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,4509 @@
|
|
|
1
|
+
# ruff: noqa: F821
|
|
2
|
+
# Dependencies are injected at runtime from alysis_code.cli to preserve monkeypatch surfaces.
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import socket
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
from click import get_current_context
|
|
15
|
+
from click.core import ParameterSource
|
|
16
|
+
|
|
17
|
+
from ..assets import AssetError
|
|
18
|
+
from ..assets.budget_allocator import (
|
|
19
|
+
TaskAssetAllocation,
|
|
20
|
+
allocate_task_assets,
|
|
21
|
+
write_task_asset_allocation,
|
|
22
|
+
)
|
|
23
|
+
from ..assets.surface import build_asset_surface
|
|
24
|
+
from ..assets.usage_logger import AssetUsageLogger
|
|
25
|
+
from ..assets.worker_mirror import TaskAssetMirror, mirror_task_assets
|
|
26
|
+
from ..assets.worker_section import render_relevant_assets_section
|
|
27
|
+
from ..assets.worker_tools import build_worker_asset_mcp_manager, compose_worker_asset_mcp_manager
|
|
28
|
+
from ..branding import env_get
|
|
29
|
+
from ..error_text import sanitize_error_summary, sanitize_optional_error_summary
|
|
30
|
+
from ..failure_category import FailureCategory
|
|
31
|
+
from ..forge_events import (
|
|
32
|
+
EVENT_PLAN_INVALID,
|
|
33
|
+
EVENT_PLAN_SAVED,
|
|
34
|
+
EVENT_REVIEW_RESULT,
|
|
35
|
+
EVENT_SCOPE_AMENDED,
|
|
36
|
+
EVENT_TASK_COMPLETED,
|
|
37
|
+
EVENT_TASK_FAILED,
|
|
38
|
+
EVENT_TASK_STARTED,
|
|
39
|
+
EVENT_VERIFICATION_RESULT,
|
|
40
|
+
EVENT_VERIFICATION_UNAVAILABLE,
|
|
41
|
+
EXIT_ERROR,
|
|
42
|
+
EXIT_NOT_ACCEPTED,
|
|
43
|
+
EXIT_OK,
|
|
44
|
+
ForgeEventEmitter,
|
|
45
|
+
)
|
|
46
|
+
from ..model_registry import ModelRegistry
|
|
47
|
+
from ..plan_repair import (
|
|
48
|
+
PLAN_STATUS_DRAFT,
|
|
49
|
+
apply_plan_status,
|
|
50
|
+
plan_repair_event_payload,
|
|
51
|
+
)
|
|
52
|
+
from ..plan_validation import PlannerFailedError, raise_for_execution_ready_plan
|
|
53
|
+
from ..replanning import resolve_replanning_mode
|
|
54
|
+
from ..run_state import RUN_STATUS_FAILED, RUN_STATUS_RUNNING
|
|
55
|
+
from ..runtime_kind import RuntimeKind
|
|
56
|
+
from ..swarm_orchestrator import acquire_swarm_mutation_guard
|
|
57
|
+
from ..swarm_scheduler import (
|
|
58
|
+
SUCCESSFUL_TERMINAL_STATUSES,
|
|
59
|
+
TaskCandidate,
|
|
60
|
+
canonical_task_status,
|
|
61
|
+
select_task_candidates,
|
|
62
|
+
)
|
|
63
|
+
from ..task_readiness import is_clearly_non_mutating_task
|
|
64
|
+
from ..task_scope import (
|
|
65
|
+
apply_scope_amendments,
|
|
66
|
+
assess_scope_changes,
|
|
67
|
+
describe_scope_violations,
|
|
68
|
+
is_non_material_untracked_path,
|
|
69
|
+
normalize_scope_patterns,
|
|
70
|
+
relocate_known_scratch_artifacts,
|
|
71
|
+
)
|
|
72
|
+
from ..verification_repair import (
|
|
73
|
+
TASK_STATUS_COMPLETED_UNVERIFIED,
|
|
74
|
+
RepairAttemptExecution,
|
|
75
|
+
build_repair_instruction,
|
|
76
|
+
resolve_repair_attempt_budget,
|
|
77
|
+
run_verification_repair_loop,
|
|
78
|
+
verification_failure_excerpts,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
_PROTECTED_GLOBAL_NAMES: set[str] = set()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _events_or_null(events: Any) -> ForgeEventEmitter:
|
|
85
|
+
"""Return the caller's emitter, or an inert one so call sites stay unconditional."""
|
|
86
|
+
if isinstance(events, ForgeEventEmitter):
|
|
87
|
+
return events
|
|
88
|
+
return ForgeEventEmitter(command="forge", enabled=False)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass
|
|
92
|
+
class TaskExecutionOutcome:
|
|
93
|
+
"""What one run of :func:`execute_forge_task` produced.
|
|
94
|
+
|
|
95
|
+
``forge exec`` turns a single outcome into its exit code and terminal event;
|
|
96
|
+
``forge run`` collects one per task and decides whether the run continues.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
task_id: str
|
|
100
|
+
title: str
|
|
101
|
+
status: str
|
|
102
|
+
success: bool
|
|
103
|
+
exit_code: int
|
|
104
|
+
summary: str
|
|
105
|
+
report_path: Path
|
|
106
|
+
patch_path: Path
|
|
107
|
+
merge_conflict: bool
|
|
108
|
+
conflict_review_path: Path | None
|
|
109
|
+
payload: dict[str, Any] = field(default_factory=dict)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class ForgeTaskExecutionError(Exception):
|
|
113
|
+
"""The task could not be executed at all -- a command error, not a failed task.
|
|
114
|
+
|
|
115
|
+
A task that ran and was rejected comes back as an outcome with
|
|
116
|
+
``success=False`` (exit 1). This is the other case: bad git state, missing
|
|
117
|
+
branch context, nothing to execute against. Callers report it as exit 2, and
|
|
118
|
+
a sequential run stops rather than moving to the next task.
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
def __init__(
|
|
122
|
+
self,
|
|
123
|
+
message: str,
|
|
124
|
+
*,
|
|
125
|
+
kind: str = "forge_error",
|
|
126
|
+
task_id: str | None = None,
|
|
127
|
+
) -> None:
|
|
128
|
+
super().__init__(message)
|
|
129
|
+
self.kind = str(kind)
|
|
130
|
+
self.task_id = task_id
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _sequential_conflict_report_lines(
|
|
134
|
+
*,
|
|
135
|
+
root: Path,
|
|
136
|
+
task_id: str,
|
|
137
|
+
base_branch: str | None,
|
|
138
|
+
task_branch: str | None,
|
|
139
|
+
review_path: Path | None,
|
|
140
|
+
) -> list[str]:
|
|
141
|
+
"""Describe an unresolved merge conflict and how to finish it.
|
|
142
|
+
|
|
143
|
+
The sequential path stops at a conflict on purpose instead of starting the
|
|
144
|
+
worktree-based resolver agent, so this report has to carry everything the
|
|
145
|
+
next step needs: where the conflict is, how to land it by hand, and how to
|
|
146
|
+
resume the run.
|
|
147
|
+
"""
|
|
148
|
+
base = base_branch or "the base branch"
|
|
149
|
+
branch = task_branch or "the task branch"
|
|
150
|
+
workspace = os.fspath(root)
|
|
151
|
+
lines = [
|
|
152
|
+
f"Merge conflict: {branch} did not merge into {base}. The sequential run "
|
|
153
|
+
"stopped here and started no resolver agent.",
|
|
154
|
+
]
|
|
155
|
+
if review_path is not None:
|
|
156
|
+
lines.append(f"Conflict review: {os.fspath(review_path)}")
|
|
157
|
+
lines.append(
|
|
158
|
+
f"Finish it by hand: `git -C {workspace} checkout {base}` then "
|
|
159
|
+
f"`git -C {workspace} merge --no-ff {branch}`, resolve the conflicted "
|
|
160
|
+
"files, and commit."
|
|
161
|
+
)
|
|
162
|
+
lines.append(f"Then continue the remaining tasks with `alysis forge run --path {workspace}`.")
|
|
163
|
+
lines.append(
|
|
164
|
+
"To let an agent attempt the resolution instead, re-run this task with "
|
|
165
|
+
f"`alysis forge exec {task_id} --pr --auto-resolve-conflicts "
|
|
166
|
+
f"--path {workspace}`."
|
|
167
|
+
)
|
|
168
|
+
return lines
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# --- sequential run scheduling -------------------------------------------------
|
|
172
|
+
#
|
|
173
|
+
# `forge run` reuses the swarm scheduler's readiness rules (dependencies, retry
|
|
174
|
+
# eligibility, attempt limits) but never its batching: the next task is simply the
|
|
175
|
+
# first ready one, recomputed after each task so a task that just finished unblocks
|
|
176
|
+
# its dependents.
|
|
177
|
+
|
|
178
|
+
_NON_EXECUTABLE_TASK_STATUSES = frozenset({"superseded", "invalidated"})
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _parse_only_task_ids(only: str | None) -> set[str] | None:
|
|
182
|
+
if not isinstance(only, str):
|
|
183
|
+
return None
|
|
184
|
+
ids = {part.strip() for part in only.split(",") if part.strip()}
|
|
185
|
+
return ids or None
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _plan_task_dicts(plan: dict[str, Any]) -> list[dict[str, Any]]:
|
|
189
|
+
tasks = plan.get("tasks")
|
|
190
|
+
if not isinstance(tasks, list):
|
|
191
|
+
return []
|
|
192
|
+
return [task for task in tasks if isinstance(task, dict)]
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _sequential_candidates(
|
|
196
|
+
plan: dict[str, Any],
|
|
197
|
+
*,
|
|
198
|
+
retry_failed: bool,
|
|
199
|
+
retry_changes_requested: bool,
|
|
200
|
+
max_attempts: int | None,
|
|
201
|
+
only_ids: set[str] | None,
|
|
202
|
+
exclude_ids: set[str] | None = None,
|
|
203
|
+
) -> list[TaskCandidate]:
|
|
204
|
+
runnable, _ready_for_merge, _skipped = select_task_candidates(
|
|
205
|
+
tasks=_plan_task_dicts(plan),
|
|
206
|
+
retry_failed=retry_failed,
|
|
207
|
+
retry_changes_requested=retry_changes_requested,
|
|
208
|
+
max_attempts=max_attempts,
|
|
209
|
+
only_ids=only_ids,
|
|
210
|
+
)
|
|
211
|
+
excluded = exclude_ids or set()
|
|
212
|
+
return [candidate for candidate in runnable if candidate.task_id not in excluded]
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _next_sequential_task(
|
|
216
|
+
plan: dict[str, Any],
|
|
217
|
+
*,
|
|
218
|
+
retry_failed: bool,
|
|
219
|
+
retry_changes_requested: bool,
|
|
220
|
+
max_attempts: int | None,
|
|
221
|
+
only_ids: set[str] | None,
|
|
222
|
+
exclude_ids: set[str] | None = None,
|
|
223
|
+
) -> TaskCandidate | None:
|
|
224
|
+
"""First task whose dependencies are satisfied, or None when none are ready.
|
|
225
|
+
|
|
226
|
+
``exclude_ids`` holds what this run already attempted. Without it a retried
|
|
227
|
+
task that fails again would be re-selected forever, because failing is exactly
|
|
228
|
+
the status ``--retry-failed`` asks the scheduler to accept.
|
|
229
|
+
"""
|
|
230
|
+
candidates = _sequential_candidates(
|
|
231
|
+
plan,
|
|
232
|
+
retry_failed=retry_failed,
|
|
233
|
+
retry_changes_requested=retry_changes_requested,
|
|
234
|
+
max_attempts=max_attempts,
|
|
235
|
+
only_ids=only_ids,
|
|
236
|
+
exclude_ids=exclude_ids,
|
|
237
|
+
)
|
|
238
|
+
return candidates[0] if candidates else None
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _projected_sequential_order(
|
|
242
|
+
plan: dict[str, Any],
|
|
243
|
+
*,
|
|
244
|
+
retry_failed: bool,
|
|
245
|
+
retry_changes_requested: bool,
|
|
246
|
+
max_attempts: int | None,
|
|
247
|
+
only_ids: set[str] | None,
|
|
248
|
+
max_tasks: int | None,
|
|
249
|
+
) -> list[str]:
|
|
250
|
+
"""The order tasks would run in, assuming each one succeeds.
|
|
251
|
+
|
|
252
|
+
Dependencies only unblock as tasks finish, so the order has to be simulated
|
|
253
|
+
rather than read off a single scheduling pass. Shallow task copies are enough:
|
|
254
|
+
the simulation only rewrites ``status``.
|
|
255
|
+
"""
|
|
256
|
+
tasks = [dict(task) for task in _plan_task_dicts(plan)]
|
|
257
|
+
simulated = {"tasks": tasks}
|
|
258
|
+
order: list[str] = []
|
|
259
|
+
seen: set[str] = set()
|
|
260
|
+
while max_tasks is None or len(order) < max_tasks:
|
|
261
|
+
candidate = _next_sequential_task(
|
|
262
|
+
simulated,
|
|
263
|
+
retry_failed=retry_failed,
|
|
264
|
+
retry_changes_requested=retry_changes_requested,
|
|
265
|
+
max_attempts=max_attempts,
|
|
266
|
+
only_ids=only_ids,
|
|
267
|
+
exclude_ids=seen,
|
|
268
|
+
)
|
|
269
|
+
if candidate is None:
|
|
270
|
+
break
|
|
271
|
+
order.append(candidate.task_id)
|
|
272
|
+
seen.add(candidate.task_id)
|
|
273
|
+
for task in tasks:
|
|
274
|
+
if str(task.get("id") or "").strip() == candidate.task_id:
|
|
275
|
+
task["status"] = "done"
|
|
276
|
+
break
|
|
277
|
+
return order
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _unfinished_task_ids(plan: dict[str, Any]) -> list[str]:
|
|
281
|
+
"""Tasks that still represent outstanding work after the run."""
|
|
282
|
+
unfinished: list[str] = []
|
|
283
|
+
for task in _plan_task_dicts(plan):
|
|
284
|
+
task_id = str(task.get("id") or "").strip()
|
|
285
|
+
if not task_id:
|
|
286
|
+
continue
|
|
287
|
+
status = canonical_task_status(str(task.get("status") or ""))
|
|
288
|
+
if status in SUCCESSFUL_TERMINAL_STATUSES or status in _NON_EXECUTABLE_TASK_STATUSES:
|
|
289
|
+
continue
|
|
290
|
+
unfinished.append(task_id)
|
|
291
|
+
return unfinished
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _bump_task_attempt(paths: Any, plan: dict[str, Any], task: dict[str, Any]) -> int:
|
|
295
|
+
"""Record that this run is about to try the task, so --max-attempts can bite."""
|
|
296
|
+
raw = task.get("attempts")
|
|
297
|
+
try:
|
|
298
|
+
attempts = int(raw) if raw is not None else 0
|
|
299
|
+
except (TypeError, ValueError):
|
|
300
|
+
attempts = 0
|
|
301
|
+
attempts = max(0, attempts) + 1
|
|
302
|
+
task["attempts"] = attempts
|
|
303
|
+
save_plan(paths, plan)
|
|
304
|
+
return attempts
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
class _SequentialRunLog:
|
|
308
|
+
"""One appended line per lifecycle step of a sequential run.
|
|
309
|
+
|
|
310
|
+
Per-task agent logs stay where they are; this is the run-level narrative, in
|
|
311
|
+
order, in one file -- the thing a swarm run cannot have because its tasks
|
|
312
|
+
interleave.
|
|
313
|
+
"""
|
|
314
|
+
|
|
315
|
+
def __init__(self) -> None:
|
|
316
|
+
self._path: Path | None = None
|
|
317
|
+
|
|
318
|
+
def bind(self, paths: Any) -> None:
|
|
319
|
+
ensure_execution_dirs(paths)
|
|
320
|
+
self._path = paths.execution_dir / "sequential_run.jsonl"
|
|
321
|
+
|
|
322
|
+
@property
|
|
323
|
+
def path(self) -> Path | None:
|
|
324
|
+
return self._path
|
|
325
|
+
|
|
326
|
+
def append(self, event: str, **fields: Any) -> None:
|
|
327
|
+
if self._path is None:
|
|
328
|
+
return
|
|
329
|
+
record: dict[str, Any] = {"ts": now_iso(), "event": str(event)}
|
|
330
|
+
record.update(fields)
|
|
331
|
+
try:
|
|
332
|
+
with self._path.open("a", encoding="utf-8") as handle:
|
|
333
|
+
handle.write(json.dumps(record, ensure_ascii=True, default=str) + "\n")
|
|
334
|
+
except OSError:
|
|
335
|
+
# A run log that cannot be written must not take the run down with it.
|
|
336
|
+
return
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _sequential_outcome_payload(outcome: TaskExecutionOutcome) -> dict[str, Any]:
|
|
340
|
+
return {
|
|
341
|
+
"task_id": outcome.task_id,
|
|
342
|
+
"title": outcome.title,
|
|
343
|
+
"status": outcome.status,
|
|
344
|
+
"success": bool(outcome.success),
|
|
345
|
+
"summary": outcome.summary,
|
|
346
|
+
"report": os.fspath(outcome.report_path),
|
|
347
|
+
"patch": os.fspath(outcome.patch_path),
|
|
348
|
+
"merge_conflict": bool(outcome.merge_conflict),
|
|
349
|
+
"verify_summary": outcome.payload.get("verify_summary"),
|
|
350
|
+
"branch": outcome.payload.get("branch"),
|
|
351
|
+
"commit": outcome.payload.get("commit"),
|
|
352
|
+
"merge_result": outcome.payload.get("merge_result"),
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _write_sequential_summary(
|
|
357
|
+
*,
|
|
358
|
+
paths: Any,
|
|
359
|
+
run_id: str,
|
|
360
|
+
started_at: str,
|
|
361
|
+
outcomes: list[TaskExecutionOutcome],
|
|
362
|
+
stopped_reason: str,
|
|
363
|
+
exit_code: int,
|
|
364
|
+
pr: bool,
|
|
365
|
+
verify_mode: str,
|
|
366
|
+
scope_mode: str,
|
|
367
|
+
remaining: list[str] | None = None,
|
|
368
|
+
error: str | None = None,
|
|
369
|
+
) -> dict[str, Any]:
|
|
370
|
+
"""Persist the run outcome next to the swarm's, in the same execution dir."""
|
|
371
|
+
executed = [_sequential_outcome_payload(item) for item in outcomes]
|
|
372
|
+
payload: dict[str, Any] = {
|
|
373
|
+
"schema_version": 1,
|
|
374
|
+
"command": "forge.run",
|
|
375
|
+
"run_id": run_id,
|
|
376
|
+
"started_at": started_at,
|
|
377
|
+
"finished_at": now_iso(),
|
|
378
|
+
"engine": "sequential",
|
|
379
|
+
"worktrees_used": False,
|
|
380
|
+
"parallel": 1,
|
|
381
|
+
"git_flow": bool(pr),
|
|
382
|
+
"verify": verify_mode,
|
|
383
|
+
"scope": scope_mode,
|
|
384
|
+
"stopped_reason": stopped_reason,
|
|
385
|
+
"exit_code": int(exit_code),
|
|
386
|
+
"executed": executed,
|
|
387
|
+
"counts": {
|
|
388
|
+
"executed": len(executed),
|
|
389
|
+
"succeeded": sum(1 for item in executed if item["success"]),
|
|
390
|
+
"failed": sum(1 for item in executed if not item["success"]),
|
|
391
|
+
},
|
|
392
|
+
"remaining": list(remaining or []),
|
|
393
|
+
}
|
|
394
|
+
if error:
|
|
395
|
+
payload["error"] = error
|
|
396
|
+
summary_path = paths.execution_dir / "sequential_summary.json"
|
|
397
|
+
try:
|
|
398
|
+
ensure_execution_dirs(paths)
|
|
399
|
+
summary_path.write_text(
|
|
400
|
+
json.dumps(payload, indent=2, sort_keys=True, default=str) + "\n",
|
|
401
|
+
encoding="utf-8",
|
|
402
|
+
)
|
|
403
|
+
payload["summary_path"] = os.fspath(summary_path)
|
|
404
|
+
except OSError:
|
|
405
|
+
# Reporting the run must not be able to fail the run.
|
|
406
|
+
pass
|
|
407
|
+
return payload
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _print_sequential_order(console: Any, plan: dict[str, Any], order: list[str]) -> None:
|
|
411
|
+
table = _Table(title="Execution order")
|
|
412
|
+
table.add_column("#")
|
|
413
|
+
table.add_column("task")
|
|
414
|
+
table.add_column("title")
|
|
415
|
+
if not order:
|
|
416
|
+
console.print("[yellow]No ready tasks to execute.[/yellow]")
|
|
417
|
+
return
|
|
418
|
+
titles = {
|
|
419
|
+
str(task.get("id") or "").strip(): str(task.get("title") or "").strip()
|
|
420
|
+
for task in _plan_task_dicts(plan)
|
|
421
|
+
}
|
|
422
|
+
for index, task_id in enumerate(order, start=1):
|
|
423
|
+
table.add_row(str(index), task_id, titles.get(task_id, ""))
|
|
424
|
+
console.print(table)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _print_sequential_summary(console: Any, payload: dict[str, Any]) -> None:
|
|
428
|
+
counts = payload.get("counts") or {}
|
|
429
|
+
table = _Table(title="forge run")
|
|
430
|
+
table.add_column("task")
|
|
431
|
+
table.add_column("status")
|
|
432
|
+
table.add_column("report")
|
|
433
|
+
for item in payload.get("executed") or []:
|
|
434
|
+
table.add_row(
|
|
435
|
+
str(item.get("task_id") or ""),
|
|
436
|
+
str(item.get("status") or ""),
|
|
437
|
+
str(item.get("report") or ""),
|
|
438
|
+
)
|
|
439
|
+
console.print(table)
|
|
440
|
+
console.print(
|
|
441
|
+
f"Executed: {counts.get('executed', 0)} | "
|
|
442
|
+
f"succeeded: {counts.get('succeeded', 0)} | "
|
|
443
|
+
f"failed: {counts.get('failed', 0)} | "
|
|
444
|
+
f"stopped: {payload.get('stopped_reason')}"
|
|
445
|
+
)
|
|
446
|
+
remaining = payload.get("remaining") or []
|
|
447
|
+
if remaining:
|
|
448
|
+
preview = ", ".join(str(item) for item in remaining[:10])
|
|
449
|
+
if len(remaining) > 10:
|
|
450
|
+
preview += ", ..."
|
|
451
|
+
console.print(f"[yellow]Unfinished tasks:[/yellow] {preview}")
|
|
452
|
+
# A stopped run leaves the checkout wherever the failing task left it, which is
|
|
453
|
+
# the first thing you need to know before touching the repository again.
|
|
454
|
+
if payload.get("stopped_reason") == "task_failed":
|
|
455
|
+
failed = next(
|
|
456
|
+
(item for item in reversed(payload.get("executed") or []) if not item.get("success")),
|
|
457
|
+
None,
|
|
458
|
+
)
|
|
459
|
+
if failed is not None:
|
|
460
|
+
branch = str(failed.get("branch") or "").strip()
|
|
461
|
+
location = f" on branch {branch}" if branch else ""
|
|
462
|
+
console.print(
|
|
463
|
+
f"[yellow]Run stopped at {failed.get('task_id')}[/yellow]"
|
|
464
|
+
f"{location}; nothing after it was started. "
|
|
465
|
+
"Fix it and re-run `forge run` to continue, or pass --keep-going to skip "
|
|
466
|
+
"past failures next time."
|
|
467
|
+
)
|
|
468
|
+
summary_path = payload.get("summary_path")
|
|
469
|
+
if summary_path:
|
|
470
|
+
console.print(f"Summary: {summary_path}")
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _exec_error_exit(
|
|
474
|
+
console: Any,
|
|
475
|
+
events: ForgeEventEmitter,
|
|
476
|
+
message: str,
|
|
477
|
+
*,
|
|
478
|
+
task_id: str | None = None,
|
|
479
|
+
kind: str = "forge_error",
|
|
480
|
+
) -> typer.Exit:
|
|
481
|
+
"""Report a `forge exec` command error on both surfaces and build the Exit."""
|
|
482
|
+
console.print(f"[red]Forge error:[/red] {message}")
|
|
483
|
+
events.error(
|
|
484
|
+
message=str(message),
|
|
485
|
+
kind=kind,
|
|
486
|
+
exit_code=EXIT_ERROR,
|
|
487
|
+
data={"task_id": task_id} if task_id else None,
|
|
488
|
+
)
|
|
489
|
+
return typer.Exit(code=EXIT_ERROR)
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _emit_verification_unavailable(
|
|
493
|
+
events: ForgeEventEmitter,
|
|
494
|
+
*,
|
|
495
|
+
task_id: str,
|
|
496
|
+
policy: str,
|
|
497
|
+
reason: str,
|
|
498
|
+
blocking: bool,
|
|
499
|
+
outcome: str | None = None,
|
|
500
|
+
) -> None:
|
|
501
|
+
"""Report that verification did not run, and whether that blocked the task.
|
|
502
|
+
|
|
503
|
+
``outcome`` names the task status this produced, so a machine consumer can tell
|
|
504
|
+
"kept, but nothing checked it" (``completed_unverified``) apart from the merely
|
|
505
|
+
informational unavailable notices that leave the outcome untouched.
|
|
506
|
+
"""
|
|
507
|
+
payload: dict[str, Any] = {
|
|
508
|
+
"scope": "task",
|
|
509
|
+
"task_id": task_id,
|
|
510
|
+
"policy": policy,
|
|
511
|
+
"reason": reason,
|
|
512
|
+
"blocking": bool(blocking),
|
|
513
|
+
}
|
|
514
|
+
if outcome:
|
|
515
|
+
payload["outcome"] = str(outcome)
|
|
516
|
+
events.emit(EVENT_VERIFICATION_UNAVAILABLE, payload)
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def _attempt_verification_repair(
|
|
520
|
+
*,
|
|
521
|
+
attempt: int,
|
|
522
|
+
failing_result: Any,
|
|
523
|
+
max_attempts: int,
|
|
524
|
+
task_id: str,
|
|
525
|
+
base_instruction: str,
|
|
526
|
+
verify_artifact_path: Path,
|
|
527
|
+
run_repair_agent: Any,
|
|
528
|
+
run_verification: Any,
|
|
529
|
+
root: Path,
|
|
530
|
+
commit_message: str,
|
|
531
|
+
) -> RepairAttemptExecution:
|
|
532
|
+
"""Run one repair attempt: prompt with the failure, fix, commit, re-verify.
|
|
533
|
+
|
|
534
|
+
Nothing here decides the task's outcome -- it reports what happened and lets
|
|
535
|
+
:func:`run_verification_repair_loop` decide whether to keep going.
|
|
536
|
+
"""
|
|
537
|
+
instruction = build_repair_instruction(
|
|
538
|
+
base_instruction=base_instruction,
|
|
539
|
+
task_id=task_id,
|
|
540
|
+
attempt=attempt,
|
|
541
|
+
max_attempts=max_attempts,
|
|
542
|
+
verify_summary=str(getattr(failing_result, "summary", "")),
|
|
543
|
+
excerpts=verification_failure_excerpts(failing_result),
|
|
544
|
+
artifact_path=os.fspath(verify_artifact_path),
|
|
545
|
+
)
|
|
546
|
+
try:
|
|
547
|
+
exit_code = int(run_repair_agent(instruction, attempt))
|
|
548
|
+
except Exception as e: # noqa: BLE001
|
|
549
|
+
return RepairAttemptExecution(
|
|
550
|
+
agent_exit_code=1,
|
|
551
|
+
verify_result=None,
|
|
552
|
+
error=f"repair agent raised: {sanitize_error_summary(str(e))}",
|
|
553
|
+
)
|
|
554
|
+
if exit_code != 0:
|
|
555
|
+
# A crashed repair agent leaves the tree in an unknown state; re-verifying it
|
|
556
|
+
# would attribute its mess to the task's own work.
|
|
557
|
+
return RepairAttemptExecution(
|
|
558
|
+
agent_exit_code=exit_code,
|
|
559
|
+
verify_result=None,
|
|
560
|
+
error=f"repair agent exited non-zero ({exit_code})",
|
|
561
|
+
)
|
|
562
|
+
|
|
563
|
+
committed = False
|
|
564
|
+
try:
|
|
565
|
+
commit_hash = _stage_and_commit_task_changes(root, message=commit_message)
|
|
566
|
+
committed = commit_hash is not None
|
|
567
|
+
except GitOpsError as e:
|
|
568
|
+
return RepairAttemptExecution(
|
|
569
|
+
agent_exit_code=exit_code,
|
|
570
|
+
verify_result=None,
|
|
571
|
+
error=f"could not commit the repair attempt: {e}",
|
|
572
|
+
)
|
|
573
|
+
if not committed:
|
|
574
|
+
# The agent changed nothing, so re-running the same commands would produce
|
|
575
|
+
# the same failure. Stop rather than burn the rest of the budget.
|
|
576
|
+
return RepairAttemptExecution(
|
|
577
|
+
agent_exit_code=exit_code,
|
|
578
|
+
verify_result=None,
|
|
579
|
+
error="the repair attempt made no repository changes",
|
|
580
|
+
)
|
|
581
|
+
|
|
582
|
+
changed_files: tuple[str, ...] = ()
|
|
583
|
+
try:
|
|
584
|
+
changed_files = tuple(changed_files_between(root, revspec="HEAD~1..HEAD"))
|
|
585
|
+
except GitOpsError:
|
|
586
|
+
changed_files = ()
|
|
587
|
+
|
|
588
|
+
return RepairAttemptExecution(
|
|
589
|
+
agent_exit_code=exit_code,
|
|
590
|
+
verify_result=run_verification(),
|
|
591
|
+
committed=True,
|
|
592
|
+
changed_files=changed_files,
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def _stage_and_commit_task_changes(root: Path, *, message: str) -> str | None:
|
|
597
|
+
"""Stage the workspace and commit it, skipping protected and non-material paths.
|
|
598
|
+
|
|
599
|
+
Returns the new commit hash, or ``None`` when there was nothing to commit. Used
|
|
600
|
+
by verification repair attempts, which run after the task's own commit and must
|
|
601
|
+
land their fix on the same branch before verification is re-run.
|
|
602
|
+
"""
|
|
603
|
+
non_material_untracked_paths = list_untracked_packaging_metadata_paths(root)
|
|
604
|
+
stage_all(root)
|
|
605
|
+
unstage_staged_prefixes(root, [".alysis", ".alysis_images", "alysis-feedback"])
|
|
606
|
+
ensure_not_staged_prefixes(root, [".alysis", ".alysis_images", "alysis-feedback"])
|
|
607
|
+
if non_material_untracked_paths:
|
|
608
|
+
unstage_staged_paths(root, non_material_untracked_paths)
|
|
609
|
+
ensure_not_staged_paths(root, non_material_untracked_paths)
|
|
610
|
+
staged_now = staged_files(root)
|
|
611
|
+
if staged_now and has_grounded_rust_target_runtime_artifacts(root):
|
|
612
|
+
unstage_staged_runtime_artifacts(root, current_paths=staged_now)
|
|
613
|
+
staged_now = staged_files(root)
|
|
614
|
+
ensure_not_staged_runtime_artifacts(root, current_paths=staged_now)
|
|
615
|
+
if not staged_now:
|
|
616
|
+
return None
|
|
617
|
+
return commit_all(root, message=message)
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def _plan_execution_readiness(plan: dict[str, Any]) -> str | None:
|
|
621
|
+
"""Return why the plan cannot be executed yet, or None when it is ready."""
|
|
622
|
+
try:
|
|
623
|
+
return _forge_no_execution_ready_tasks_message(plan)
|
|
624
|
+
except Exception: # noqa: BLE001
|
|
625
|
+
# Readiness is reporting metadata; never let it break the command it describes.
|
|
626
|
+
return None
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def _sync_cli_globals(cli_mod: Any) -> None:
|
|
630
|
+
module_globals = globals()
|
|
631
|
+
if not _PROTECTED_GLOBAL_NAMES:
|
|
632
|
+
for local_name, local_value in module_globals.items():
|
|
633
|
+
if callable(local_value):
|
|
634
|
+
_PROTECTED_GLOBAL_NAMES.add(local_name)
|
|
635
|
+
for name, value in cli_mod.__dict__.items():
|
|
636
|
+
if name.startswith("__") or name in _PROTECTED_GLOBAL_NAMES:
|
|
637
|
+
continue
|
|
638
|
+
module_globals[name] = value
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def _path_binding_source(ctx: Any = None, path: Path | None = None) -> str:
|
|
642
|
+
current_ctx = ctx if ctx is not None else get_current_context(silent=True)
|
|
643
|
+
path_source = current_ctx.get_parameter_source("path") if current_ctx is not None else None
|
|
644
|
+
if path_source is not None and path_source is not ParameterSource.DEFAULT:
|
|
645
|
+
return "explicit_path"
|
|
646
|
+
if path is None and current_ctx is not None:
|
|
647
|
+
raw_path = current_ctx.params.get("path")
|
|
648
|
+
path = Path(raw_path) if raw_path is not None else None
|
|
649
|
+
if path is None:
|
|
650
|
+
frame = inspect.currentframe()
|
|
651
|
+
caller = frame.f_back if frame is not None else None
|
|
652
|
+
raw_path = caller.f_locals.get("path") if caller is not None else None
|
|
653
|
+
path = Path(raw_path) if raw_path is not None else None
|
|
654
|
+
if path is not None and Path(path) != Path("."):
|
|
655
|
+
return "explicit_path"
|
|
656
|
+
|
|
657
|
+
if path_source is None or path_source is ParameterSource.DEFAULT:
|
|
658
|
+
return "cwd"
|
|
659
|
+
return "explicit_path"
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def _mark_run_status(
|
|
663
|
+
paths: Any,
|
|
664
|
+
status: str,
|
|
665
|
+
*,
|
|
666
|
+
reason: str = "",
|
|
667
|
+
plan: dict[str, Any] | None = None,
|
|
668
|
+
mode: str | None = None,
|
|
669
|
+
) -> None:
|
|
670
|
+
"""Record a run lifecycle transition on the current-run pointer.
|
|
671
|
+
|
|
672
|
+
Best-effort by design: a pointer that cannot be written is a stale status line
|
|
673
|
+
in a UI, never a reason to fail the execution it describes.
|
|
674
|
+
"""
|
|
675
|
+
from ..forge import set_current_run_status
|
|
676
|
+
from ..run_state import RUN_STATUS_RUNNING, build_run_owner
|
|
677
|
+
|
|
678
|
+
owner = None
|
|
679
|
+
if status == RUN_STATUS_RUNNING:
|
|
680
|
+
owner = build_run_owner(
|
|
681
|
+
pid=os.getpid(),
|
|
682
|
+
hostname=socket.gethostname(),
|
|
683
|
+
mode=mode or "forge",
|
|
684
|
+
)
|
|
685
|
+
try:
|
|
686
|
+
set_current_run_status(paths, status, reason=reason, owner=owner, plan=plan)
|
|
687
|
+
except Exception: # noqa: BLE001 - see docstring
|
|
688
|
+
return
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def _mark_run_interrupted_if_still_running(paths: Any, *, reason: str) -> None:
|
|
692
|
+
"""Close out a run whose process is leaving without having recorded an outcome.
|
|
693
|
+
|
|
694
|
+
Every normal exit path stamps a terminal status first, so reaching here means
|
|
695
|
+
an exception, a ``KeyboardInterrupt``, or a code path that forgot -- all of
|
|
696
|
+
which leave resumable work behind, which is what ``interrupted`` means.
|
|
697
|
+
"""
|
|
698
|
+
from ..forge import current_run_status
|
|
699
|
+
from ..run_state import RUN_STATUS_INTERRUPTED, RUN_STATUS_RUNNING
|
|
700
|
+
|
|
701
|
+
try:
|
|
702
|
+
if current_run_status(paths.root) != RUN_STATUS_RUNNING:
|
|
703
|
+
return
|
|
704
|
+
except Exception: # noqa: BLE001
|
|
705
|
+
return
|
|
706
|
+
_mark_run_status(paths, RUN_STATUS_INTERRUPTED, reason=reason)
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def _terminal_run_status_for(*, plan: dict[str, Any], any_failure: bool) -> tuple[str, str]:
|
|
710
|
+
"""Map an execution's result onto the run lifecycle enum.
|
|
711
|
+
|
|
712
|
+
Unfinished tasks left by ``--only`` or ``--max-tasks`` are a scope the caller
|
|
713
|
+
chose, not lost work, so they land back on ``approved`` (executable, idle)
|
|
714
|
+
rather than ``interrupted`` (something went wrong).
|
|
715
|
+
"""
|
|
716
|
+
from ..run_state import RUN_STATUS_APPROVED, RUN_STATUS_COMPLETED
|
|
717
|
+
|
|
718
|
+
if any_failure:
|
|
719
|
+
return RUN_STATUS_FAILED, "execution finished with at least one task not accepted"
|
|
720
|
+
remaining = _unfinished_task_ids(plan)
|
|
721
|
+
if remaining:
|
|
722
|
+
return (
|
|
723
|
+
RUN_STATUS_APPROVED,
|
|
724
|
+
f"execution finished; {len(remaining)} task(s) still unexecuted",
|
|
725
|
+
)
|
|
726
|
+
return RUN_STATUS_COMPLETED, "every plan task reached a successful terminal status"
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
def _missing_swarm_run_error(*, binding: WorkspaceBinding) -> ForgeError:
|
|
730
|
+
requested = os.fspath(binding.requested_path)
|
|
731
|
+
return ForgeError(
|
|
732
|
+
"No current forge run was found for this workspace. "
|
|
733
|
+
f"Start with `alysis forge plan --path {requested}` or enter Forge "
|
|
734
|
+
"from chat after starting alysis inside a project folder."
|
|
735
|
+
)
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
def _print_forge_lock_wait_notice(console: Any, info: dict[str, Any]) -> None:
|
|
739
|
+
diagnostic = str(info.get("diagnostic") or "").strip()
|
|
740
|
+
console.print(
|
|
741
|
+
"[yellow]Forge execution queued:[/yellow] another execution is mutating this workspace; waiting for it to finish."
|
|
742
|
+
)
|
|
743
|
+
if diagnostic:
|
|
744
|
+
console.print(f"[dim]{diagnostic}[/dim]")
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
def _render_planner_reply(
|
|
748
|
+
*, console: Any, message: str, questions: list[str] | None = None
|
|
749
|
+
) -> None:
|
|
750
|
+
console.print("[bold]Planner:[/bold]")
|
|
751
|
+
console.print(message)
|
|
752
|
+
if questions:
|
|
753
|
+
console.print("[dim]Planner questions[/dim]")
|
|
754
|
+
for question in questions:
|
|
755
|
+
console.print(f"- {question}")
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
def _merge_changed_files(*path_groups: list[str]) -> list[str]:
|
|
759
|
+
seen: set[str] = set()
|
|
760
|
+
merged: list[str] = []
|
|
761
|
+
for group in path_groups:
|
|
762
|
+
for raw in group:
|
|
763
|
+
value = str(raw).strip().replace("\\", "/")
|
|
764
|
+
while value.startswith("./"):
|
|
765
|
+
value = value[2:]
|
|
766
|
+
if not value or value in seen:
|
|
767
|
+
continue
|
|
768
|
+
seen.add(value)
|
|
769
|
+
merged.append(value)
|
|
770
|
+
return merged
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
def _runtime_snapshot_changed_files(
|
|
774
|
+
before_snapshot: dict[str, str],
|
|
775
|
+
after_snapshot: dict[str, str],
|
|
776
|
+
) -> list[str]:
|
|
777
|
+
changed: list[str] = []
|
|
778
|
+
for path in sorted(set(before_snapshot) | set(after_snapshot)):
|
|
779
|
+
if before_snapshot.get(path) != after_snapshot.get(path):
|
|
780
|
+
changed.append(_normalize_changed_file_path(path))
|
|
781
|
+
return [path for path in changed if path]
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
def _normalize_changed_file_path(path: str) -> str:
|
|
785
|
+
value = str(path).strip().replace("\\", "/")
|
|
786
|
+
while value.startswith("./"):
|
|
787
|
+
value = value[2:]
|
|
788
|
+
return value.strip("/")
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
def _authorized_custom_tool_runtime_side_effects(
|
|
792
|
+
*,
|
|
793
|
+
sessions_dir: Path,
|
|
794
|
+
session_id: str,
|
|
795
|
+
) -> set[str]:
|
|
796
|
+
session_log = sessions_dir / f"{session_id}.jsonl"
|
|
797
|
+
if not session_log.exists():
|
|
798
|
+
return set()
|
|
799
|
+
authorized: set[str] = set()
|
|
800
|
+
try:
|
|
801
|
+
lines = session_log.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
802
|
+
except OSError:
|
|
803
|
+
return set()
|
|
804
|
+
for line in lines:
|
|
805
|
+
if not line.strip():
|
|
806
|
+
continue
|
|
807
|
+
try:
|
|
808
|
+
event = json.loads(line)
|
|
809
|
+
except json.JSONDecodeError:
|
|
810
|
+
continue
|
|
811
|
+
if not isinstance(event, dict) or event.get("type") != "tool_result":
|
|
812
|
+
continue
|
|
813
|
+
payload = event.get("payload")
|
|
814
|
+
result = payload.get("result") if isinstance(payload, dict) else None
|
|
815
|
+
if not isinstance(result, dict) or result.get("success") is not True:
|
|
816
|
+
continue
|
|
817
|
+
side_effects = result.get("side_effects")
|
|
818
|
+
if not isinstance(side_effects, dict):
|
|
819
|
+
continue
|
|
820
|
+
writes = side_effects.get("workspace_writes")
|
|
821
|
+
if not isinstance(writes, list):
|
|
822
|
+
continue
|
|
823
|
+
for item in writes:
|
|
824
|
+
if not isinstance(item, dict):
|
|
825
|
+
continue
|
|
826
|
+
if str(item.get("scope") or "") != "tool_dir":
|
|
827
|
+
continue
|
|
828
|
+
rel_path = _normalize_changed_file_path(str(item.get("path") or ""))
|
|
829
|
+
if rel_path == ".alysis/tools" or rel_path.startswith(".alysis/tools/"):
|
|
830
|
+
authorized.add(rel_path)
|
|
831
|
+
return authorized
|
|
832
|
+
|
|
833
|
+
|
|
834
|
+
def _path_matches_authorized_runtime_prefix(path: str, prefixes: set[str]) -> bool:
|
|
835
|
+
normalized = _normalize_changed_file_path(path)
|
|
836
|
+
return any(
|
|
837
|
+
normalized == prefix or normalized.startswith(prefix.rstrip("/") + "/")
|
|
838
|
+
for prefix in prefixes
|
|
839
|
+
)
|
|
840
|
+
|
|
841
|
+
|
|
842
|
+
def _drop_parent_directory_placeholders(paths: list[str]) -> list[str]:
|
|
843
|
+
concrete_paths = [path for path in paths if path and not path.endswith("/")]
|
|
844
|
+
filtered: list[str] = []
|
|
845
|
+
for path in paths:
|
|
846
|
+
if path.endswith("/") and any(other.startswith(path) for other in concrete_paths):
|
|
847
|
+
continue
|
|
848
|
+
filtered.append(path)
|
|
849
|
+
return filtered
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
def _task_declares_explicit_write_scope(task: dict[str, Any]) -> bool:
|
|
853
|
+
raw = task.get("write_scope")
|
|
854
|
+
if isinstance(raw, list):
|
|
855
|
+
return any(str(item or "").strip() for item in raw)
|
|
856
|
+
if isinstance(raw, str):
|
|
857
|
+
return bool(raw.strip())
|
|
858
|
+
return False
|
|
859
|
+
|
|
860
|
+
|
|
861
|
+
def _task_is_analysis_only(task: dict[str, Any]) -> bool:
|
|
862
|
+
raw_flag = task.get("analysis_only")
|
|
863
|
+
if isinstance(raw_flag, bool):
|
|
864
|
+
return raw_flag
|
|
865
|
+
return is_clearly_non_mutating_task(
|
|
866
|
+
title=str(task.get("title") or "").strip(),
|
|
867
|
+
description=str(task.get("description") or "").strip(),
|
|
868
|
+
acceptance_criteria=[
|
|
869
|
+
str(item or "")
|
|
870
|
+
for item in (task.get("acceptance_criteria") or [])
|
|
871
|
+
if str(item or "").strip()
|
|
872
|
+
],
|
|
873
|
+
)
|
|
874
|
+
|
|
875
|
+
|
|
876
|
+
def _task_string_list(task: dict[str, Any], key: str) -> list[str]:
|
|
877
|
+
raw = task.get(key)
|
|
878
|
+
if raw is None:
|
|
879
|
+
return []
|
|
880
|
+
if isinstance(raw, str):
|
|
881
|
+
return [raw.strip()] if raw.strip() else []
|
|
882
|
+
if not isinstance(raw, (list, tuple)):
|
|
883
|
+
text = str(raw).strip()
|
|
884
|
+
return [text] if text else []
|
|
885
|
+
return [str(item).strip() for item in raw if str(item).strip()]
|
|
886
|
+
|
|
887
|
+
|
|
888
|
+
def _empty_forge_exec_task_asset_mirror(
|
|
889
|
+
workspace_path: Path,
|
|
890
|
+
*,
|
|
891
|
+
task_id: str = "",
|
|
892
|
+
) -> TaskAssetMirror:
|
|
893
|
+
workspace = workspace_path.resolve()
|
|
894
|
+
return TaskAssetMirror(
|
|
895
|
+
workspace_path=workspace,
|
|
896
|
+
manifest_path=workspace / ".alysis" / "task_assets" / "manifest.json",
|
|
897
|
+
primary=[],
|
|
898
|
+
may_need=[],
|
|
899
|
+
pinned=[],
|
|
900
|
+
task_id=task_id,
|
|
901
|
+
)
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
def _combined_forge_exec_image_paths(
|
|
905
|
+
*,
|
|
906
|
+
legacy_paths: list[str],
|
|
907
|
+
mirror: TaskAssetMirror,
|
|
908
|
+
allocation: TaskAssetAllocation | None,
|
|
909
|
+
cfg: Any,
|
|
910
|
+
role_model: str,
|
|
911
|
+
model_registry: ModelRegistry,
|
|
912
|
+
usage_logger: AssetUsageLogger,
|
|
913
|
+
) -> list[str] | None:
|
|
914
|
+
combined: list[str] = []
|
|
915
|
+
seen: set[str] = set()
|
|
916
|
+
|
|
917
|
+
def _append(path: str) -> None:
|
|
918
|
+
try:
|
|
919
|
+
normalized = os.fspath(Path(path).resolve())
|
|
920
|
+
except OSError:
|
|
921
|
+
return
|
|
922
|
+
if normalized in seen:
|
|
923
|
+
return
|
|
924
|
+
seen.add(normalized)
|
|
925
|
+
combined.append(normalized)
|
|
926
|
+
|
|
927
|
+
for path in legacy_paths:
|
|
928
|
+
_append(path)
|
|
929
|
+
if cfg.assets.worker.inline_images and model_registry.get(role_model).supports_vision:
|
|
930
|
+
decision_by_id = {
|
|
931
|
+
decision.asset_id: decision.mode
|
|
932
|
+
for decision in (allocation.decisions if allocation else [])
|
|
933
|
+
}
|
|
934
|
+
max_new = max(0, int(cfg.assets.worker.max_inline_images))
|
|
935
|
+
added_new = 0
|
|
936
|
+
for entry in mirror.primary:
|
|
937
|
+
if entry.kind != "image" or entry.status != "mirrored":
|
|
938
|
+
continue
|
|
939
|
+
if decision_by_id.get(entry.asset_id) not in {"full_inline", "focused_extract"}:
|
|
940
|
+
continue
|
|
941
|
+
if entry.raw_workspace_path is None or not entry.raw_workspace_path.exists():
|
|
942
|
+
continue
|
|
943
|
+
before = len(combined)
|
|
944
|
+
_append(os.fspath(entry.raw_workspace_path))
|
|
945
|
+
if len(combined) > before:
|
|
946
|
+
usage_logger.inline_injection(asset_id=entry.asset_id, kind=entry.kind)
|
|
947
|
+
added_new += 1
|
|
948
|
+
if added_new >= max_new:
|
|
949
|
+
break
|
|
950
|
+
max_total = max(0, int(cfg.assets.worker.max_inline_images))
|
|
951
|
+
if max_total > 0:
|
|
952
|
+
combined = combined[:max_total]
|
|
953
|
+
return combined or None
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
def _append_patch_debug_section(patch_path: Path, *, title: str, patch_text: str) -> None:
|
|
957
|
+
existing = patch_path.read_text(encoding="utf-8") if patch_path.exists() else ""
|
|
958
|
+
parts: list[str] = []
|
|
959
|
+
if existing:
|
|
960
|
+
parts.append(existing if existing.endswith("\n") else existing + "\n")
|
|
961
|
+
parts.append(f"# {title}\n")
|
|
962
|
+
if patch_text:
|
|
963
|
+
parts.append(patch_text if patch_text.endswith("\n") else patch_text + "\n")
|
|
964
|
+
patch_path.write_text("\n".join(part.rstrip("\n") for part in parts if part), encoding="utf-8")
|
|
965
|
+
|
|
966
|
+
|
|
967
|
+
def forge_plan(
|
|
968
|
+
path: Path = typer.Option(
|
|
969
|
+
Path("."),
|
|
970
|
+
"--path",
|
|
971
|
+
help="Workspace path or repository subdirectory.",
|
|
972
|
+
),
|
|
973
|
+
create_path: bool = typer.Option(
|
|
974
|
+
False,
|
|
975
|
+
"--create-path",
|
|
976
|
+
help="Create --path if it does not exist before binding the workspace.",
|
|
977
|
+
),
|
|
978
|
+
allow_broad_workspace: bool = typer.Option(
|
|
979
|
+
False,
|
|
980
|
+
"--allow-broad-workspace",
|
|
981
|
+
help="Allow guarded broad workspaces instead of choosing a narrower project folder.",
|
|
982
|
+
),
|
|
983
|
+
cli_ctx: Any = None,
|
|
984
|
+
events: Any = None,
|
|
985
|
+
) -> None:
|
|
986
|
+
events = _events_or_null(events)
|
|
987
|
+
console = _console()
|
|
988
|
+
try:
|
|
989
|
+
binding = _resolve_startup_workspace_binding(
|
|
990
|
+
requested_path=path,
|
|
991
|
+
console=console,
|
|
992
|
+
interactive=not _is_non_interactive_terminal(),
|
|
993
|
+
create_if_missing=create_path,
|
|
994
|
+
allow_broad_workspace=allow_broad_workspace,
|
|
995
|
+
source=_path_binding_source(cli_ctx),
|
|
996
|
+
action=WorkspaceAction.FORGE_PLAN,
|
|
997
|
+
)
|
|
998
|
+
paths = create_plan_run(
|
|
999
|
+
path,
|
|
1000
|
+
create_if_missing=create_path,
|
|
1001
|
+
allow_broad_workspace=allow_broad_workspace,
|
|
1002
|
+
workspace_binding=binding,
|
|
1003
|
+
)
|
|
1004
|
+
events.set_run_id(paths.run_id)
|
|
1005
|
+
plan = load_plan(paths)
|
|
1006
|
+
workspace_scan = ensure_workspace_context_artifacts(paths)
|
|
1007
|
+
except (ForgeError, WorkspaceBindingError) as e:
|
|
1008
|
+
console.print(f"[red]Forge error:[/red] {e}")
|
|
1009
|
+
events.error(message=str(e), kind="forge_error", exit_code=EXIT_ERROR)
|
|
1010
|
+
raise typer.Exit(code=EXIT_ERROR) from e
|
|
1011
|
+
|
|
1012
|
+
console.rule("[bold cyan]forge plan[/bold cyan]")
|
|
1013
|
+
console.print(f"Run ID: {paths.run_id}")
|
|
1014
|
+
console.print(f"Plan directory: {paths.plan_dir}")
|
|
1015
|
+
for line in format_workspace_context_summary_lines(workspace_scan):
|
|
1016
|
+
console.print(line)
|
|
1017
|
+
console.print("Planning loop started. Type /help for commands. Type /done to finish.")
|
|
1018
|
+
assistant_enabled = False
|
|
1019
|
+
planning_suggested: set[str] = set()
|
|
1020
|
+
planner_state = _ForgePlannerSessionState(
|
|
1021
|
+
workspace_context=(
|
|
1022
|
+
_workspace_context_payload_for_paths(paths=paths)
|
|
1023
|
+
or {
|
|
1024
|
+
**workspace_scan.to_dict(),
|
|
1025
|
+
"greenfield": bool(getattr(paths, "greenfield", False)),
|
|
1026
|
+
}
|
|
1027
|
+
)
|
|
1028
|
+
)
|
|
1029
|
+
|
|
1030
|
+
def _emit_planner_meta(message: str) -> None:
|
|
1031
|
+
console.print(message)
|
|
1032
|
+
|
|
1033
|
+
def _emit_planner_warning_group(label: str, warnings: list[str]) -> None:
|
|
1034
|
+
for warning in warnings:
|
|
1035
|
+
console.print(f"[yellow]{label}:[/yellow] {warning}")
|
|
1036
|
+
|
|
1037
|
+
while True:
|
|
1038
|
+
try:
|
|
1039
|
+
# In machine mode the prompt goes to stderr so stdout stays pure NDJSON;
|
|
1040
|
+
# planning input is still read from stdin exactly as before.
|
|
1041
|
+
line = typer.prompt("plan", err=events.enabled)
|
|
1042
|
+
except (EOFError, KeyboardInterrupt):
|
|
1043
|
+
console.print("")
|
|
1044
|
+
break
|
|
1045
|
+
text = line.strip()
|
|
1046
|
+
if not text:
|
|
1047
|
+
continue
|
|
1048
|
+
|
|
1049
|
+
append_transcript_note(paths, role="user", message=text)
|
|
1050
|
+
parts = text.split(maxsplit=1)
|
|
1051
|
+
cmd = parts[0].lower()
|
|
1052
|
+
arg = parts[1].strip() if len(parts) > 1 else ""
|
|
1053
|
+
|
|
1054
|
+
if cmd in {"/done", "done", "/exit", "exit", "/quit", "quit"}:
|
|
1055
|
+
append_transcript_note(paths, role="system", message="Planning loop finished.")
|
|
1056
|
+
break
|
|
1057
|
+
if cmd in {"/help", "help"}:
|
|
1058
|
+
console.print(_planning_help_panel())
|
|
1059
|
+
append_transcript_note(paths, role="system", message="Displayed planning help.")
|
|
1060
|
+
continue
|
|
1061
|
+
if cmd == "/assistant":
|
|
1062
|
+
assistant_cmd = arg.lower()
|
|
1063
|
+
if not assistant_cmd:
|
|
1064
|
+
assistant_cmd, picker_available = _select_forge_assistant_interactive(
|
|
1065
|
+
enabled=assistant_enabled,
|
|
1066
|
+
console=console,
|
|
1067
|
+
)
|
|
1068
|
+
if not picker_available:
|
|
1069
|
+
console.print("[yellow]Usage:[/yellow] /assistant on|off|status")
|
|
1070
|
+
append_transcript_note(
|
|
1071
|
+
paths,
|
|
1072
|
+
role="system",
|
|
1073
|
+
message="Rejected invalid /assistant usage.",
|
|
1074
|
+
)
|
|
1075
|
+
continue
|
|
1076
|
+
if assistant_cmd is None:
|
|
1077
|
+
continue
|
|
1078
|
+
if assistant_cmd == "on":
|
|
1079
|
+
assistant_enabled = True
|
|
1080
|
+
console.print("Planner assistant: ON")
|
|
1081
|
+
append_transcript_note(paths, role="system", message="Planner assistant enabled.")
|
|
1082
|
+
continue
|
|
1083
|
+
if assistant_cmd == "off":
|
|
1084
|
+
assistant_enabled = False
|
|
1085
|
+
_set_forge_planner_follow_up_state(
|
|
1086
|
+
planner_state=planner_state,
|
|
1087
|
+
questions=[],
|
|
1088
|
+
awaiting_clarification=False,
|
|
1089
|
+
)
|
|
1090
|
+
console.print("Planner assistant: OFF")
|
|
1091
|
+
append_transcript_note(paths, role="system", message="Planner assistant disabled.")
|
|
1092
|
+
continue
|
|
1093
|
+
if assistant_cmd == "status":
|
|
1094
|
+
state = "ON" if assistant_enabled else "OFF"
|
|
1095
|
+
console.print(f"Planner assistant: {state}")
|
|
1096
|
+
append_transcript_note(
|
|
1097
|
+
paths,
|
|
1098
|
+
role="system",
|
|
1099
|
+
message=f"Planner assistant status requested ({state}).",
|
|
1100
|
+
)
|
|
1101
|
+
continue
|
|
1102
|
+
|
|
1103
|
+
console.print("[yellow]Usage:[/yellow] /assistant on|off|status")
|
|
1104
|
+
append_transcript_note(
|
|
1105
|
+
paths,
|
|
1106
|
+
role="system",
|
|
1107
|
+
message="Rejected invalid /assistant usage.",
|
|
1108
|
+
)
|
|
1109
|
+
continue
|
|
1110
|
+
|
|
1111
|
+
if assistant_enabled:
|
|
1112
|
+
_run_forge_planner_turn_controller(
|
|
1113
|
+
console=console,
|
|
1114
|
+
paths=paths,
|
|
1115
|
+
plan=plan,
|
|
1116
|
+
planner_state=planner_state,
|
|
1117
|
+
user_text=text,
|
|
1118
|
+
cfg_loader=load_config,
|
|
1119
|
+
unavailable_message_builder=(
|
|
1120
|
+
lambda error: (
|
|
1121
|
+
"Planner assistant is unavailable because config could not be loaded: "
|
|
1122
|
+
f"{error}"
|
|
1123
|
+
)
|
|
1124
|
+
),
|
|
1125
|
+
emit_meta=_emit_planner_meta,
|
|
1126
|
+
emit_warning_group=_emit_planner_warning_group,
|
|
1127
|
+
api_key_override=None,
|
|
1128
|
+
render_reply=lambda message, questions: _render_planner_reply(
|
|
1129
|
+
console=console,
|
|
1130
|
+
message=message,
|
|
1131
|
+
questions=questions,
|
|
1132
|
+
),
|
|
1133
|
+
selection_label="planner",
|
|
1134
|
+
planning_relevant=True,
|
|
1135
|
+
)
|
|
1136
|
+
continue
|
|
1137
|
+
|
|
1138
|
+
if cmd == "/goal":
|
|
1139
|
+
if not arg:
|
|
1140
|
+
console.print("[yellow]Usage:[/yellow] /goal <text>")
|
|
1141
|
+
append_transcript_note(paths, role="system", message="Rejected empty /goal.")
|
|
1142
|
+
continue
|
|
1143
|
+
goal = arg
|
|
1144
|
+
plan["project_goal"] = goal
|
|
1145
|
+
if not str(plan.get("summary") or "").strip():
|
|
1146
|
+
plan["summary"] = goal
|
|
1147
|
+
save_plan(paths, plan)
|
|
1148
|
+
console.print("Project goal updated.")
|
|
1149
|
+
if "goal" not in planning_suggested:
|
|
1150
|
+
planning_suggested.add("goal")
|
|
1151
|
+
console.print(
|
|
1152
|
+
"[dim]Next: add tasks with /task <title>, or describe the work and "
|
|
1153
|
+
"let the planner draft them.[/dim]"
|
|
1154
|
+
)
|
|
1155
|
+
append_transcript_note(paths, role="system", message="Updated project goal.")
|
|
1156
|
+
continue
|
|
1157
|
+
if cmd == "/task":
|
|
1158
|
+
if not arg:
|
|
1159
|
+
console.print("[yellow]Usage:[/yellow] /task <title>")
|
|
1160
|
+
append_transcript_note(paths, role="system", message="Rejected empty /task.")
|
|
1161
|
+
continue
|
|
1162
|
+
title = arg
|
|
1163
|
+
try:
|
|
1164
|
+
task = add_task(
|
|
1165
|
+
plan,
|
|
1166
|
+
title=title,
|
|
1167
|
+
description=f"Manual planning chat task: {title}",
|
|
1168
|
+
)
|
|
1169
|
+
except ForgeError as e:
|
|
1170
|
+
console.print(f"[yellow]Task rejected:[/yellow] {e}")
|
|
1171
|
+
append_transcript_note(
|
|
1172
|
+
paths,
|
|
1173
|
+
role="system",
|
|
1174
|
+
message=f"Rejected /task because it lacked runnable file scope: {e}",
|
|
1175
|
+
)
|
|
1176
|
+
continue
|
|
1177
|
+
save_plan(paths, plan)
|
|
1178
|
+
console.print(f"Added task: {task['id']} - {task['title']}")
|
|
1179
|
+
if "task" not in planning_suggested:
|
|
1180
|
+
planning_suggested.add("task")
|
|
1181
|
+
console.print(
|
|
1182
|
+
"[dim]Next: add more tasks, or /done to save and validate the plan.[/dim]"
|
|
1183
|
+
)
|
|
1184
|
+
append_transcript_note(paths, role="system", message=f"Added task {task['id']}.")
|
|
1185
|
+
continue
|
|
1186
|
+
|
|
1187
|
+
add_requirement(plan, text)
|
|
1188
|
+
save_plan(paths, plan)
|
|
1189
|
+
console.print("Captured requirement note.")
|
|
1190
|
+
append_transcript_note(paths, role="system", message="Captured requirement note.")
|
|
1191
|
+
|
|
1192
|
+
finalize_plan(plan)
|
|
1193
|
+
reconciliation_result, _ = _reconcile_plan_for_paths(
|
|
1194
|
+
paths=paths,
|
|
1195
|
+
plan=plan,
|
|
1196
|
+
refresh_if_stale=True,
|
|
1197
|
+
transcript_tail=planner_state.transcript,
|
|
1198
|
+
)
|
|
1199
|
+
save_plan(paths, plan)
|
|
1200
|
+
validation_warnings = _validate_forge_plan_for_paths(paths, plan)
|
|
1201
|
+
if reconciliation_result.warnings:
|
|
1202
|
+
console.print("[yellow]Plan reconciliation warnings:[/yellow]")
|
|
1203
|
+
for warning in reconciliation_result.warnings:
|
|
1204
|
+
console.print(f"- {warning}")
|
|
1205
|
+
append_transcript_note(
|
|
1206
|
+
paths,
|
|
1207
|
+
role="system",
|
|
1208
|
+
message=f"Plan reconciliation warning: {warning}",
|
|
1209
|
+
)
|
|
1210
|
+
_write_plan_validation_artifact(
|
|
1211
|
+
paths=paths,
|
|
1212
|
+
reconciliation_result=reconciliation_result,
|
|
1213
|
+
validation_warnings=validation_warnings,
|
|
1214
|
+
)
|
|
1215
|
+
if validation_warnings:
|
|
1216
|
+
console.print("[yellow]Plan validation warnings:[/yellow]")
|
|
1217
|
+
for warning in validation_warnings:
|
|
1218
|
+
console.print(f"- {warning}")
|
|
1219
|
+
append_transcript_note(
|
|
1220
|
+
paths,
|
|
1221
|
+
role="system",
|
|
1222
|
+
message=f"Plan validation warning: {warning}",
|
|
1223
|
+
)
|
|
1224
|
+
|
|
1225
|
+
# Stamp the plan with an explicit status so a plan that only earned warnings
|
|
1226
|
+
# is stored as a draft rather than looking ready until exec rejects it.
|
|
1227
|
+
status_assessment = apply_plan_status(plan, validation_warnings=validation_warnings)
|
|
1228
|
+
save_plan(paths, plan)
|
|
1229
|
+
if status_assessment.status == PLAN_STATUS_DRAFT:
|
|
1230
|
+
console.print("[yellow]Plan status:[/yellow] draft (not execution-ready)")
|
|
1231
|
+
for reason in status_assessment.blocking_reasons[:5]:
|
|
1232
|
+
console.print(f"- {reason}")
|
|
1233
|
+
append_transcript_note(
|
|
1234
|
+
paths,
|
|
1235
|
+
role="system",
|
|
1236
|
+
message="Plan saved as draft: " + "; ".join(status_assessment.blocking_reasons[:5]),
|
|
1237
|
+
)
|
|
1238
|
+
else:
|
|
1239
|
+
console.print("Plan status: execution_ready")
|
|
1240
|
+
|
|
1241
|
+
console.print(f"Plan saved: {paths.plan_md_path}")
|
|
1242
|
+
console.print(f"Structured plan: {paths.plan_json_path}")
|
|
1243
|
+
|
|
1244
|
+
tasks = plan.get("tasks") or []
|
|
1245
|
+
not_ready_reason = _plan_execution_readiness(plan)
|
|
1246
|
+
repair_payload = plan_repair_event_payload(plan)
|
|
1247
|
+
plan_saved_payload = {
|
|
1248
|
+
"run_id": paths.run_id,
|
|
1249
|
+
"plan_md": os.fspath(paths.plan_md_path),
|
|
1250
|
+
"plan_json": os.fspath(paths.plan_json_path),
|
|
1251
|
+
"run_dir": os.fspath(paths.run_dir),
|
|
1252
|
+
"project_goal": str(plan.get("project_goal") or "").strip() or None,
|
|
1253
|
+
"task_count": len(tasks),
|
|
1254
|
+
"task_ids": [str(task.get("id", "")) for task in tasks],
|
|
1255
|
+
"execution_ready": not_ready_reason is None,
|
|
1256
|
+
"warnings": list(validation_warnings),
|
|
1257
|
+
"reconciliation_warnings": list(reconciliation_result.warnings),
|
|
1258
|
+
**repair_payload,
|
|
1259
|
+
}
|
|
1260
|
+
events.emit(EVENT_PLAN_SAVED, plan_saved_payload)
|
|
1261
|
+
if not_ready_reason is not None:
|
|
1262
|
+
events.emit(
|
|
1263
|
+
EVENT_PLAN_INVALID,
|
|
1264
|
+
{
|
|
1265
|
+
"reason": not_ready_reason,
|
|
1266
|
+
"source": "execution_readiness",
|
|
1267
|
+
"warnings": list(validation_warnings),
|
|
1268
|
+
**repair_payload,
|
|
1269
|
+
},
|
|
1270
|
+
)
|
|
1271
|
+
events.run_completed(ok=True, exit_code=EXIT_OK, data={"plan": plan_saved_payload})
|
|
1272
|
+
|
|
1273
|
+
|
|
1274
|
+
_SEQUENTIAL_DELEGATE_ENV = "ALYSIS_SWARM_SEQUENTIAL_DELEGATE"
|
|
1275
|
+
|
|
1276
|
+
|
|
1277
|
+
def _swarm_sequential_delegation_blocker(
|
|
1278
|
+
*,
|
|
1279
|
+
cfg: Any,
|
|
1280
|
+
parallel: int,
|
|
1281
|
+
dry_run: Any,
|
|
1282
|
+
keep_worktrees: Any,
|
|
1283
|
+
retry_merge_conflicts: Any,
|
|
1284
|
+
replan: str | None,
|
|
1285
|
+
integration_verify: str | None,
|
|
1286
|
+
integration_verify_cmd: list[str] | None,
|
|
1287
|
+
) -> str | None:
|
|
1288
|
+
"""Why ``--parallel 1`` must stay on the swarm engine, or None to delegate.
|
|
1289
|
+
|
|
1290
|
+
Delegation is only safe when nothing swarm-specific was requested. Every
|
|
1291
|
+
blocker below names a capability the sequential engine genuinely does not
|
|
1292
|
+
have, so the answer is always "keep the swarm", never "quietly drop it".
|
|
1293
|
+
"""
|
|
1294
|
+
if not isinstance(parallel, int) or parallel != 1:
|
|
1295
|
+
return "parallel > 1"
|
|
1296
|
+
if str(env_get(_SEQUENTIAL_DELEGATE_ENV, "")).strip().lower() in {
|
|
1297
|
+
"0",
|
|
1298
|
+
"false",
|
|
1299
|
+
"no",
|
|
1300
|
+
"off",
|
|
1301
|
+
}:
|
|
1302
|
+
return f"{_SEQUENTIAL_DELEGATE_ENV} disables delegation"
|
|
1303
|
+
if dry_run is True:
|
|
1304
|
+
return "--dry-run reports the swarm schedule"
|
|
1305
|
+
if keep_worktrees is True:
|
|
1306
|
+
return "--keep-worktrees is worktree-specific"
|
|
1307
|
+
if retry_merge_conflicts is True:
|
|
1308
|
+
return "--retry-merge-conflicts re-merges existing task branches"
|
|
1309
|
+
if integration_verify is not None or integration_verify_cmd:
|
|
1310
|
+
return "an explicit integration verification gate was requested"
|
|
1311
|
+
try:
|
|
1312
|
+
replanning_mode = resolve_replanning_mode(cfg=cfg, replanning_mode=replan)
|
|
1313
|
+
except Exception: # noqa: BLE001
|
|
1314
|
+
# Failing to classify replanning must not decide the engine; keep the
|
|
1315
|
+
# swarm, which is what the caller literally typed.
|
|
1316
|
+
return "between-batch replanning mode could not be resolved"
|
|
1317
|
+
if str(replanning_mode) != "off":
|
|
1318
|
+
return f"between-batch replanning is {replanning_mode}"
|
|
1319
|
+
return None
|
|
1320
|
+
|
|
1321
|
+
|
|
1322
|
+
def _load_swarm_summary_payload(paths: Any) -> dict[str, Any] | None:
|
|
1323
|
+
"""Read the structured swarm outcome the orchestrator already writes."""
|
|
1324
|
+
summary_path = paths.execution_dir / "swarm_summary.json"
|
|
1325
|
+
try:
|
|
1326
|
+
payload = json.loads(summary_path.read_text(encoding="utf-8"))
|
|
1327
|
+
except (OSError, ValueError):
|
|
1328
|
+
return None
|
|
1329
|
+
return payload if isinstance(payload, dict) else None
|
|
1330
|
+
|
|
1331
|
+
|
|
1332
|
+
def _emit_swarm_outcome_events(
|
|
1333
|
+
*,
|
|
1334
|
+
events: ForgeEventEmitter,
|
|
1335
|
+
paths: Any,
|
|
1336
|
+
exit_code: int,
|
|
1337
|
+
dry_run: bool,
|
|
1338
|
+
) -> None:
|
|
1339
|
+
"""Translate the swarm's own outcome artifact into protocol events."""
|
|
1340
|
+
if not events.enabled:
|
|
1341
|
+
return
|
|
1342
|
+
|
|
1343
|
+
summary = _load_swarm_summary_payload(paths)
|
|
1344
|
+
data: dict[str, Any] = {"dry_run": bool(dry_run)}
|
|
1345
|
+
if summary is not None:
|
|
1346
|
+
verification_status = str(summary.get("verification_status") or "")
|
|
1347
|
+
integration = summary.get("integration")
|
|
1348
|
+
integration_payload = integration if isinstance(integration, dict) else {}
|
|
1349
|
+
if verification_status == "not_run":
|
|
1350
|
+
events.emit(
|
|
1351
|
+
EVENT_VERIFICATION_UNAVAILABLE,
|
|
1352
|
+
{
|
|
1353
|
+
"scope": "swarm",
|
|
1354
|
+
"status": verification_status,
|
|
1355
|
+
"reason": "no integration verification ran for this swarm run",
|
|
1356
|
+
},
|
|
1357
|
+
)
|
|
1358
|
+
elif verification_status:
|
|
1359
|
+
events.emit(
|
|
1360
|
+
EVENT_VERIFICATION_RESULT,
|
|
1361
|
+
{
|
|
1362
|
+
"scope": "swarm",
|
|
1363
|
+
"status": verification_status,
|
|
1364
|
+
"passed": verification_status == "passed",
|
|
1365
|
+
"integration": integration_payload,
|
|
1366
|
+
},
|
|
1367
|
+
)
|
|
1368
|
+
data["outcome"] = summary
|
|
1369
|
+
elif not dry_run:
|
|
1370
|
+
events.emit(
|
|
1371
|
+
EVENT_VERIFICATION_UNAVAILABLE,
|
|
1372
|
+
{
|
|
1373
|
+
"scope": "swarm",
|
|
1374
|
+
"status": "unknown",
|
|
1375
|
+
"reason": "swarm summary artifact was not written",
|
|
1376
|
+
},
|
|
1377
|
+
)
|
|
1378
|
+
|
|
1379
|
+
events.run_completed(ok=exit_code == EXIT_OK, exit_code=exit_code, data=data)
|
|
1380
|
+
|
|
1381
|
+
|
|
1382
|
+
def forge_swarm(
|
|
1383
|
+
path: Path = typer.Option(
|
|
1384
|
+
Path("."),
|
|
1385
|
+
"--path",
|
|
1386
|
+
help="Workspace path or repository subdirectory.",
|
|
1387
|
+
),
|
|
1388
|
+
allow_broad_workspace: bool = typer.Option(
|
|
1389
|
+
False,
|
|
1390
|
+
"--allow-broad-workspace",
|
|
1391
|
+
help="Allow guarded broad workspaces instead of requiring a narrower project path.",
|
|
1392
|
+
),
|
|
1393
|
+
parallel: int = typer.Option(2, "--parallel", min=1, help="Parallel workers per batch."),
|
|
1394
|
+
base_branch: str | None = typer.Option(
|
|
1395
|
+
None,
|
|
1396
|
+
"--base-branch",
|
|
1397
|
+
help="Base branch (defaults to current checked out branch).",
|
|
1398
|
+
),
|
|
1399
|
+
max_tasks: int | None = typer.Option(
|
|
1400
|
+
None,
|
|
1401
|
+
"--max-tasks",
|
|
1402
|
+
min=1,
|
|
1403
|
+
help="Maximum number of tasks to execute in this swarm run.",
|
|
1404
|
+
),
|
|
1405
|
+
max_attempts: int | None = typer.Option(
|
|
1406
|
+
None,
|
|
1407
|
+
"--max-attempts",
|
|
1408
|
+
min=1,
|
|
1409
|
+
help="Maximum attempts allowed per task before scheduler skips it.",
|
|
1410
|
+
),
|
|
1411
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="Print schedule and exit."),
|
|
1412
|
+
keep_worktrees: bool = typer.Option(
|
|
1413
|
+
False,
|
|
1414
|
+
"--keep-worktrees",
|
|
1415
|
+
help=(
|
|
1416
|
+
"Keep failed task worktrees and branches for debugging; default cleanup removes "
|
|
1417
|
+
"successful worktrees and rejected failed branch state."
|
|
1418
|
+
),
|
|
1419
|
+
),
|
|
1420
|
+
retry_failed: bool = typer.Option(
|
|
1421
|
+
False,
|
|
1422
|
+
"--retry-failed",
|
|
1423
|
+
help="Include tasks currently marked failed.",
|
|
1424
|
+
),
|
|
1425
|
+
retry_changes_requested: bool = typer.Option(
|
|
1426
|
+
False,
|
|
1427
|
+
"--retry-changes-requested",
|
|
1428
|
+
help="Include tasks currently marked changes_requested.",
|
|
1429
|
+
),
|
|
1430
|
+
only: str | None = typer.Option(
|
|
1431
|
+
None,
|
|
1432
|
+
"--only",
|
|
1433
|
+
help="Comma-separated task ids to execute (still enforces dependencies).",
|
|
1434
|
+
),
|
|
1435
|
+
retry_merge_conflicts: bool = typer.Option(
|
|
1436
|
+
False,
|
|
1437
|
+
"--retry-merge-conflicts",
|
|
1438
|
+
help="Retry tasks marked merge_conflict during merge phase.",
|
|
1439
|
+
),
|
|
1440
|
+
scope: str = typer.Option(
|
|
1441
|
+
"strict",
|
|
1442
|
+
"--scope",
|
|
1443
|
+
help="Write-scope enforcement: strict by default; use warn or off to opt out.",
|
|
1444
|
+
),
|
|
1445
|
+
verify: str = typer.Option(
|
|
1446
|
+
"warn",
|
|
1447
|
+
"--verify",
|
|
1448
|
+
help="Verification policy: off, warn, or strict.",
|
|
1449
|
+
),
|
|
1450
|
+
verify_cmd: list[str] | None = typer.Option(
|
|
1451
|
+
None,
|
|
1452
|
+
"--verify-cmd",
|
|
1453
|
+
help="Override verify command for this run (repeatable).",
|
|
1454
|
+
),
|
|
1455
|
+
integration_verify: str | None = typer.Option(
|
|
1456
|
+
None,
|
|
1457
|
+
"--integration-verify",
|
|
1458
|
+
help="Batch integration verification policy: off, warn, or strict (defaults to config: warn).",
|
|
1459
|
+
),
|
|
1460
|
+
integration_verify_cmd: list[str] | None = typer.Option(
|
|
1461
|
+
None,
|
|
1462
|
+
"--integration-verify-cmd",
|
|
1463
|
+
help="Override integration verify command for this swarm run (repeatable).",
|
|
1464
|
+
),
|
|
1465
|
+
replan: str | None = typer.Option(
|
|
1466
|
+
None,
|
|
1467
|
+
"--replan",
|
|
1468
|
+
help="Between-batch replanning mode: off, suggest, or apply.",
|
|
1469
|
+
),
|
|
1470
|
+
review: bool = typer.Option(
|
|
1471
|
+
False,
|
|
1472
|
+
"--review",
|
|
1473
|
+
help="Run automated PR review gate before merging task branches.",
|
|
1474
|
+
),
|
|
1475
|
+
mode: Mode | None = typer.Option(None, "--mode", help="Mode override."),
|
|
1476
|
+
model: str | None = typer.Option(None, "--model", help="Model override."),
|
|
1477
|
+
base_url: str | None = typer.Option(None, "--base-url", help="Base URL override."),
|
|
1478
|
+
temperature: float | None = typer.Option(None, "--temperature", help="Sampling temperature."),
|
|
1479
|
+
stream: bool | None = typer.Option(
|
|
1480
|
+
None,
|
|
1481
|
+
"--stream/--no-stream",
|
|
1482
|
+
help="Enable streamed assistant output.",
|
|
1483
|
+
),
|
|
1484
|
+
max_steps: int | None = typer.Option(
|
|
1485
|
+
None,
|
|
1486
|
+
"--max-steps",
|
|
1487
|
+
help="Optional safety limit on each managed agent task.",
|
|
1488
|
+
),
|
|
1489
|
+
no_log: bool = typer.Option(False, "--no-log", help="Disable JSONL session logging."),
|
|
1490
|
+
api_key_env: str | None = typer.Option(
|
|
1491
|
+
None,
|
|
1492
|
+
"--api-key-env",
|
|
1493
|
+
help=(
|
|
1494
|
+
"Read API key from this environment variable (overrides ALYSIS_API_KEY/OPENAI_API_KEY)."
|
|
1495
|
+
),
|
|
1496
|
+
),
|
|
1497
|
+
api_key_stdin: bool = typer.Option(
|
|
1498
|
+
False,
|
|
1499
|
+
"--api-key-stdin",
|
|
1500
|
+
help="Prompt for API key (hidden input). Key is kept in memory for this run only.",
|
|
1501
|
+
),
|
|
1502
|
+
api_key: str | None = typer.Option(
|
|
1503
|
+
None,
|
|
1504
|
+
"--api-key",
|
|
1505
|
+
help=(
|
|
1506
|
+
"UNSAFE: Provide API key via CLI argument (may leak via shell history / process list). "
|
|
1507
|
+
"Prefer --api-key-stdin or --api-key-env."
|
|
1508
|
+
),
|
|
1509
|
+
),
|
|
1510
|
+
yes: bool = typer.Option(
|
|
1511
|
+
False,
|
|
1512
|
+
"--yes",
|
|
1513
|
+
help="In auto mode, skip confirmations for sensitive commands (hard blocks still apply).",
|
|
1514
|
+
),
|
|
1515
|
+
cli_ctx: Any = None,
|
|
1516
|
+
events: Any = None,
|
|
1517
|
+
) -> None:
|
|
1518
|
+
events = _events_or_null(events)
|
|
1519
|
+
console = _console()
|
|
1520
|
+
cfg = load_config()
|
|
1521
|
+
|
|
1522
|
+
# A swarm of one is a sequential run wearing worktrees. Hand it to the sequential
|
|
1523
|
+
# engine so the blessed path is what actually executes, unless the invocation asked
|
|
1524
|
+
# for machinery only the swarm has.
|
|
1525
|
+
delegation_blocker = _swarm_sequential_delegation_blocker(
|
|
1526
|
+
cfg=cfg,
|
|
1527
|
+
parallel=parallel,
|
|
1528
|
+
dry_run=dry_run,
|
|
1529
|
+
keep_worktrees=keep_worktrees,
|
|
1530
|
+
retry_merge_conflicts=retry_merge_conflicts,
|
|
1531
|
+
replan=replan,
|
|
1532
|
+
integration_verify=integration_verify,
|
|
1533
|
+
integration_verify_cmd=integration_verify_cmd,
|
|
1534
|
+
)
|
|
1535
|
+
if isinstance(parallel, int) and parallel == 1:
|
|
1536
|
+
if delegation_blocker is None:
|
|
1537
|
+
console.print(
|
|
1538
|
+
"[dim]forge swarm --parallel 1 has no parallelism to exploit; running the "
|
|
1539
|
+
"sequential engine (`forge run`) in this checkout instead of provisioning "
|
|
1540
|
+
"worktrees.[/dim]"
|
|
1541
|
+
)
|
|
1542
|
+
return forge_run(
|
|
1543
|
+
path=path,
|
|
1544
|
+
allow_broad_workspace=allow_broad_workspace,
|
|
1545
|
+
only=only,
|
|
1546
|
+
max_tasks=max_tasks,
|
|
1547
|
+
max_attempts=max_attempts,
|
|
1548
|
+
retry_failed=retry_failed,
|
|
1549
|
+
retry_changes_requested=retry_changes_requested,
|
|
1550
|
+
keep_going=False,
|
|
1551
|
+
dry_run=False,
|
|
1552
|
+
scope=scope,
|
|
1553
|
+
verify=verify,
|
|
1554
|
+
verify_cmd=verify_cmd,
|
|
1555
|
+
verify_repair_attempts=None,
|
|
1556
|
+
pr=True,
|
|
1557
|
+
review=review,
|
|
1558
|
+
base_branch=base_branch,
|
|
1559
|
+
keep_branch=False,
|
|
1560
|
+
auto_resolve_conflicts=False,
|
|
1561
|
+
mode=mode,
|
|
1562
|
+
model=model,
|
|
1563
|
+
base_url=base_url,
|
|
1564
|
+
temperature=temperature,
|
|
1565
|
+
stream=stream,
|
|
1566
|
+
max_steps=max_steps,
|
|
1567
|
+
no_log=no_log,
|
|
1568
|
+
api_key_env=api_key_env,
|
|
1569
|
+
api_key_stdin=api_key_stdin,
|
|
1570
|
+
api_key=api_key,
|
|
1571
|
+
yes=yes,
|
|
1572
|
+
cli_ctx=cli_ctx,
|
|
1573
|
+
events=events,
|
|
1574
|
+
)
|
|
1575
|
+
console.print(
|
|
1576
|
+
f"[dim]forge swarm --parallel 1 kept the swarm engine: {delegation_blocker}. "
|
|
1577
|
+
"Use `forge run` for the sequential path.[/dim]"
|
|
1578
|
+
)
|
|
1579
|
+
|
|
1580
|
+
effective = clone_cfg(cfg)
|
|
1581
|
+
current_ctx = get_current_context(silent=True)
|
|
1582
|
+
max_steps_source = (
|
|
1583
|
+
current_ctx.get_parameter_source("max_steps") if current_ctx is not None else None
|
|
1584
|
+
)
|
|
1585
|
+
max_steps_provided = max_steps is not None
|
|
1586
|
+
if current_ctx is not None:
|
|
1587
|
+
max_steps_provided = (
|
|
1588
|
+
max_steps_source is not None and max_steps_source is not ParameterSource.DEFAULT
|
|
1589
|
+
)
|
|
1590
|
+
if base_url is not None:
|
|
1591
|
+
effective.base_url = base_url
|
|
1592
|
+
if model is not None:
|
|
1593
|
+
effective.model = model
|
|
1594
|
+
if temperature is not None:
|
|
1595
|
+
_apply_temperature_override(effective, temperature)
|
|
1596
|
+
if stream is not None:
|
|
1597
|
+
effective.stream = stream
|
|
1598
|
+
if max_steps is not None:
|
|
1599
|
+
effective.max_steps = max_steps
|
|
1600
|
+
swarm_max_steps = effective.max_steps if max_steps_provided else None
|
|
1601
|
+
|
|
1602
|
+
effective_mode = (mode.value if mode else effective.default_mode) or "review"
|
|
1603
|
+
scope_mode = "strict"
|
|
1604
|
+
verify_mode = "warn"
|
|
1605
|
+
integration_verify_mode = None
|
|
1606
|
+
replanning_mode = None
|
|
1607
|
+
|
|
1608
|
+
try:
|
|
1609
|
+
scope_mode = _normalize_scope_mode(scope)
|
|
1610
|
+
verify_mode = _normalize_verify_mode(verify)
|
|
1611
|
+
integration_verify_mode = integration_verify
|
|
1612
|
+
replanning_mode = replan
|
|
1613
|
+
api_key_override = _resolve_api_key_override(
|
|
1614
|
+
api_key=api_key,
|
|
1615
|
+
api_key_env=api_key_env,
|
|
1616
|
+
api_key_stdin=api_key_stdin,
|
|
1617
|
+
)
|
|
1618
|
+
binding = resolve_workspace_binding(
|
|
1619
|
+
path,
|
|
1620
|
+
create_if_missing=False,
|
|
1621
|
+
allow_broad_workspace=allow_broad_workspace,
|
|
1622
|
+
source=_path_binding_source(cli_ctx),
|
|
1623
|
+
)
|
|
1624
|
+
ensure_workspace_policy(
|
|
1625
|
+
binding,
|
|
1626
|
+
action=WorkspaceAction.SWARM,
|
|
1627
|
+
allow_broad_workspace=allow_broad_workspace,
|
|
1628
|
+
)
|
|
1629
|
+
try:
|
|
1630
|
+
paths = load_current_run_paths(binding.workspace_context.focus_path)
|
|
1631
|
+
except ForgeError as e:
|
|
1632
|
+
if "current_run.json" in str(e):
|
|
1633
|
+
raise _missing_swarm_run_error(binding=binding) from e
|
|
1634
|
+
raise
|
|
1635
|
+
events.set_run_id(paths.run_id)
|
|
1636
|
+
plan = load_plan(paths)
|
|
1637
|
+
run_mutation_guard = acquire_swarm_mutation_guard(
|
|
1638
|
+
paths,
|
|
1639
|
+
mode="forge_swarm:cli",
|
|
1640
|
+
on_wait=lambda info: _print_forge_lock_wait_notice(console, info),
|
|
1641
|
+
)
|
|
1642
|
+
try:
|
|
1643
|
+
if bool(getattr(run_mutation_guard, "acquired_after_wait", False)):
|
|
1644
|
+
plan = load_plan(paths)
|
|
1645
|
+
reconciliation_result, _ = _reconcile_plan_for_paths(
|
|
1646
|
+
paths=paths,
|
|
1647
|
+
plan=plan,
|
|
1648
|
+
refresh_if_stale=True,
|
|
1649
|
+
)
|
|
1650
|
+
if reconciliation_result.changed:
|
|
1651
|
+
save_plan(paths, plan)
|
|
1652
|
+
if reconciliation_result.warnings:
|
|
1653
|
+
console.print("[yellow]Plan reconciliation warnings:[/yellow]")
|
|
1654
|
+
for warning in reconciliation_result.warnings:
|
|
1655
|
+
console.print(f"- {warning}")
|
|
1656
|
+
validation_warnings = _validate_forge_plan_for_paths(paths, plan)
|
|
1657
|
+
_write_plan_validation_artifact(
|
|
1658
|
+
paths=paths,
|
|
1659
|
+
reconciliation_result=reconciliation_result,
|
|
1660
|
+
validation_warnings=validation_warnings,
|
|
1661
|
+
)
|
|
1662
|
+
repair_payload = plan_repair_event_payload(plan)
|
|
1663
|
+
no_execution_ready_tasks_message = _forge_no_execution_ready_tasks_message(plan)
|
|
1664
|
+
if no_execution_ready_tasks_message is not None:
|
|
1665
|
+
events.emit(
|
|
1666
|
+
EVENT_PLAN_INVALID,
|
|
1667
|
+
{
|
|
1668
|
+
"reason": no_execution_ready_tasks_message,
|
|
1669
|
+
"source": "execution_readiness",
|
|
1670
|
+
"warnings": list(validation_warnings),
|
|
1671
|
+
**repair_payload,
|
|
1672
|
+
},
|
|
1673
|
+
)
|
|
1674
|
+
raise ForgeError(no_execution_ready_tasks_message)
|
|
1675
|
+
try:
|
|
1676
|
+
raise_for_execution_ready_plan(
|
|
1677
|
+
plan,
|
|
1678
|
+
retry_failed=retry_failed,
|
|
1679
|
+
retry_changes_requested=retry_changes_requested,
|
|
1680
|
+
retry_merge_conflicts=retry_merge_conflicts,
|
|
1681
|
+
only=only,
|
|
1682
|
+
)
|
|
1683
|
+
except PlannerFailedError as e:
|
|
1684
|
+
events.emit(
|
|
1685
|
+
EVENT_PLAN_INVALID,
|
|
1686
|
+
{
|
|
1687
|
+
"reason": str(e),
|
|
1688
|
+
"source": "plan_validation",
|
|
1689
|
+
"failure_category": getattr(e, "failure_category", None),
|
|
1690
|
+
**repair_payload,
|
|
1691
|
+
},
|
|
1692
|
+
)
|
|
1693
|
+
err = ForgeError(str(e))
|
|
1694
|
+
err.failure_category = e.failure_category # type: ignore[attr-defined]
|
|
1695
|
+
raise err from e
|
|
1696
|
+
resolve_model_for_role(
|
|
1697
|
+
cfg=effective,
|
|
1698
|
+
role=ROLE_CODING,
|
|
1699
|
+
plan=plan,
|
|
1700
|
+
prefer_context="forge",
|
|
1701
|
+
)
|
|
1702
|
+
if not dry_run:
|
|
1703
|
+
_mark_run_status(
|
|
1704
|
+
paths,
|
|
1705
|
+
RUN_STATUS_RUNNING,
|
|
1706
|
+
reason=f"forge swarm started (parallel={parallel})",
|
|
1707
|
+
plan=plan,
|
|
1708
|
+
mode="forge_swarm:cli",
|
|
1709
|
+
)
|
|
1710
|
+
code = run_swarm(
|
|
1711
|
+
paths=paths,
|
|
1712
|
+
plan=plan,
|
|
1713
|
+
cfg=effective,
|
|
1714
|
+
mode=effective_mode,
|
|
1715
|
+
yes=yes,
|
|
1716
|
+
max_steps=swarm_max_steps,
|
|
1717
|
+
api_key_override=api_key_override,
|
|
1718
|
+
no_log=no_log,
|
|
1719
|
+
parallel=parallel,
|
|
1720
|
+
base_branch=base_branch,
|
|
1721
|
+
max_tasks=max_tasks,
|
|
1722
|
+
max_attempts=max_attempts,
|
|
1723
|
+
dry_run=dry_run,
|
|
1724
|
+
keep_worktrees=keep_worktrees,
|
|
1725
|
+
retry_failed=retry_failed,
|
|
1726
|
+
retry_changes_requested=retry_changes_requested,
|
|
1727
|
+
only=only,
|
|
1728
|
+
retry_merge_conflicts=retry_merge_conflicts,
|
|
1729
|
+
scope_mode=scope_mode,
|
|
1730
|
+
verify_mode=verify_mode,
|
|
1731
|
+
verify_cmd=verify_cmd,
|
|
1732
|
+
integration_mode=integration_verify_mode,
|
|
1733
|
+
integration_verify_cmd=integration_verify_cmd,
|
|
1734
|
+
replanning_mode=replanning_mode,
|
|
1735
|
+
review=review,
|
|
1736
|
+
console=console,
|
|
1737
|
+
workspace_binding=binding,
|
|
1738
|
+
run_mutation_guard=run_mutation_guard,
|
|
1739
|
+
)
|
|
1740
|
+
if not dry_run:
|
|
1741
|
+
# The swarm reloads and rewrites the plan as it merges batches, so the
|
|
1742
|
+
# in-memory copy is stale by now; the terminal status has to be decided
|
|
1743
|
+
# from what actually landed on disk.
|
|
1744
|
+
terminal_status, terminal_reason = _terminal_run_status_for(
|
|
1745
|
+
plan=load_plan(paths),
|
|
1746
|
+
any_failure=code != EXIT_OK,
|
|
1747
|
+
)
|
|
1748
|
+
_mark_run_status(paths, terminal_status, reason=terminal_reason)
|
|
1749
|
+
finally:
|
|
1750
|
+
_mark_run_interrupted_if_still_running(
|
|
1751
|
+
paths,
|
|
1752
|
+
reason="forge swarm exited without recording an outcome",
|
|
1753
|
+
)
|
|
1754
|
+
run_mutation_guard.release()
|
|
1755
|
+
except (ConfigError, ForgeError, GitOpsError, WorkspaceBindingError) as e:
|
|
1756
|
+
console.print(f"[red]Forge error:[/red] {e}")
|
|
1757
|
+
events.error(
|
|
1758
|
+
message=str(e),
|
|
1759
|
+
kind="forge_error",
|
|
1760
|
+
exit_code=EXIT_ERROR,
|
|
1761
|
+
data={"failure_category": getattr(e, "failure_category", None)},
|
|
1762
|
+
)
|
|
1763
|
+
raise typer.Exit(code=EXIT_ERROR) from e
|
|
1764
|
+
except Exception as e: # noqa: BLE001
|
|
1765
|
+
# An unexpected exception is a command error, not "the swarm ran and some tasks
|
|
1766
|
+
# failed" -- exit 2 keeps exit 1 meaning only the latter.
|
|
1767
|
+
console.print(f"[red]Forge error:[/red] {e}")
|
|
1768
|
+
events.error(
|
|
1769
|
+
message=str(e) or e.__class__.__name__,
|
|
1770
|
+
kind="exception",
|
|
1771
|
+
exit_code=EXIT_ERROR,
|
|
1772
|
+
data={"exception_type": e.__class__.__name__},
|
|
1773
|
+
)
|
|
1774
|
+
raise typer.Exit(code=EXIT_ERROR) from e
|
|
1775
|
+
|
|
1776
|
+
log_paths = sorted(paths.execution_logs_dir.glob("*.jsonl"))
|
|
1777
|
+
_print_usage_summary_from_logs(
|
|
1778
|
+
console=console,
|
|
1779
|
+
title="Swarm Usage Summary",
|
|
1780
|
+
log_paths=log_paths,
|
|
1781
|
+
)
|
|
1782
|
+
_emit_swarm_outcome_events(events=events, paths=paths, exit_code=code, dry_run=dry_run)
|
|
1783
|
+
raise typer.Exit(code=code)
|
|
1784
|
+
|
|
1785
|
+
|
|
1786
|
+
def execute_forge_task(
|
|
1787
|
+
*,
|
|
1788
|
+
console: Any,
|
|
1789
|
+
events: ForgeEventEmitter,
|
|
1790
|
+
paths: Any,
|
|
1791
|
+
plan: dict[str, Any],
|
|
1792
|
+
task: dict[str, Any],
|
|
1793
|
+
task_id: str,
|
|
1794
|
+
effective: Any,
|
|
1795
|
+
run_cfg: Any,
|
|
1796
|
+
effective_mode: str,
|
|
1797
|
+
scope_mode: str,
|
|
1798
|
+
verify_mode: str,
|
|
1799
|
+
verify_commands: list[str],
|
|
1800
|
+
verify_command_source: str | None,
|
|
1801
|
+
verify_repair_budget: int,
|
|
1802
|
+
api_key_override: str | None,
|
|
1803
|
+
yes: bool,
|
|
1804
|
+
no_log: bool,
|
|
1805
|
+
max_steps_provided: bool,
|
|
1806
|
+
pr: bool,
|
|
1807
|
+
review: bool,
|
|
1808
|
+
base_branch: str | None,
|
|
1809
|
+
keep_branch: bool,
|
|
1810
|
+
auto_resolve_conflicts: bool = False,
|
|
1811
|
+
) -> TaskExecutionOutcome:
|
|
1812
|
+
"""Execute exactly one plan task in the main checkout, with no worktrees.
|
|
1813
|
+
|
|
1814
|
+
This is the sequential execution core. ``forge exec`` calls it once and turns
|
|
1815
|
+
the outcome into an exit code; ``forge run`` calls it per ready task in
|
|
1816
|
+
dependency order. Sharing it is the point: scope triage, verification, the
|
|
1817
|
+
repair loop, the review gate and the PR flow behave identically either way,
|
|
1818
|
+
so a fix to one is a fix to both.
|
|
1819
|
+
|
|
1820
|
+
The caller owns the run mutation guard, the plan reload and the terminal
|
|
1821
|
+
event. Raising :class:`ForgeTaskExecutionError` means the task could not be
|
|
1822
|
+
executed at all (a command error); returning an outcome with
|
|
1823
|
+
``success=False`` means it ran and was not accepted.
|
|
1824
|
+
"""
|
|
1825
|
+
pr_base_branch: str | None = None
|
|
1826
|
+
pr_task_branch: str | None = None
|
|
1827
|
+
commit_hash: str | None = None
|
|
1828
|
+
merge_commit_hash: str | None = None
|
|
1829
|
+
merge_result: str | None = None
|
|
1830
|
+
allowed_scope = normalize_scope_patterns(task, root=paths.root) if scope_mode != "off" else []
|
|
1831
|
+
scope_warnings: list[str] = []
|
|
1832
|
+
review_blocked = False
|
|
1833
|
+
verify_blocked = False
|
|
1834
|
+
# Set when the work landed but strict verification had no authoritative
|
|
1835
|
+
# command to hold it against: a completion, not a failure, and never merged.
|
|
1836
|
+
verification_unavailable_completion = False
|
|
1837
|
+
verification_repair_payload: dict[str, Any] | None = None
|
|
1838
|
+
verify_summary: str | None = None
|
|
1839
|
+
verify_payload: dict[str, Any] | None = None
|
|
1840
|
+
merge_conflict_detected = False
|
|
1841
|
+
conflict_review_path: Path | None = None
|
|
1842
|
+
verify_path = paths.execution_verify_dir / f"{_safe_task_file_component(task_id)}.txt"
|
|
1843
|
+
remote_settings = load_remote_settings_from_env()
|
|
1844
|
+
remote_record: dict[str, Any] | None = None
|
|
1845
|
+
conflict_auto_settings = load_conflict_auto_resolve_settings(cfg=effective)
|
|
1846
|
+
|
|
1847
|
+
if pr:
|
|
1848
|
+
try:
|
|
1849
|
+
ensure_git_available()
|
|
1850
|
+
ensure_git_repo(paths.root)
|
|
1851
|
+
ensure_clean_for_pr(paths.root)
|
|
1852
|
+
pr_base_branch = base_branch.strip() if base_branch else current_branch(paths.root)
|
|
1853
|
+
if not pr_base_branch:
|
|
1854
|
+
raise GitOpsError("base branch is empty")
|
|
1855
|
+
pr_task_branch = str(task.get("branch") or "").strip()
|
|
1856
|
+
if not pr_task_branch:
|
|
1857
|
+
pr_task_branch = generate_task_branch_name(
|
|
1858
|
+
task_id,
|
|
1859
|
+
str(task.get("title") or ""),
|
|
1860
|
+
)
|
|
1861
|
+
task["branch"] = pr_task_branch
|
|
1862
|
+
save_plan(paths, plan)
|
|
1863
|
+
checkout_branch(paths.root, pr_task_branch, base_branch=pr_base_branch)
|
|
1864
|
+
merge_result = "not merged"
|
|
1865
|
+
except GitOpsError as e:
|
|
1866
|
+
raise ForgeTaskExecutionError(
|
|
1867
|
+
str(e),
|
|
1868
|
+
kind="git_error",
|
|
1869
|
+
task_id=task_id,
|
|
1870
|
+
) from e
|
|
1871
|
+
|
|
1872
|
+
ensure_execution_dirs(paths)
|
|
1873
|
+
set_task_status(plan, task_id, "in_progress")
|
|
1874
|
+
save_plan(paths, plan)
|
|
1875
|
+
events.emit(
|
|
1876
|
+
EVENT_TASK_STARTED,
|
|
1877
|
+
{
|
|
1878
|
+
"task_id": task_id,
|
|
1879
|
+
"title": str(task.get("title", "")),
|
|
1880
|
+
"status": "in_progress",
|
|
1881
|
+
"source": "exec",
|
|
1882
|
+
"pr": bool(pr),
|
|
1883
|
+
"branch": pr_task_branch,
|
|
1884
|
+
},
|
|
1885
|
+
)
|
|
1886
|
+
|
|
1887
|
+
started_at = now_iso()
|
|
1888
|
+
run_non_interactive = _is_non_interactive_terminal()
|
|
1889
|
+
prompt_verification_enabled = verify_mode != "off" and bool(verify_commands)
|
|
1890
|
+
prepared_knowledge = _prepare_task_execution_knowledge(
|
|
1891
|
+
run_paths=paths,
|
|
1892
|
+
task=task,
|
|
1893
|
+
selection_label="execution",
|
|
1894
|
+
)
|
|
1895
|
+
runtime_session_id = _safe_task_file_component(task_id)
|
|
1896
|
+
task_mcp_scope, task_mcp_scope_warnings = normalize_task_mcp_scope(
|
|
1897
|
+
task.get("mcp_scope"),
|
|
1898
|
+
warning_prefix=f"Task {task_id}",
|
|
1899
|
+
)
|
|
1900
|
+
scope_warnings.extend(task_mcp_scope_warnings)
|
|
1901
|
+
instruction = ""
|
|
1902
|
+
task_image_paths: list[str] | None = None
|
|
1903
|
+
budget_artifact_path = paths.execution_budgets_dir / f"{runtime_session_id}.json"
|
|
1904
|
+
runtime_sessions_dir = _execution_private_sessions_dir(
|
|
1905
|
+
cfg=run_cfg,
|
|
1906
|
+
run_id=paths.run_id,
|
|
1907
|
+
task_id=task_id,
|
|
1908
|
+
workspace_root=paths.root,
|
|
1909
|
+
)
|
|
1910
|
+
_cleanup_execution_private_sessions_dir(runtime_sessions_dir)
|
|
1911
|
+
task_attempts_raw = task.get("attempts")
|
|
1912
|
+
try:
|
|
1913
|
+
task_attempt_count = (
|
|
1914
|
+
max(0, int(task_attempts_raw if task_attempts_raw is not None else 0)) + 1
|
|
1915
|
+
)
|
|
1916
|
+
except (TypeError, ValueError):
|
|
1917
|
+
task_attempt_count = 1
|
|
1918
|
+
task_step_budget = _resolve_managed_task_step_budget(
|
|
1919
|
+
cfg=run_cfg,
|
|
1920
|
+
plan=plan,
|
|
1921
|
+
task=task,
|
|
1922
|
+
kind="managed_task",
|
|
1923
|
+
mode=effective_mode,
|
|
1924
|
+
verification_enabled=verify_mode != "off",
|
|
1925
|
+
max_steps_override=(effective.max_steps if max_steps_provided else None),
|
|
1926
|
+
attempt_count=task_attempt_count,
|
|
1927
|
+
image_count=0,
|
|
1928
|
+
)
|
|
1929
|
+
head_before_run = head_commit(paths.root) if paths.has_head_commit else None
|
|
1930
|
+
before_runtime_snapshot: dict[str, str] | None = None
|
|
1931
|
+
reporting_baseline: Any | None = None
|
|
1932
|
+
recording_surface = RecordingSurface(_make_rich_surface(console=console))
|
|
1933
|
+
asset_setup_error: str | None = None
|
|
1934
|
+
asset_setup_warnings: list[str] = []
|
|
1935
|
+
asset_usage_logger = AssetUsageLogger(run_paths=paths, task_id=task_id)
|
|
1936
|
+
asset_model_registry = ModelRegistry(cfg=run_cfg)
|
|
1937
|
+
asset_surface = (
|
|
1938
|
+
build_asset_surface(
|
|
1939
|
+
cfg=run_cfg,
|
|
1940
|
+
run_paths=paths,
|
|
1941
|
+
model_registry=asset_model_registry,
|
|
1942
|
+
)
|
|
1943
|
+
if run_cfg.assets.enabled
|
|
1944
|
+
else None
|
|
1945
|
+
)
|
|
1946
|
+
task_asset_mirror = _empty_forge_exec_task_asset_mirror(paths.root, task_id=task_id)
|
|
1947
|
+
if asset_surface is not None:
|
|
1948
|
+
try:
|
|
1949
|
+
task_asset_mirror = mirror_task_assets(
|
|
1950
|
+
task=task,
|
|
1951
|
+
plan=plan,
|
|
1952
|
+
surface=asset_surface,
|
|
1953
|
+
workspace_path=paths.root,
|
|
1954
|
+
)
|
|
1955
|
+
except AssetError as exc:
|
|
1956
|
+
if run_cfg.assets.worker.fail_on_mirror_error:
|
|
1957
|
+
asset_setup_error = (
|
|
1958
|
+
f"forge exec asset mirror failed: {sanitize_error_summary(str(exc))}"
|
|
1959
|
+
)
|
|
1960
|
+
else:
|
|
1961
|
+
asset_setup_warnings.append(
|
|
1962
|
+
f"forge exec asset mirror skipped: {sanitize_optional_error_summary(str(exc))}"
|
|
1963
|
+
)
|
|
1964
|
+
scope_warnings.extend(asset_setup_warnings)
|
|
1965
|
+
for entry in [
|
|
1966
|
+
*task_asset_mirror.primary,
|
|
1967
|
+
*task_asset_mirror.may_need,
|
|
1968
|
+
*task_asset_mirror.pinned,
|
|
1969
|
+
]:
|
|
1970
|
+
asset_usage_logger.mirror(
|
|
1971
|
+
asset_id=entry.asset_id,
|
|
1972
|
+
kind=entry.kind,
|
|
1973
|
+
status=entry.status,
|
|
1974
|
+
)
|
|
1975
|
+
has_mirrored_task_assets = bool(
|
|
1976
|
+
task_asset_mirror.primary or task_asset_mirror.may_need or task_asset_mirror.pinned
|
|
1977
|
+
)
|
|
1978
|
+
|
|
1979
|
+
run_code = 1
|
|
1980
|
+
run_err: str | None = asset_setup_error
|
|
1981
|
+
task_mcp_manager: Any | None = None
|
|
1982
|
+
asset_allocation: TaskAssetAllocation | None = None
|
|
1983
|
+
try:
|
|
1984
|
+
task_mcp_manager = _build_forge_task_scoped_mcp_manager(
|
|
1985
|
+
workspace_root=paths.root,
|
|
1986
|
+
session_id=runtime_session_id,
|
|
1987
|
+
task_scope=task_mcp_scope,
|
|
1988
|
+
)
|
|
1989
|
+
mcp_context_section = _build_forge_mcp_execution_context_section(
|
|
1990
|
+
task_scope=task_mcp_scope,
|
|
1991
|
+
mcp_manager=task_mcp_manager,
|
|
1992
|
+
)
|
|
1993
|
+
instruction_bundle = _build_forge_exec_instruction_bundle(
|
|
1994
|
+
plan=plan,
|
|
1995
|
+
task=task,
|
|
1996
|
+
root=paths.root,
|
|
1997
|
+
cfg=run_cfg,
|
|
1998
|
+
role_model=run_cfg.model,
|
|
1999
|
+
mode=effective_mode,
|
|
2000
|
+
yes=yes,
|
|
2001
|
+
deny_write_prefixes=[".alysis"],
|
|
2002
|
+
allow_write_globs=allowed_scope if scope_mode == "strict" else None,
|
|
2003
|
+
non_interactive=run_non_interactive,
|
|
2004
|
+
verification_enabled=prompt_verification_enabled,
|
|
2005
|
+
authoritative_verification_commands=(
|
|
2006
|
+
verify_commands if prompt_verification_enabled else None
|
|
2007
|
+
),
|
|
2008
|
+
api_key=api_key_override,
|
|
2009
|
+
subagents_enabled=False,
|
|
2010
|
+
leading_sections=[prepared_knowledge.prompt_section, mcp_context_section],
|
|
2011
|
+
)
|
|
2012
|
+
relevant_assets_section = ""
|
|
2013
|
+
if asset_surface is not None and task_asset_mirror.primary:
|
|
2014
|
+
asset_allocation = allocate_task_assets(
|
|
2015
|
+
task=task,
|
|
2016
|
+
plan=plan,
|
|
2017
|
+
mirror=task_asset_mirror,
|
|
2018
|
+
cfg=run_cfg,
|
|
2019
|
+
model_registry=asset_model_registry,
|
|
2020
|
+
instruction_token_budget=instruction_bundle.budget.final_instruction_budget,
|
|
2021
|
+
api_key=api_key_override,
|
|
2022
|
+
)
|
|
2023
|
+
for decision in asset_allocation.decisions:
|
|
2024
|
+
asset_usage_logger.allocation_decision(
|
|
2025
|
+
asset_id=decision.asset_id,
|
|
2026
|
+
mode=decision.mode,
|
|
2027
|
+
)
|
|
2028
|
+
relevant_assets_section = render_relevant_assets_section(
|
|
2029
|
+
mirror=task_asset_mirror,
|
|
2030
|
+
allocation=asset_allocation,
|
|
2031
|
+
cfg=run_cfg,
|
|
2032
|
+
surface=asset_surface,
|
|
2033
|
+
model_registry=asset_model_registry,
|
|
2034
|
+
api_key=api_key_override,
|
|
2035
|
+
)
|
|
2036
|
+
elif asset_surface is not None and (task_asset_mirror.may_need or task_asset_mirror.pinned):
|
|
2037
|
+
asset_allocation = TaskAssetAllocation(
|
|
2038
|
+
task_id=task_id,
|
|
2039
|
+
decisions=[],
|
|
2040
|
+
elapsed_ms=0,
|
|
2041
|
+
model=None,
|
|
2042
|
+
tokens_used={},
|
|
2043
|
+
fallback_used=False,
|
|
2044
|
+
fallback_reason=None,
|
|
2045
|
+
)
|
|
2046
|
+
relevant_assets_section = render_relevant_assets_section(
|
|
2047
|
+
mirror=task_asset_mirror,
|
|
2048
|
+
allocation=asset_allocation,
|
|
2049
|
+
cfg=run_cfg,
|
|
2050
|
+
surface=asset_surface,
|
|
2051
|
+
model_registry=asset_model_registry,
|
|
2052
|
+
api_key=api_key_override,
|
|
2053
|
+
)
|
|
2054
|
+
if relevant_assets_section:
|
|
2055
|
+
instruction_bundle = _build_forge_exec_instruction_bundle(
|
|
2056
|
+
plan=plan,
|
|
2057
|
+
task=task,
|
|
2058
|
+
root=paths.root,
|
|
2059
|
+
cfg=run_cfg,
|
|
2060
|
+
role_model=run_cfg.model,
|
|
2061
|
+
mode=effective_mode,
|
|
2062
|
+
yes=yes,
|
|
2063
|
+
deny_write_prefixes=[".alysis"],
|
|
2064
|
+
allow_write_globs=allowed_scope if scope_mode == "strict" else None,
|
|
2065
|
+
non_interactive=run_non_interactive,
|
|
2066
|
+
verification_enabled=prompt_verification_enabled,
|
|
2067
|
+
authoritative_verification_commands=(
|
|
2068
|
+
verify_commands if prompt_verification_enabled else None
|
|
2069
|
+
),
|
|
2070
|
+
api_key=api_key_override,
|
|
2071
|
+
subagents_enabled=False,
|
|
2072
|
+
leading_sections=[prepared_knowledge.prompt_section, mcp_context_section],
|
|
2073
|
+
relevant_assets_section=relevant_assets_section,
|
|
2074
|
+
)
|
|
2075
|
+
instruction = instruction_bundle.instruction
|
|
2076
|
+
_write_execution_context_artifact(
|
|
2077
|
+
paths=paths,
|
|
2078
|
+
task_id=task_id,
|
|
2079
|
+
context_text=instruction_bundle.artifact_text,
|
|
2080
|
+
)
|
|
2081
|
+
task_image_paths = _combined_forge_exec_image_paths(
|
|
2082
|
+
legacy_paths=list(instruction_bundle.image_paths),
|
|
2083
|
+
mirror=task_asset_mirror,
|
|
2084
|
+
allocation=asset_allocation,
|
|
2085
|
+
cfg=run_cfg,
|
|
2086
|
+
role_model=run_cfg.model,
|
|
2087
|
+
model_registry=asset_model_registry,
|
|
2088
|
+
usage_logger=asset_usage_logger,
|
|
2089
|
+
)
|
|
2090
|
+
task_step_budget = _resolve_managed_task_step_budget(
|
|
2091
|
+
cfg=run_cfg,
|
|
2092
|
+
plan=plan,
|
|
2093
|
+
task=task,
|
|
2094
|
+
kind="managed_task",
|
|
2095
|
+
mode=effective_mode,
|
|
2096
|
+
verification_enabled=verify_mode != "off",
|
|
2097
|
+
max_steps_override=(effective.max_steps if max_steps_provided else None),
|
|
2098
|
+
attempt_count=task_attempt_count,
|
|
2099
|
+
image_count=len(task_image_paths or []),
|
|
2100
|
+
)
|
|
2101
|
+
budget_artifact_payload = instruction_bundle.to_budget_artifact_payload()
|
|
2102
|
+
budget_artifact_payload["step_budget"] = task_step_budget.to_payload()
|
|
2103
|
+
budget_artifact_path = _write_execution_budget_artifact(
|
|
2104
|
+
paths=paths,
|
|
2105
|
+
task_id=task_id,
|
|
2106
|
+
payload=budget_artifact_payload,
|
|
2107
|
+
)
|
|
2108
|
+
before_runtime_snapshot = _snapshot_runtime_tree(paths.root)
|
|
2109
|
+
reporting_baseline = _capture_task_local_workspace_baseline(
|
|
2110
|
+
paths.root,
|
|
2111
|
+
before_commit=head_before_run,
|
|
2112
|
+
)
|
|
2113
|
+
if run_err is None:
|
|
2114
|
+
if asset_surface is not None and has_mirrored_task_assets:
|
|
2115
|
+
task_mcp_manager = compose_worker_asset_mcp_manager(
|
|
2116
|
+
base_manager=task_mcp_manager,
|
|
2117
|
+
asset_manager=build_worker_asset_mcp_manager(
|
|
2118
|
+
cfg=run_cfg,
|
|
2119
|
+
surface=asset_surface,
|
|
2120
|
+
model_registry=asset_model_registry,
|
|
2121
|
+
mirror=task_asset_mirror,
|
|
2122
|
+
usage_logger=asset_usage_logger,
|
|
2123
|
+
api_key=api_key_override,
|
|
2124
|
+
),
|
|
2125
|
+
)
|
|
2126
|
+
run_code = run_agent(
|
|
2127
|
+
cfg=run_cfg,
|
|
2128
|
+
root=paths.root,
|
|
2129
|
+
instruction=instruction,
|
|
2130
|
+
mode=effective_mode,
|
|
2131
|
+
runtime_kind=RuntimeKind.FORGE_EXEC,
|
|
2132
|
+
yes=yes,
|
|
2133
|
+
max_steps=task_step_budget.resolved_max_steps,
|
|
2134
|
+
no_log=no_log,
|
|
2135
|
+
api_key_override=api_key_override,
|
|
2136
|
+
console=console,
|
|
2137
|
+
surface=recording_surface,
|
|
2138
|
+
image_paths=task_image_paths,
|
|
2139
|
+
deny_write_prefixes=[".alysis"],
|
|
2140
|
+
allow_write_globs=allowed_scope if scope_mode == "strict" else None,
|
|
2141
|
+
non_interactive=run_non_interactive,
|
|
2142
|
+
session_log_dir_override=runtime_sessions_dir,
|
|
2143
|
+
session_id_override=runtime_session_id,
|
|
2144
|
+
usage_role=f"forge_exec:{task_id}",
|
|
2145
|
+
enable_compaction=False,
|
|
2146
|
+
enable_tool_output_offload=True,
|
|
2147
|
+
enable_conversation_summarization=True,
|
|
2148
|
+
compaction_profile="execution",
|
|
2149
|
+
enable_chat_turn_step_budget=False,
|
|
2150
|
+
one_shot_execution=True,
|
|
2151
|
+
verification_enabled=prompt_verification_enabled,
|
|
2152
|
+
authoritative_verification_commands=(
|
|
2153
|
+
verify_commands if prompt_verification_enabled else None
|
|
2154
|
+
),
|
|
2155
|
+
subagents_enabled=False,
|
|
2156
|
+
enforce_explicit_subagent_requests=False,
|
|
2157
|
+
mcp_manager=task_mcp_manager,
|
|
2158
|
+
session_source_metadata={
|
|
2159
|
+
"surface": "forge_exec",
|
|
2160
|
+
"run_id": paths.run_id,
|
|
2161
|
+
"task_id": str(task_id),
|
|
2162
|
+
},
|
|
2163
|
+
)
|
|
2164
|
+
except Exception as e: # noqa: BLE001
|
|
2165
|
+
run_code = 1
|
|
2166
|
+
run_err = str(e)
|
|
2167
|
+
if not instruction:
|
|
2168
|
+
_write_execution_context_artifact(
|
|
2169
|
+
paths=paths,
|
|
2170
|
+
task_id=task_id,
|
|
2171
|
+
context_text=(
|
|
2172
|
+
"# Task Context Pack\n\n"
|
|
2173
|
+
"Task execution setup failed before the agent started.\n\n"
|
|
2174
|
+
f"- Error: {run_err}\n"
|
|
2175
|
+
),
|
|
2176
|
+
)
|
|
2177
|
+
if not budget_artifact_path.exists():
|
|
2178
|
+
_write_execution_budget_artifact(
|
|
2179
|
+
paths=paths,
|
|
2180
|
+
task_id=task_id,
|
|
2181
|
+
payload={
|
|
2182
|
+
"error": run_err,
|
|
2183
|
+
"step_budget": task_step_budget.to_payload(),
|
|
2184
|
+
},
|
|
2185
|
+
)
|
|
2186
|
+
if before_runtime_snapshot is None:
|
|
2187
|
+
before_runtime_snapshot = _snapshot_runtime_tree(paths.root)
|
|
2188
|
+
if reporting_baseline is None:
|
|
2189
|
+
reporting_baseline = _capture_task_local_workspace_baseline(
|
|
2190
|
+
paths.root,
|
|
2191
|
+
before_commit=head_before_run,
|
|
2192
|
+
)
|
|
2193
|
+
finally:
|
|
2194
|
+
if task_mcp_manager is not None:
|
|
2195
|
+
task_mcp_manager.close()
|
|
2196
|
+
|
|
2197
|
+
assert before_runtime_snapshot is not None
|
|
2198
|
+
assert reporting_baseline is not None
|
|
2199
|
+
after_runtime_snapshot = _snapshot_runtime_tree(paths.root)
|
|
2200
|
+
runtime_artifact_changes = _runtime_snapshot_changed_files(
|
|
2201
|
+
before_runtime_snapshot,
|
|
2202
|
+
after_runtime_snapshot,
|
|
2203
|
+
)
|
|
2204
|
+
authorized_runtime_side_effects = _authorized_custom_tool_runtime_side_effects(
|
|
2205
|
+
sessions_dir=runtime_sessions_dir,
|
|
2206
|
+
session_id=runtime_session_id,
|
|
2207
|
+
)
|
|
2208
|
+
runtime_artifact_changes = [
|
|
2209
|
+
path for path in runtime_artifact_changes if path not in authorized_runtime_side_effects
|
|
2210
|
+
]
|
|
2211
|
+
runtime_artifacts_changed = bool(runtime_artifact_changes)
|
|
2212
|
+
if asset_allocation is not None:
|
|
2213
|
+
write_task_asset_allocation(
|
|
2214
|
+
run_paths=paths,
|
|
2215
|
+
allocation=asset_allocation,
|
|
2216
|
+
started_at=started_at,
|
|
2217
|
+
)
|
|
2218
|
+
asset_usage_logger.summary(
|
|
2219
|
+
primary_count=len(task_asset_mirror.primary),
|
|
2220
|
+
may_need_count=len(task_asset_mirror.may_need),
|
|
2221
|
+
pinned_count=len(task_asset_mirror.pinned),
|
|
2222
|
+
)
|
|
2223
|
+
try:
|
|
2224
|
+
exec_artifacts = _write_exec_log_artifacts(
|
|
2225
|
+
paths=paths,
|
|
2226
|
+
task_id=task_id,
|
|
2227
|
+
cfg=run_cfg,
|
|
2228
|
+
no_log=no_log,
|
|
2229
|
+
before_logs=None,
|
|
2230
|
+
sessions_dir=runtime_sessions_dir,
|
|
2231
|
+
expected_session_id=runtime_session_id,
|
|
2232
|
+
)
|
|
2233
|
+
finally:
|
|
2234
|
+
_cleanup_execution_private_sessions_dir(runtime_sessions_dir)
|
|
2235
|
+
|
|
2236
|
+
safe_task_component = _safe_task_file_component(task_id)
|
|
2237
|
+
patch_path = paths.execution_patches_dir / f"{safe_task_component}.diff"
|
|
2238
|
+
scratch_artifact_dir = paths.execution_dir / "scratch" / safe_task_component
|
|
2239
|
+
scratch_artifact_dir.mkdir(parents=True, exist_ok=True)
|
|
2240
|
+
success = run_code == 0 and not runtime_artifacts_changed
|
|
2241
|
+
pr_report_state_upgraded = False
|
|
2242
|
+
head_after_run = head_commit(paths.root) if paths.has_head_commit else None
|
|
2243
|
+
# Captured before cleanup so scope triage can tell a file the task created from an
|
|
2244
|
+
# existing neighbour it merely edited.
|
|
2245
|
+
baseline_workspace_paths = set(reporting_baseline.before_snapshot)
|
|
2246
|
+
try:
|
|
2247
|
+
report_diff = _build_task_local_workspace_reporting_diff(
|
|
2248
|
+
paths.root,
|
|
2249
|
+
baseline=reporting_baseline,
|
|
2250
|
+
after_commit=head_after_run,
|
|
2251
|
+
)
|
|
2252
|
+
finally:
|
|
2253
|
+
_cleanup_task_local_workspace_baseline(reporting_baseline)
|
|
2254
|
+
patch_path.write_text(report_diff.patch_text, encoding="utf-8")
|
|
2255
|
+
scratch_scope_diagnostics = relocate_known_scratch_artifacts(
|
|
2256
|
+
root=paths.root,
|
|
2257
|
+
artifact_dir=scratch_artifact_dir,
|
|
2258
|
+
)
|
|
2259
|
+
relocated_scratch_paths = {item.path for item in scratch_scope_diagnostics}
|
|
2260
|
+
changed_files = list(report_diff.changed_files)
|
|
2261
|
+
if relocated_scratch_paths:
|
|
2262
|
+
changed_files = [path for path in changed_files if path not in relocated_scratch_paths]
|
|
2263
|
+
agent_added_non_material_paths: list[str] = []
|
|
2264
|
+
if head_before_run and head_after_run and head_after_run != head_before_run:
|
|
2265
|
+
agent_added_non_material_paths = [
|
|
2266
|
+
path
|
|
2267
|
+
for path in added_files_since(
|
|
2268
|
+
paths.root,
|
|
2269
|
+
before_commit=head_before_run,
|
|
2270
|
+
after_commit=head_after_run,
|
|
2271
|
+
)
|
|
2272
|
+
if is_non_material_untracked_path(path)
|
|
2273
|
+
]
|
|
2274
|
+
if agent_added_non_material_paths:
|
|
2275
|
+
changed_files = [
|
|
2276
|
+
path for path in changed_files if path not in agent_added_non_material_paths
|
|
2277
|
+
]
|
|
2278
|
+
pr_material_changed_files = (
|
|
2279
|
+
_drop_parent_directory_placeholders(
|
|
2280
|
+
_merge_changed_files(
|
|
2281
|
+
list(changed_files),
|
|
2282
|
+
list_changed_files_including_untracked(paths.root),
|
|
2283
|
+
)
|
|
2284
|
+
)
|
|
2285
|
+
if pr
|
|
2286
|
+
else list(changed_files)
|
|
2287
|
+
)
|
|
2288
|
+
scope_changed_files = pr_material_changed_files if pr else changed_files
|
|
2289
|
+
scope_inspection_error = report_diff.inspection_error
|
|
2290
|
+
scope_violation_files: list[str] = []
|
|
2291
|
+
scope_diagnostics = [item.to_payload() for item in scratch_scope_diagnostics]
|
|
2292
|
+
for diagnostic in scratch_scope_diagnostics:
|
|
2293
|
+
scope_warnings.append(
|
|
2294
|
+
"Scope recovery: "
|
|
2295
|
+
f"{diagnostic.classification} for {diagnostic.path} "
|
|
2296
|
+
f"({diagnostic.reason_code}; action={diagnostic.recommended_action})."
|
|
2297
|
+
)
|
|
2298
|
+
material_changes_detected = bool(pr_material_changed_files)
|
|
2299
|
+
nonzero_agent_exit = run_code != 0 and run_err is None
|
|
2300
|
+
scope_amendment_payloads: list[dict[str, Any]] = []
|
|
2301
|
+
scope_amended_patterns: list[str] = []
|
|
2302
|
+
scope_in_scope_changes = False
|
|
2303
|
+
adjacent_only_changes = False
|
|
2304
|
+
strict_scope_blocked = False
|
|
2305
|
+
can_attempt_pr_flow = False
|
|
2306
|
+
pr_nonzero_salvage_allowed = False
|
|
2307
|
+
pr_nonzero_salvage_attempted = False
|
|
2308
|
+
no_material_changes_blocked = False
|
|
2309
|
+
result_kind: str | None = None
|
|
2310
|
+
noop_reason: str | None = None
|
|
2311
|
+
analysis_only_noop_accepted = False
|
|
2312
|
+
if scope_mode in {"warn", "strict"}:
|
|
2313
|
+
if scope_inspection_error:
|
|
2314
|
+
if scope_mode == "strict":
|
|
2315
|
+
strict_scope_blocked = True
|
|
2316
|
+
success = False
|
|
2317
|
+
run_err = (run_err + "; " if run_err else "") + scope_inspection_error
|
|
2318
|
+
else:
|
|
2319
|
+
scope_warnings.append(scope_inspection_error)
|
|
2320
|
+
# Adjacent changes are triaged into scope amendments only in strict mode: warn
|
|
2321
|
+
# mode has nothing to unblock, so amending there would silently rewrite the plan
|
|
2322
|
+
# for a mode whose whole contract is "report, change nothing".
|
|
2323
|
+
scope_assessment = assess_scope_changes(
|
|
2324
|
+
scope_changed_files,
|
|
2325
|
+
allowed_scope,
|
|
2326
|
+
task=task,
|
|
2327
|
+
root=paths.root,
|
|
2328
|
+
extra_diagnostics=scratch_scope_diagnostics,
|
|
2329
|
+
amend_adjacent=scope_mode == "strict",
|
|
2330
|
+
new_paths=[
|
|
2331
|
+
path
|
|
2332
|
+
for path in scope_changed_files
|
|
2333
|
+
if _normalize_changed_file_path(path) not in baseline_workspace_paths
|
|
2334
|
+
],
|
|
2335
|
+
)
|
|
2336
|
+
scope_changed_files = list(scope_assessment.effective_changed_files)
|
|
2337
|
+
scope_diagnostics = [item.to_payload() for item in scope_assessment.diagnostics]
|
|
2338
|
+
scope_in_scope_changes = bool(scope_assessment.in_scope_paths)
|
|
2339
|
+
for diagnostic in scope_assessment.diagnostics:
|
|
2340
|
+
if diagnostic.allowed:
|
|
2341
|
+
warning = (
|
|
2342
|
+
"Scope recovery: "
|
|
2343
|
+
f"{diagnostic.classification} for {diagnostic.path} "
|
|
2344
|
+
f"({diagnostic.reason_code}; action={diagnostic.recommended_action})."
|
|
2345
|
+
)
|
|
2346
|
+
if warning not in scope_warnings:
|
|
2347
|
+
scope_warnings.append(warning)
|
|
2348
|
+
if scope_assessment.amendments:
|
|
2349
|
+
scope_amendment_payloads = [item.to_payload() for item in scope_assessment.amendments]
|
|
2350
|
+
scope_amended_patterns = apply_scope_amendments(
|
|
2351
|
+
task,
|
|
2352
|
+
scope_assessment.amendments,
|
|
2353
|
+
)
|
|
2354
|
+
adjacent_only_changes = not scope_in_scope_changes
|
|
2355
|
+
amendment_preview = ", ".join(
|
|
2356
|
+
f"{item.path} ({item.reason_code})" for item in scope_assessment.amendments[:20]
|
|
2357
|
+
)
|
|
2358
|
+
if len(scope_assessment.amendments) > 20:
|
|
2359
|
+
amendment_preview += ", ..."
|
|
2360
|
+
scope_warnings.append(
|
|
2361
|
+
f"Scope amended: {len(scope_assessment.amendments)} adjacent change(s) "
|
|
2362
|
+
f"accepted and added to write_scope: {amendment_preview}."
|
|
2363
|
+
)
|
|
2364
|
+
events.emit(
|
|
2365
|
+
EVENT_SCOPE_AMENDED,
|
|
2366
|
+
{
|
|
2367
|
+
"task_id": task_id,
|
|
2368
|
+
"scope_mode": scope_mode,
|
|
2369
|
+
"allowed_scope": list(allowed_scope),
|
|
2370
|
+
"added_patterns": list(scope_amended_patterns),
|
|
2371
|
+
"amendments": scope_amendment_payloads,
|
|
2372
|
+
"adjacent_paths": list(scope_assessment.adjacent_paths),
|
|
2373
|
+
"adjacent_only": bool(adjacent_only_changes),
|
|
2374
|
+
},
|
|
2375
|
+
)
|
|
2376
|
+
if not scope_assessment.ok:
|
|
2377
|
+
violations = scope_assessment.blocking_paths
|
|
2378
|
+
scope_violation_files = list(violations)
|
|
2379
|
+
preview = ", ".join(violations[:20])
|
|
2380
|
+
if len(violations) > 20:
|
|
2381
|
+
preview += ", ..."
|
|
2382
|
+
classes = sorted(
|
|
2383
|
+
{
|
|
2384
|
+
str(item.get("classification") or "unknown")
|
|
2385
|
+
for item in scope_diagnostics
|
|
2386
|
+
if not bool(item.get("allowed"))
|
|
2387
|
+
}
|
|
2388
|
+
)
|
|
2389
|
+
scope_msg = (
|
|
2390
|
+
f"Out-of-scope file changes detected ({len(violations)}): {preview}. "
|
|
2391
|
+
f"Allowed scope: {allowed_scope or ['(none)']}."
|
|
2392
|
+
)
|
|
2393
|
+
if classes:
|
|
2394
|
+
scope_msg += f" Scope classifications: {', '.join(classes)}."
|
|
2395
|
+
# The full triage plus a ready-to-paste scope patch, so a human or the
|
|
2396
|
+
# replanner can fix the plan in one step instead of re-deriving it.
|
|
2397
|
+
violation_lines = describe_scope_violations(scope_assessment.diagnostics)
|
|
2398
|
+
if violation_lines:
|
|
2399
|
+
scope_msg += " Classified changes: " + " | ".join(violation_lines) + "."
|
|
2400
|
+
if scope_assessment.protected_paths:
|
|
2401
|
+
scope_msg += (
|
|
2402
|
+
" Protected paths (never amendable): "
|
|
2403
|
+
+ ", ".join(scope_assessment.protected_paths)
|
|
2404
|
+
+ "."
|
|
2405
|
+
)
|
|
2406
|
+
if scope_assessment.suggested_scope_patterns:
|
|
2407
|
+
scope_msg += (
|
|
2408
|
+
" Suggested write_scope additions: "
|
|
2409
|
+
+ ", ".join(scope_assessment.suggested_scope_patterns)
|
|
2410
|
+
+ "."
|
|
2411
|
+
)
|
|
2412
|
+
if scope_mode == "strict":
|
|
2413
|
+
strict_scope_blocked = True
|
|
2414
|
+
success = False
|
|
2415
|
+
run_err = (
|
|
2416
|
+
(run_err + "; " if run_err else "")
|
|
2417
|
+
+ scope_msg
|
|
2418
|
+
+ " Task was blocked due to strict scope isolation."
|
|
2419
|
+
)
|
|
2420
|
+
else:
|
|
2421
|
+
scope_warnings.append(scope_msg)
|
|
2422
|
+
|
|
2423
|
+
if pr and not runtime_artifacts_changed and not material_changes_detected:
|
|
2424
|
+
success = False
|
|
2425
|
+
|
|
2426
|
+
if run_code == 0 and not runtime_artifacts_changed and not material_changes_detected:
|
|
2427
|
+
if _task_is_analysis_only(task):
|
|
2428
|
+
if pr:
|
|
2429
|
+
if not pr_base_branch:
|
|
2430
|
+
success = False
|
|
2431
|
+
run_err = (run_err + "; " if run_err else "") + (
|
|
2432
|
+
"missing PR base branch context for analysis-only no-op cleanup"
|
|
2433
|
+
)
|
|
2434
|
+
merge_result = "not merged: analysis-only no-op cleanup failed"
|
|
2435
|
+
else:
|
|
2436
|
+
try:
|
|
2437
|
+
checkout_branch(
|
|
2438
|
+
paths.root,
|
|
2439
|
+
pr_base_branch,
|
|
2440
|
+
base_branch=pr_base_branch,
|
|
2441
|
+
)
|
|
2442
|
+
if keep_branch or not pr_task_branch or pr_task_branch == pr_base_branch:
|
|
2443
|
+
merge_result = "no merge required (analysis-only no-op; branch kept)"
|
|
2444
|
+
else:
|
|
2445
|
+
try:
|
|
2446
|
+
delete_branch(paths.root, pr_task_branch)
|
|
2447
|
+
merge_result = (
|
|
2448
|
+
"no merge required (analysis-only no-op; branch deleted)"
|
|
2449
|
+
)
|
|
2450
|
+
except GitOpsError as cleanup_err:
|
|
2451
|
+
scope_warnings.append(
|
|
2452
|
+
"Branch cleanup warning: "
|
|
2453
|
+
f"failed to delete {pr_task_branch}: {cleanup_err}"
|
|
2454
|
+
)
|
|
2455
|
+
merge_result = (
|
|
2456
|
+
"no merge required (analysis-only no-op; "
|
|
2457
|
+
f"branch delete warning: {cleanup_err})"
|
|
2458
|
+
)
|
|
2459
|
+
success = True
|
|
2460
|
+
except GitOpsError as e:
|
|
2461
|
+
success = False
|
|
2462
|
+
run_err = (run_err + "; " if run_err else "") + (
|
|
2463
|
+
f"PR no-op cleanup failed: {e}"
|
|
2464
|
+
)
|
|
2465
|
+
merge_result = f"not merged: analysis-only no-op cleanup failed: {e}"
|
|
2466
|
+
else:
|
|
2467
|
+
success = True
|
|
2468
|
+
if success:
|
|
2469
|
+
result_kind = "success_noop"
|
|
2470
|
+
noop_reason = "analysis_only"
|
|
2471
|
+
analysis_only_noop_accepted = True
|
|
2472
|
+
verify_summary = "verification skipped: analysis-only task made no changes"
|
|
2473
|
+
_emit_verification_unavailable(
|
|
2474
|
+
events,
|
|
2475
|
+
task_id=task_id,
|
|
2476
|
+
policy=verify_mode,
|
|
2477
|
+
reason=verify_summary,
|
|
2478
|
+
blocking=False,
|
|
2479
|
+
)
|
|
2480
|
+
elif _task_declares_explicit_write_scope(task):
|
|
2481
|
+
no_material_changes_blocked = True
|
|
2482
|
+
success = False
|
|
2483
|
+
run_err = (run_err + "; " if run_err else "") + (
|
|
2484
|
+
"No material file changes were detected for a task with explicit write_scope. "
|
|
2485
|
+
"The task produced no file changes at all -- not in the declared scope and "
|
|
2486
|
+
"not adjacent to it -- so its expected local file update was not produced."
|
|
2487
|
+
)
|
|
2488
|
+
elif (
|
|
2489
|
+
run_code == 0
|
|
2490
|
+
and not runtime_artifacts_changed
|
|
2491
|
+
and adjacent_only_changes
|
|
2492
|
+
and not strict_scope_blocked
|
|
2493
|
+
):
|
|
2494
|
+
# No declared scope path changed, but the task did do work: every change was
|
|
2495
|
+
# adjacent and has been amended into write_scope. That is a pass with an
|
|
2496
|
+
# amendment, not the "task did nothing" rejection above.
|
|
2497
|
+
result_kind = result_kind or "success_scope_amended"
|
|
2498
|
+
scope_warnings.append(
|
|
2499
|
+
"No declared write_scope path changed; every change was adjacent to the "
|
|
2500
|
+
"declared scope and write_scope was amended to cover it."
|
|
2501
|
+
)
|
|
2502
|
+
|
|
2503
|
+
pr_nonzero_salvage_allowed = (
|
|
2504
|
+
pr
|
|
2505
|
+
and nonzero_agent_exit
|
|
2506
|
+
and verify_mode == "strict"
|
|
2507
|
+
and bool(verify_commands)
|
|
2508
|
+
and not runtime_artifacts_changed
|
|
2509
|
+
and material_changes_detected
|
|
2510
|
+
and not strict_scope_blocked
|
|
2511
|
+
)
|
|
2512
|
+
|
|
2513
|
+
if nonzero_agent_exit and pr_nonzero_salvage_allowed:
|
|
2514
|
+
pr_nonzero_salvage_attempted = True
|
|
2515
|
+
scope_warnings.append(
|
|
2516
|
+
f"PR flow attempted to salvage a non-zero agent exit ({run_code}); "
|
|
2517
|
+
"acceptance requires strict verification and PR gates."
|
|
2518
|
+
)
|
|
2519
|
+
elif nonzero_agent_exit:
|
|
2520
|
+
success = False
|
|
2521
|
+
run_err = (run_err + "; " if run_err else "") + (
|
|
2522
|
+
f"agent exited non-zero ({run_code}); refusing to accept partial task result"
|
|
2523
|
+
)
|
|
2524
|
+
|
|
2525
|
+
can_attempt_pr_flow = (
|
|
2526
|
+
pr
|
|
2527
|
+
and not runtime_artifacts_changed
|
|
2528
|
+
and material_changes_detected
|
|
2529
|
+
and not strict_scope_blocked
|
|
2530
|
+
and (run_code == 0 or pr_nonzero_salvage_allowed)
|
|
2531
|
+
)
|
|
2532
|
+
|
|
2533
|
+
if can_attempt_pr_flow:
|
|
2534
|
+
success = True
|
|
2535
|
+
if not pr_base_branch or not pr_task_branch:
|
|
2536
|
+
success = False
|
|
2537
|
+
run_err = (run_err + "; " if run_err else "") + "missing PR branch context"
|
|
2538
|
+
merge_result = "not merged"
|
|
2539
|
+
else:
|
|
2540
|
+
try:
|
|
2541
|
+
non_material_untracked_paths = list_untracked_packaging_metadata_paths(paths.root)
|
|
2542
|
+
stage_all(paths.root)
|
|
2543
|
+
unstage_staged_prefixes(
|
|
2544
|
+
paths.root,
|
|
2545
|
+
[".alysis", ".alysis_images", "alysis-feedback"],
|
|
2546
|
+
)
|
|
2547
|
+
ensure_not_staged_prefixes(
|
|
2548
|
+
paths.root,
|
|
2549
|
+
[".alysis", ".alysis_images", "alysis-feedback"],
|
|
2550
|
+
)
|
|
2551
|
+
if non_material_untracked_paths:
|
|
2552
|
+
unstage_staged_paths(paths.root, non_material_untracked_paths)
|
|
2553
|
+
ensure_not_staged_paths(paths.root, non_material_untracked_paths)
|
|
2554
|
+
if has_grounded_rust_target_runtime_artifacts(paths.root):
|
|
2555
|
+
staged_now = staged_files(paths.root)
|
|
2556
|
+
unstage_staged_runtime_artifacts(
|
|
2557
|
+
paths.root,
|
|
2558
|
+
current_paths=staged_now,
|
|
2559
|
+
)
|
|
2560
|
+
staged_now = staged_files(paths.root)
|
|
2561
|
+
ensure_not_staged_runtime_artifacts(
|
|
2562
|
+
paths.root,
|
|
2563
|
+
current_paths=staged_now,
|
|
2564
|
+
)
|
|
2565
|
+
commit_title = str(task.get("title") or "").strip() or "task update"
|
|
2566
|
+
commit_hash = commit_all(
|
|
2567
|
+
paths.root,
|
|
2568
|
+
message=f"{task_id}: {commit_title}",
|
|
2569
|
+
)
|
|
2570
|
+
patch_text = format_patch_stdout(paths.root, base_branch=pr_base_branch)
|
|
2571
|
+
patch_path.write_text(
|
|
2572
|
+
patch_text if patch_text else "(empty format-patch output)\n",
|
|
2573
|
+
encoding="utf-8",
|
|
2574
|
+
)
|
|
2575
|
+
changed_files = changed_files_between(
|
|
2576
|
+
paths.root,
|
|
2577
|
+
revspec=f"{pr_base_branch}..HEAD",
|
|
2578
|
+
)
|
|
2579
|
+
pr_report_state_upgraded = True
|
|
2580
|
+
except GitOpsError as e:
|
|
2581
|
+
success = False
|
|
2582
|
+
run_err = (run_err + "; " if run_err else "") + f"PR flow failed: {e}"
|
|
2583
|
+
merge_result = f"not merged: {e}"
|
|
2584
|
+
|
|
2585
|
+
if success and remote_settings.enabled:
|
|
2586
|
+
if not pr_task_branch:
|
|
2587
|
+
success = False
|
|
2588
|
+
run_err = (
|
|
2589
|
+
run_err + "; " if run_err else ""
|
|
2590
|
+
) + "missing task branch for remote sync"
|
|
2591
|
+
merge_result = "not merged: remote sync branch context missing"
|
|
2592
|
+
else:
|
|
2593
|
+
remote_name = remote_settings.remote_name
|
|
2594
|
+
provider = "unknown"
|
|
2595
|
+
remote_record = init_remote_record(
|
|
2596
|
+
task_id=task_id,
|
|
2597
|
+
remote=remote_name,
|
|
2598
|
+
provider=provider,
|
|
2599
|
+
)
|
|
2600
|
+
remote_errors = remote_record["errors"]
|
|
2601
|
+
assert isinstance(remote_errors, list)
|
|
2602
|
+
try:
|
|
2603
|
+
remote_url = get_remote_url(paths.root, remote_name)
|
|
2604
|
+
provider = resolve_provider(
|
|
2605
|
+
settings_provider=remote_settings.provider,
|
|
2606
|
+
remote_url=remote_url,
|
|
2607
|
+
)
|
|
2608
|
+
remote_record["provider"] = provider
|
|
2609
|
+
except RemoteSyncError as e:
|
|
2610
|
+
msg = f"remote discovery failed: {e}"
|
|
2611
|
+
remote_errors.append(msg)
|
|
2612
|
+
if remote_settings.strict:
|
|
2613
|
+
success = False
|
|
2614
|
+
run_err = (run_err + "; " if run_err else "") + msg
|
|
2615
|
+
merge_result = f"not merged: {msg}"
|
|
2616
|
+
else:
|
|
2617
|
+
scope_warnings.append(msg)
|
|
2618
|
+
|
|
2619
|
+
if success:
|
|
2620
|
+
pushed_branch, branch_output = push_branch(
|
|
2621
|
+
paths.root,
|
|
2622
|
+
remote=remote_name,
|
|
2623
|
+
branch=pr_task_branch,
|
|
2624
|
+
)
|
|
2625
|
+
remote_record["pushed_branch"] = pushed_branch
|
|
2626
|
+
remote_record["branch_push_output"] = truncate_output(branch_output)
|
|
2627
|
+
if not pushed_branch:
|
|
2628
|
+
msg = f"remote branch push failed: {branch_output or 'unknown error'}"
|
|
2629
|
+
remote_errors.append(msg)
|
|
2630
|
+
if remote_settings.strict:
|
|
2631
|
+
success = False
|
|
2632
|
+
run_err = (run_err + "; " if run_err else "") + msg
|
|
2633
|
+
merge_result = f"not merged: {msg}"
|
|
2634
|
+
else:
|
|
2635
|
+
scope_warnings.append(msg)
|
|
2636
|
+
|
|
2637
|
+
if success and remote_settings.create_pr and remote_record is not None:
|
|
2638
|
+
created_pr, pr_url, pr_id, pr_output = ensure_pr_or_mr(
|
|
2639
|
+
paths.root,
|
|
2640
|
+
provider=str(remote_record.get("provider") or "unknown"),
|
|
2641
|
+
base_branch=pr_base_branch,
|
|
2642
|
+
head_branch=pr_task_branch,
|
|
2643
|
+
title=(
|
|
2644
|
+
f"{task_id}: "
|
|
2645
|
+
f"{str(task.get('title') or '').strip() or 'task update'}"
|
|
2646
|
+
),
|
|
2647
|
+
body=instruction[:4000],
|
|
2648
|
+
)
|
|
2649
|
+
remote_record["created_pr"] = created_pr
|
|
2650
|
+
remote_record["pr_url"] = pr_url
|
|
2651
|
+
remote_record["pr_number_or_iid"] = pr_id
|
|
2652
|
+
remote_record["pr_output"] = truncate_output(pr_output)
|
|
2653
|
+
if created_pr and pr_url:
|
|
2654
|
+
task["remote_pr_url"] = pr_url
|
|
2655
|
+
task["remote_provider"] = str(
|
|
2656
|
+
remote_record.get("provider") or "unknown"
|
|
2657
|
+
)
|
|
2658
|
+
save_plan(paths, plan)
|
|
2659
|
+
if not created_pr:
|
|
2660
|
+
msg = f"remote PR/MR creation failed: {pr_output or 'unknown error'}"
|
|
2661
|
+
remote_errors.append(msg)
|
|
2662
|
+
if remote_settings.strict:
|
|
2663
|
+
success = False
|
|
2664
|
+
run_err = (run_err + "; " if run_err else "") + msg
|
|
2665
|
+
merge_result = f"not merged: {msg}"
|
|
2666
|
+
else:
|
|
2667
|
+
scope_warnings.append(msg)
|
|
2668
|
+
|
|
2669
|
+
if remote_record is not None:
|
|
2670
|
+
write_remote_record(
|
|
2671
|
+
execution_dir=paths.execution_dir,
|
|
2672
|
+
task_id=task_id,
|
|
2673
|
+
record=remote_record,
|
|
2674
|
+
)
|
|
2675
|
+
|
|
2676
|
+
def _run_repair_agent(repair_instruction: str, attempt: int) -> int:
|
|
2677
|
+
"""Re-run the executing agent on this task with a repair prompt.
|
|
2678
|
+
|
|
2679
|
+
Same machinery as the original invocation -- same config, mode,
|
|
2680
|
+
write guard, budget and MCP scope -- only the instruction and the
|
|
2681
|
+
session id differ, so a repair is not a weaker kind of run.
|
|
2682
|
+
"""
|
|
2683
|
+
repair_session_id = f"{runtime_session_id}-repair{attempt}"
|
|
2684
|
+
repair_sessions_dir = _execution_private_sessions_dir(
|
|
2685
|
+
cfg=run_cfg,
|
|
2686
|
+
run_id=paths.run_id,
|
|
2687
|
+
task_id=f"{task_id}-repair{attempt}",
|
|
2688
|
+
workspace_root=paths.root,
|
|
2689
|
+
)
|
|
2690
|
+
_cleanup_execution_private_sessions_dir(repair_sessions_dir)
|
|
2691
|
+
repair_mcp_manager: Any | None = None
|
|
2692
|
+
try:
|
|
2693
|
+
repair_mcp_manager = _build_forge_task_scoped_mcp_manager(
|
|
2694
|
+
workspace_root=paths.root,
|
|
2695
|
+
session_id=repair_session_id,
|
|
2696
|
+
task_scope=task_mcp_scope,
|
|
2697
|
+
)
|
|
2698
|
+
if asset_surface is not None and has_mirrored_task_assets:
|
|
2699
|
+
repair_mcp_manager = compose_worker_asset_mcp_manager(
|
|
2700
|
+
base_manager=repair_mcp_manager,
|
|
2701
|
+
asset_manager=build_worker_asset_mcp_manager(
|
|
2702
|
+
cfg=run_cfg,
|
|
2703
|
+
surface=asset_surface,
|
|
2704
|
+
model_registry=asset_model_registry,
|
|
2705
|
+
mirror=task_asset_mirror,
|
|
2706
|
+
usage_logger=asset_usage_logger,
|
|
2707
|
+
api_key=api_key_override,
|
|
2708
|
+
),
|
|
2709
|
+
)
|
|
2710
|
+
return run_agent(
|
|
2711
|
+
cfg=run_cfg,
|
|
2712
|
+
root=paths.root,
|
|
2713
|
+
instruction=repair_instruction,
|
|
2714
|
+
mode=effective_mode,
|
|
2715
|
+
runtime_kind=RuntimeKind.FORGE_EXEC,
|
|
2716
|
+
yes=yes,
|
|
2717
|
+
max_steps=task_step_budget.resolved_max_steps,
|
|
2718
|
+
no_log=no_log,
|
|
2719
|
+
api_key_override=api_key_override,
|
|
2720
|
+
console=console,
|
|
2721
|
+
surface=recording_surface,
|
|
2722
|
+
image_paths=task_image_paths,
|
|
2723
|
+
deny_write_prefixes=[".alysis"],
|
|
2724
|
+
allow_write_globs=allowed_scope if scope_mode == "strict" else None,
|
|
2725
|
+
non_interactive=run_non_interactive,
|
|
2726
|
+
session_log_dir_override=repair_sessions_dir,
|
|
2727
|
+
session_id_override=repair_session_id,
|
|
2728
|
+
usage_role=f"forge_exec:{task_id}:repair{attempt}",
|
|
2729
|
+
enable_compaction=False,
|
|
2730
|
+
enable_tool_output_offload=True,
|
|
2731
|
+
enable_conversation_summarization=True,
|
|
2732
|
+
compaction_profile="execution",
|
|
2733
|
+
enable_chat_turn_step_budget=False,
|
|
2734
|
+
one_shot_execution=True,
|
|
2735
|
+
verification_enabled=prompt_verification_enabled,
|
|
2736
|
+
authoritative_verification_commands=(
|
|
2737
|
+
verify_commands if prompt_verification_enabled else None
|
|
2738
|
+
),
|
|
2739
|
+
subagents_enabled=False,
|
|
2740
|
+
enforce_explicit_subagent_requests=False,
|
|
2741
|
+
mcp_manager=repair_mcp_manager,
|
|
2742
|
+
session_source_metadata={
|
|
2743
|
+
"surface": "forge_exec",
|
|
2744
|
+
"run_id": paths.run_id,
|
|
2745
|
+
"task_id": str(task_id),
|
|
2746
|
+
"verification_repair_attempt": int(attempt),
|
|
2747
|
+
},
|
|
2748
|
+
)
|
|
2749
|
+
finally:
|
|
2750
|
+
if repair_mcp_manager is not None:
|
|
2751
|
+
repair_mcp_manager.close()
|
|
2752
|
+
_cleanup_execution_private_sessions_dir(repair_sessions_dir)
|
|
2753
|
+
|
|
2754
|
+
if success and verify_mode != "off" and verify_commands:
|
|
2755
|
+
verify_mutation_paths: list[str] = []
|
|
2756
|
+
verify_mutation_patch_sections: list[str] = []
|
|
2757
|
+
|
|
2758
|
+
def _verification_pass(pass_label: str) -> Any:
|
|
2759
|
+
"""Run the gate once, recording anything it changed on disk.
|
|
2760
|
+
|
|
2761
|
+
Each pass is snapshotted separately so a repair attempt's own
|
|
2762
|
+
(committed) edits between passes are never mistaken for a
|
|
2763
|
+
verification command mutating the repository.
|
|
2764
|
+
"""
|
|
2765
|
+
before_verify_snapshot = _snapshot_workspace_tree(paths.root)
|
|
2766
|
+
result = run_task_verification(
|
|
2767
|
+
root=paths.root,
|
|
2768
|
+
commands=verify_commands,
|
|
2769
|
+
artifact_path=verify_path,
|
|
2770
|
+
cfg=effective,
|
|
2771
|
+
)
|
|
2772
|
+
events.emit(
|
|
2773
|
+
EVENT_VERIFICATION_RESULT,
|
|
2774
|
+
{
|
|
2775
|
+
"scope": "task",
|
|
2776
|
+
"task_id": task_id,
|
|
2777
|
+
"passed": bool(result.all_passed),
|
|
2778
|
+
"policy": verify_mode,
|
|
2779
|
+
"pass": pass_label,
|
|
2780
|
+
"summary": result.summary,
|
|
2781
|
+
"failure_category": result.failure_category_value,
|
|
2782
|
+
"commands": list(verify_commands),
|
|
2783
|
+
"command_source": verify_command_source,
|
|
2784
|
+
"artifact": os.fspath(verify_path),
|
|
2785
|
+
"result": verify_run_result_to_payload(
|
|
2786
|
+
root=paths.root,
|
|
2787
|
+
result=result,
|
|
2788
|
+
),
|
|
2789
|
+
},
|
|
2790
|
+
)
|
|
2791
|
+
after_verify_snapshot = _snapshot_workspace_tree(paths.root)
|
|
2792
|
+
pass_diff = _build_workspace_snapshot_reporting_diff(
|
|
2793
|
+
paths.root,
|
|
2794
|
+
before_snapshot=before_verify_snapshot,
|
|
2795
|
+
after_snapshot=after_verify_snapshot,
|
|
2796
|
+
)
|
|
2797
|
+
for path in pass_diff.changed_files:
|
|
2798
|
+
if path not in verify_mutation_paths:
|
|
2799
|
+
verify_mutation_paths.append(path)
|
|
2800
|
+
if pass_diff.patch_text:
|
|
2801
|
+
verify_mutation_patch_sections.append(pass_diff.patch_text)
|
|
2802
|
+
return result
|
|
2803
|
+
|
|
2804
|
+
verify_result = _verification_pass("initial")
|
|
2805
|
+
|
|
2806
|
+
# A failing gate is a signal about the work, not a verdict on it:
|
|
2807
|
+
# hand the failing output back to the same agent and let it fix
|
|
2808
|
+
# what it broke before the task is called failed. Only in strict
|
|
2809
|
+
# mode, because that is the only mode where the failure blocks.
|
|
2810
|
+
repair_outcome = None
|
|
2811
|
+
if (
|
|
2812
|
+
not verify_result.all_passed
|
|
2813
|
+
and verify_mode == "strict"
|
|
2814
|
+
and verify_repair_budget > 0
|
|
2815
|
+
):
|
|
2816
|
+
repair_outcome = run_verification_repair_loop(
|
|
2817
|
+
initial_result=verify_result,
|
|
2818
|
+
max_attempts=verify_repair_budget,
|
|
2819
|
+
repairable=lambda result: (
|
|
2820
|
+
result.failure_category_value != FailureCategory.INFRA_UNAVAILABLE.value
|
|
2821
|
+
),
|
|
2822
|
+
attempt_repair=lambda attempt, failing: _attempt_verification_repair(
|
|
2823
|
+
attempt=attempt,
|
|
2824
|
+
failing_result=failing,
|
|
2825
|
+
max_attempts=verify_repair_budget,
|
|
2826
|
+
task_id=task_id,
|
|
2827
|
+
base_instruction=instruction,
|
|
2828
|
+
verify_artifact_path=verify_path,
|
|
2829
|
+
run_verification=lambda: _verification_pass(f"repair.{attempt}"),
|
|
2830
|
+
run_repair_agent=_run_repair_agent,
|
|
2831
|
+
root=paths.root,
|
|
2832
|
+
commit_message=(f"{task_id}: verification repair attempt {attempt}"),
|
|
2833
|
+
),
|
|
2834
|
+
)
|
|
2835
|
+
verify_result = repair_outcome.final_result
|
|
2836
|
+
verification_repair_payload = repair_outcome.to_payload()
|
|
2837
|
+
for line in repair_outcome.report_lines():
|
|
2838
|
+
scope_warnings.append(line)
|
|
2839
|
+
if repair_outcome.attempts:
|
|
2840
|
+
try:
|
|
2841
|
+
patch_text = format_patch_stdout(
|
|
2842
|
+
paths.root,
|
|
2843
|
+
base_branch=pr_base_branch,
|
|
2844
|
+
)
|
|
2845
|
+
patch_path.write_text(
|
|
2846
|
+
patch_text if patch_text else "(empty format-patch output)\n",
|
|
2847
|
+
encoding="utf-8",
|
|
2848
|
+
)
|
|
2849
|
+
changed_files = changed_files_between(
|
|
2850
|
+
paths.root,
|
|
2851
|
+
revspec=f"{pr_base_branch}..HEAD",
|
|
2852
|
+
)
|
|
2853
|
+
except GitOpsError as e:
|
|
2854
|
+
scope_warnings.append(
|
|
2855
|
+
f"Could not refresh the patch after verification repair: {e}"
|
|
2856
|
+
)
|
|
2857
|
+
|
|
2858
|
+
verify_summary = verify_result.summary
|
|
2859
|
+
verify_payload = verify_run_result_to_payload(
|
|
2860
|
+
root=paths.root,
|
|
2861
|
+
result=verify_result,
|
|
2862
|
+
)
|
|
2863
|
+
if repair_outcome is not None and repair_outcome.attempts:
|
|
2864
|
+
verify_summary = (
|
|
2865
|
+
f"{verify_summary} "
|
|
2866
|
+
f"(after {repair_outcome.attempts_used} repair attempt"
|
|
2867
|
+
f"{'s' if repair_outcome.attempts_used != 1 else ''})"
|
|
2868
|
+
)
|
|
2869
|
+
verify_mutation_diff_text = "\n".join(verify_mutation_patch_sections)
|
|
2870
|
+
if verify_mutation_paths:
|
|
2871
|
+
preview = ", ".join(verify_mutation_paths[:20])
|
|
2872
|
+
if len(verify_mutation_paths) > 20:
|
|
2873
|
+
preview += ", ..."
|
|
2874
|
+
verify_mutation_msg = (
|
|
2875
|
+
"Verification commands modified repository state after the task commit "
|
|
2876
|
+
f"({len(verify_mutation_paths)}): {preview}."
|
|
2877
|
+
)
|
|
2878
|
+
if scope_mode in {"warn", "strict"}:
|
|
2879
|
+
scope_assessment = assess_scope_changes(
|
|
2880
|
+
verify_mutation_paths,
|
|
2881
|
+
allowed_scope,
|
|
2882
|
+
task=task,
|
|
2883
|
+
root=paths.root,
|
|
2884
|
+
)
|
|
2885
|
+
scope_diagnostics.extend(
|
|
2886
|
+
item.to_payload() for item in scope_assessment.diagnostics
|
|
2887
|
+
)
|
|
2888
|
+
if not scope_assessment.ok:
|
|
2889
|
+
verify_scope_violations = scope_assessment.blocking_paths
|
|
2890
|
+
scope_violation_files = _merge_changed_files(
|
|
2891
|
+
scope_violation_files,
|
|
2892
|
+
verify_scope_violations,
|
|
2893
|
+
)
|
|
2894
|
+
classes = sorted(
|
|
2895
|
+
{
|
|
2896
|
+
item.classification
|
|
2897
|
+
for item in scope_assessment.diagnostics
|
|
2898
|
+
if not item.allowed
|
|
2899
|
+
}
|
|
2900
|
+
)
|
|
2901
|
+
scope_msg = (
|
|
2902
|
+
f"Out-of-scope file changes detected ({len(verify_scope_violations)}): "
|
|
2903
|
+
f"{', '.join(verify_scope_violations[:20])}"
|
|
2904
|
+
)
|
|
2905
|
+
if len(verify_scope_violations) > 20:
|
|
2906
|
+
scope_msg += ", ..."
|
|
2907
|
+
scope_msg += (
|
|
2908
|
+
f". Allowed scope: {allowed_scope or ['(none)']}."
|
|
2909
|
+
" Task was blocked due to strict scope isolation."
|
|
2910
|
+
" Verification commands modified repository state after the task commit."
|
|
2911
|
+
)
|
|
2912
|
+
if classes:
|
|
2913
|
+
scope_msg += f" Scope classifications: {', '.join(classes)}."
|
|
2914
|
+
if scope_mode == "strict":
|
|
2915
|
+
success = False
|
|
2916
|
+
commit_hash = None
|
|
2917
|
+
merge_result = "not merged: strict scope isolation blocked verification-time writes"
|
|
2918
|
+
run_err = (run_err + "; " if run_err else "") + scope_msg
|
|
2919
|
+
changed_files = _merge_changed_files(
|
|
2920
|
+
changed_files,
|
|
2921
|
+
verify_mutation_paths,
|
|
2922
|
+
)
|
|
2923
|
+
_append_patch_debug_section(
|
|
2924
|
+
patch_path,
|
|
2925
|
+
title="Post-verification workspace diff",
|
|
2926
|
+
patch_text=verify_mutation_diff_text,
|
|
2927
|
+
)
|
|
2928
|
+
else:
|
|
2929
|
+
scope_warnings.append(scope_msg)
|
|
2930
|
+
elif scope_mode == "strict":
|
|
2931
|
+
success = False
|
|
2932
|
+
commit_hash = None
|
|
2933
|
+
merge_result = (
|
|
2934
|
+
"not merged: verification commands modified repository state"
|
|
2935
|
+
)
|
|
2936
|
+
run_err = (run_err + "; " if run_err else "") + verify_mutation_msg
|
|
2937
|
+
changed_files = _merge_changed_files(
|
|
2938
|
+
changed_files, verify_mutation_paths
|
|
2939
|
+
)
|
|
2940
|
+
_append_patch_debug_section(
|
|
2941
|
+
patch_path,
|
|
2942
|
+
title="Post-verification workspace diff",
|
|
2943
|
+
patch_text=verify_mutation_diff_text,
|
|
2944
|
+
)
|
|
2945
|
+
else:
|
|
2946
|
+
scope_warnings.append(verify_mutation_msg)
|
|
2947
|
+
if not verify_result.all_passed and verify_mode == "strict":
|
|
2948
|
+
success = False
|
|
2949
|
+
verify_blocked = True
|
|
2950
|
+
merge_result = "not merged: strict verification failed"
|
|
2951
|
+
run_err = (run_err + "; " if run_err else "") + (
|
|
2952
|
+
f"verification failed: {verify_result.summary}"
|
|
2953
|
+
)
|
|
2954
|
+
elif not verify_result.all_passed:
|
|
2955
|
+
warning_prefix = (
|
|
2956
|
+
"Verification infrastructure warning"
|
|
2957
|
+
if verify_result.failure_category_value
|
|
2958
|
+
== FailureCategory.INFRA_UNAVAILABLE.value
|
|
2959
|
+
else "Verification warning"
|
|
2960
|
+
)
|
|
2961
|
+
scope_warnings.append(f"{warning_prefix}: {verify_result.summary}")
|
|
2962
|
+
elif success and verify_mode == "strict":
|
|
2963
|
+
# The work is committed on the task branch and no authoritative
|
|
2964
|
+
# command exists to check it -- a property of the workspace, not
|
|
2965
|
+
# a defect in the work. Failing here used to discard a completed
|
|
2966
|
+
# task over missing tooling, so the task now completes unverified:
|
|
2967
|
+
# nothing is merged, the branch stays intact for review, and the
|
|
2968
|
+
# honest outcome is recorded instead of a fabricated failure.
|
|
2969
|
+
verification_unavailable_completion = True
|
|
2970
|
+
verify_summary = (
|
|
2971
|
+
"verification skipped: no authoritative commands available; "
|
|
2972
|
+
"task kept as completed_unverified"
|
|
2973
|
+
)
|
|
2974
|
+
merge_result = (
|
|
2975
|
+
"not merged: verification unavailable "
|
|
2976
|
+
f"(branch {pr_task_branch} kept for review)"
|
|
2977
|
+
)
|
|
2978
|
+
scope_warnings.append(
|
|
2979
|
+
"Strict verification found no authoritative command for this task. "
|
|
2980
|
+
"The work was kept and committed, but nothing checked it -- review "
|
|
2981
|
+
"the branch before merging, or provide --verify-cmd."
|
|
2982
|
+
)
|
|
2983
|
+
_emit_verification_unavailable(
|
|
2984
|
+
events,
|
|
2985
|
+
task_id=task_id,
|
|
2986
|
+
policy=verify_mode,
|
|
2987
|
+
reason=verify_summary,
|
|
2988
|
+
blocking=False,
|
|
2989
|
+
outcome=TASK_STATUS_COMPLETED_UNVERIFIED,
|
|
2990
|
+
)
|
|
2991
|
+
elif verify_mode == "strict":
|
|
2992
|
+
# Reached only when the task already failed for another reason;
|
|
2993
|
+
# strict verification simply had nothing to add.
|
|
2994
|
+
success = False
|
|
2995
|
+
verify_blocked = True
|
|
2996
|
+
merge_result = "not merged: strict verification unavailable"
|
|
2997
|
+
run_err = (run_err + "; " if run_err else "") + (
|
|
2998
|
+
"strict verification requires authoritative commands, but none were available"
|
|
2999
|
+
)
|
|
3000
|
+
verify_summary = "verification skipped: no authoritative commands available"
|
|
3001
|
+
_emit_verification_unavailable(
|
|
3002
|
+
events,
|
|
3003
|
+
task_id=task_id,
|
|
3004
|
+
policy=verify_mode,
|
|
3005
|
+
reason=verify_summary,
|
|
3006
|
+
blocking=True,
|
|
3007
|
+
)
|
|
3008
|
+
elif verify_mode != "off":
|
|
3009
|
+
verify_summary = "verification skipped: no authoritative commands available"
|
|
3010
|
+
_emit_verification_unavailable(
|
|
3011
|
+
events,
|
|
3012
|
+
task_id=task_id,
|
|
3013
|
+
policy=verify_mode,
|
|
3014
|
+
reason=verify_summary,
|
|
3015
|
+
blocking=False,
|
|
3016
|
+
)
|
|
3017
|
+
elif verify_mode == "off":
|
|
3018
|
+
verify_summary = "verification disabled (--verify off)"
|
|
3019
|
+
_emit_verification_unavailable(
|
|
3020
|
+
events,
|
|
3021
|
+
task_id=task_id,
|
|
3022
|
+
policy=verify_mode,
|
|
3023
|
+
reason=verify_summary,
|
|
3024
|
+
blocking=False,
|
|
3025
|
+
)
|
|
3026
|
+
|
|
3027
|
+
if success and review:
|
|
3028
|
+
try:
|
|
3029
|
+
review_outcome = review_task(
|
|
3030
|
+
paths=paths,
|
|
3031
|
+
plan=plan,
|
|
3032
|
+
task=task,
|
|
3033
|
+
cfg=effective,
|
|
3034
|
+
api_key_override=api_key_override,
|
|
3035
|
+
verification_payload_override=verify_payload,
|
|
3036
|
+
)
|
|
3037
|
+
events.emit(
|
|
3038
|
+
EVENT_REVIEW_RESULT,
|
|
3039
|
+
{
|
|
3040
|
+
"task_id": task_id,
|
|
3041
|
+
"approved": bool(review_outcome.approved),
|
|
3042
|
+
"confidence": review_outcome.confidence,
|
|
3043
|
+
"summary": review_outcome.summary,
|
|
3044
|
+
"blocking_issues": review_outcome.blocking_issues_count,
|
|
3045
|
+
"non_blocking_issues": review_outcome.non_blocking_issues_count,
|
|
3046
|
+
"review_json": os.fspath(review_outcome.json_path),
|
|
3047
|
+
"review_markdown": os.fspath(review_outcome.markdown_path),
|
|
3048
|
+
},
|
|
3049
|
+
)
|
|
3050
|
+
if not review_outcome.approved:
|
|
3051
|
+
success = False
|
|
3052
|
+
review_blocked = True
|
|
3053
|
+
merge_result = "not merged: review requested changes"
|
|
3054
|
+
except ReviewError as e:
|
|
3055
|
+
success = False
|
|
3056
|
+
run_err = (run_err + "; " if run_err else "") + f"review failed: {e}"
|
|
3057
|
+
merge_result = f"not merged: review failed: {e}"
|
|
3058
|
+
events.emit(
|
|
3059
|
+
EVENT_REVIEW_RESULT,
|
|
3060
|
+
{
|
|
3061
|
+
"task_id": task_id,
|
|
3062
|
+
"approved": False,
|
|
3063
|
+
"available": False,
|
|
3064
|
+
"error": str(e),
|
|
3065
|
+
},
|
|
3066
|
+
)
|
|
3067
|
+
|
|
3068
|
+
if success and verification_unavailable_completion:
|
|
3069
|
+
# Nothing merges without a check behind it. Return the worktree to
|
|
3070
|
+
# the base branch so the run leaves the repository where it found
|
|
3071
|
+
# it, and leave the task branch in place for a human to review.
|
|
3072
|
+
try:
|
|
3073
|
+
checkout_branch(
|
|
3074
|
+
paths.root,
|
|
3075
|
+
pr_base_branch,
|
|
3076
|
+
base_branch=pr_base_branch,
|
|
3077
|
+
)
|
|
3078
|
+
except GitOpsError as e:
|
|
3079
|
+
scope_warnings.append(
|
|
3080
|
+
"Could not return to "
|
|
3081
|
+
f"{pr_base_branch} after an unverified completion: {e}. "
|
|
3082
|
+
f"The work is committed on {pr_task_branch}."
|
|
3083
|
+
)
|
|
3084
|
+
elif success:
|
|
3085
|
+
try:
|
|
3086
|
+
merge_title = str(task.get("title") or "").strip()
|
|
3087
|
+
merge_message = (
|
|
3088
|
+
f"Merge {task_id}: {merge_title}" if merge_title else f"Merge {task_id}"
|
|
3089
|
+
)
|
|
3090
|
+
merge_commit_hash = merge_no_ff(
|
|
3091
|
+
paths.root,
|
|
3092
|
+
base_branch=pr_base_branch,
|
|
3093
|
+
task_branch=pr_task_branch,
|
|
3094
|
+
message=merge_message,
|
|
3095
|
+
)
|
|
3096
|
+
if keep_branch:
|
|
3097
|
+
merge_result = f"merged into {pr_base_branch} (branch kept)"
|
|
3098
|
+
else:
|
|
3099
|
+
try:
|
|
3100
|
+
delete_branch(paths.root, pr_task_branch)
|
|
3101
|
+
merge_result = f"merged into {pr_base_branch} (branch deleted)"
|
|
3102
|
+
except GitOpsError as cleanup_err:
|
|
3103
|
+
scope_warnings.append(
|
|
3104
|
+
"Branch cleanup warning: "
|
|
3105
|
+
f"failed to delete {pr_task_branch}: {cleanup_err}"
|
|
3106
|
+
)
|
|
3107
|
+
merge_result = (
|
|
3108
|
+
f"merged into {pr_base_branch} "
|
|
3109
|
+
f"(branch delete warning: {cleanup_err})"
|
|
3110
|
+
)
|
|
3111
|
+
except GitOpsError as e:
|
|
3112
|
+
success = False
|
|
3113
|
+
run_err = (run_err + "; " if run_err else "") + f"PR flow failed: {e}"
|
|
3114
|
+
unmerged = list_unmerged_files(paths.root)
|
|
3115
|
+
if unmerged and pr_base_branch and pr_task_branch:
|
|
3116
|
+
merge_conflict_detected = True
|
|
3117
|
+
context = capture_merge_conflict_context(
|
|
3118
|
+
paths.root,
|
|
3119
|
+
base_branch=pr_base_branch,
|
|
3120
|
+
task_branch=pr_task_branch,
|
|
3121
|
+
merge_error=str(e),
|
|
3122
|
+
)
|
|
3123
|
+
review_outcome = review_merge_conflict(
|
|
3124
|
+
paths=paths,
|
|
3125
|
+
task=task,
|
|
3126
|
+
cfg=effective,
|
|
3127
|
+
api_key_override=api_key_override,
|
|
3128
|
+
context=context,
|
|
3129
|
+
plan=plan,
|
|
3130
|
+
)
|
|
3131
|
+
cleanup_ok, cleanup_log = try_abort_merge(
|
|
3132
|
+
paths.root,
|
|
3133
|
+
base_branch=pr_base_branch,
|
|
3134
|
+
)
|
|
3135
|
+
conflict_artifacts = write_conflict_artifacts(
|
|
3136
|
+
paths=paths,
|
|
3137
|
+
task_id=task_id,
|
|
3138
|
+
context=context,
|
|
3139
|
+
review_json=review_outcome.review_json,
|
|
3140
|
+
review_md=review_outcome.review_markdown,
|
|
3141
|
+
cleanup_log=cleanup_log,
|
|
3142
|
+
)
|
|
3143
|
+
conflict_review_path = conflict_artifacts.review_md_path
|
|
3144
|
+
merge_result = (
|
|
3145
|
+
f"not merged: conflict while merging {pr_task_branch} into "
|
|
3146
|
+
f"{pr_base_branch}"
|
|
3147
|
+
)
|
|
3148
|
+
if review_outcome.skipped_reason:
|
|
3149
|
+
scope_warnings.append(
|
|
3150
|
+
f"Conflict review note: {review_outcome.skipped_reason}"
|
|
3151
|
+
)
|
|
3152
|
+
if not cleanup_ok:
|
|
3153
|
+
scope_warnings.append(
|
|
3154
|
+
"Merge cleanup warning: repository state may need manual recovery. "
|
|
3155
|
+
f"See {conflict_artifacts.cleanup_log_path}"
|
|
3156
|
+
)
|
|
3157
|
+
if auto_resolve_conflicts and can_attempt_conflict_auto_resolve(
|
|
3158
|
+
task=task,
|
|
3159
|
+
settings=conflict_auto_settings,
|
|
3160
|
+
):
|
|
3161
|
+
bump_conflict_attempt(task)
|
|
3162
|
+
save_plan(paths, plan)
|
|
3163
|
+
auto_outcome = attempt_auto_resolve_conflict(
|
|
3164
|
+
paths=paths,
|
|
3165
|
+
plan=plan,
|
|
3166
|
+
task=task,
|
|
3167
|
+
cfg=effective,
|
|
3168
|
+
api_key_override=api_key_override,
|
|
3169
|
+
base_branch=pr_base_branch,
|
|
3170
|
+
task_branch=pr_task_branch,
|
|
3171
|
+
keep_worktrees=False,
|
|
3172
|
+
settings=conflict_auto_settings,
|
|
3173
|
+
verify_commands=(verify_commands if verify_mode != "off" else []),
|
|
3174
|
+
)
|
|
3175
|
+
if auto_outcome.success:
|
|
3176
|
+
success = True
|
|
3177
|
+
run_err = None
|
|
3178
|
+
merge_conflict_detected = False
|
|
3179
|
+
merge_commit_hash = auto_outcome.merge_commit_hash
|
|
3180
|
+
merge_result = f"auto-resolved and merged into {pr_base_branch}"
|
|
3181
|
+
if auto_outcome.warnings:
|
|
3182
|
+
scope_warnings.extend(auto_outcome.warnings)
|
|
3183
|
+
conflict_review_path = auto_outcome.report_path
|
|
3184
|
+
else:
|
|
3185
|
+
scope_warnings.append(
|
|
3186
|
+
"Conflict auto-resolve failed: "
|
|
3187
|
+
f"{auto_outcome.error or 'unknown error'}"
|
|
3188
|
+
)
|
|
3189
|
+
else:
|
|
3190
|
+
# The sequential path stops at a conflict instead of
|
|
3191
|
+
# starting a resolver agent in its own worktree. That
|
|
3192
|
+
# is swarm machinery, and reaching for it here turned
|
|
3193
|
+
# "one task did not merge" into a second opaque agent
|
|
3194
|
+
# run. Report the conflict and how to finish it.
|
|
3195
|
+
scope_warnings.extend(
|
|
3196
|
+
_sequential_conflict_report_lines(
|
|
3197
|
+
root=paths.root,
|
|
3198
|
+
task_id=task_id,
|
|
3199
|
+
base_branch=pr_base_branch,
|
|
3200
|
+
task_branch=pr_task_branch,
|
|
3201
|
+
review_path=conflict_review_path,
|
|
3202
|
+
)
|
|
3203
|
+
)
|
|
3204
|
+
else:
|
|
3205
|
+
merge_result = f"not merged: {e}"
|
|
3206
|
+
|
|
3207
|
+
if success and remote_settings.enabled and remote_record is not None and pr_base_branch:
|
|
3208
|
+
pushed_base, base_output = push_base(
|
|
3209
|
+
paths.root,
|
|
3210
|
+
remote=str(remote_record.get("remote") or remote_settings.remote_name),
|
|
3211
|
+
base_branch=pr_base_branch,
|
|
3212
|
+
)
|
|
3213
|
+
remote_record["pushed_base"] = pushed_base
|
|
3214
|
+
remote_record["base_push_output"] = truncate_output(base_output)
|
|
3215
|
+
if not pushed_base:
|
|
3216
|
+
msg = f"remote base push failed: {base_output or 'unknown error'}"
|
|
3217
|
+
raw_errors = remote_record.get("errors")
|
|
3218
|
+
if isinstance(raw_errors, list):
|
|
3219
|
+
raw_errors.append(msg)
|
|
3220
|
+
# Local merge already happened; keep success and record warning.
|
|
3221
|
+
scope_warnings.append(msg)
|
|
3222
|
+
write_remote_record(
|
|
3223
|
+
execution_dir=paths.execution_dir,
|
|
3224
|
+
task_id=task_id,
|
|
3225
|
+
record=remote_record,
|
|
3226
|
+
)
|
|
3227
|
+
|
|
3228
|
+
if pr and merge_result is None:
|
|
3229
|
+
merge_result = "not merged"
|
|
3230
|
+
|
|
3231
|
+
if pr and not pr_report_state_upgraded:
|
|
3232
|
+
recovered_pr_report_state = False
|
|
3233
|
+
if commit_hash is not None and pr_base_branch:
|
|
3234
|
+
try:
|
|
3235
|
+
patch_text = format_patch_stdout(paths.root, base_branch=pr_base_branch)
|
|
3236
|
+
patch_path.write_text(
|
|
3237
|
+
patch_text if patch_text else "(empty format-patch output)\n",
|
|
3238
|
+
encoding="utf-8",
|
|
3239
|
+
)
|
|
3240
|
+
changed_files = changed_files_between(
|
|
3241
|
+
paths.root,
|
|
3242
|
+
revspec=f"{pr_base_branch}..HEAD",
|
|
3243
|
+
)
|
|
3244
|
+
recovered_pr_report_state = True
|
|
3245
|
+
except GitOpsError:
|
|
3246
|
+
recovered_pr_report_state = False
|
|
3247
|
+
if not recovered_pr_report_state:
|
|
3248
|
+
patch_path.write_text(report_diff.patch_text, encoding="utf-8")
|
|
3249
|
+
changed_files = list(report_diff.changed_files)
|
|
3250
|
+
|
|
3251
|
+
if runtime_artifacts_changed:
|
|
3252
|
+
summary = "Task failed: agent modified files under .alysis/ which is not allowed."
|
|
3253
|
+
elif scope_violation_files:
|
|
3254
|
+
summary = "Task blocked due to strict scope isolation."
|
|
3255
|
+
elif no_material_changes_blocked:
|
|
3256
|
+
summary = "Task failed: no material file changes were detected."
|
|
3257
|
+
elif verify_blocked:
|
|
3258
|
+
summary = "Task blocked by strict verification gate."
|
|
3259
|
+
elif review_blocked:
|
|
3260
|
+
summary = "Task blocked by review gate (changes requested)."
|
|
3261
|
+
elif analysis_only_noop_accepted:
|
|
3262
|
+
summary = "Analysis-only task completed successfully with no repository changes."
|
|
3263
|
+
elif success and verification_unavailable_completion:
|
|
3264
|
+
summary = (
|
|
3265
|
+
"Task completed, but nothing verified it: no authoritative verification "
|
|
3266
|
+
f"command exists for this workspace. The work is committed on "
|
|
3267
|
+
f"{pr_task_branch or 'the task branch'} and was deliberately not merged."
|
|
3268
|
+
)
|
|
3269
|
+
elif run_code == 0 and success:
|
|
3270
|
+
summary = "Task execution completed successfully."
|
|
3271
|
+
elif pr and can_attempt_pr_flow and run_code == 0:
|
|
3272
|
+
summary = "Task execution failed during PR flow."
|
|
3273
|
+
else:
|
|
3274
|
+
summary = "Task execution failed."
|
|
3275
|
+
if run_err:
|
|
3276
|
+
summary += f" Error: {run_err}"
|
|
3277
|
+
if scope_warnings:
|
|
3278
|
+
summary += " Warnings: " + " | ".join(scope_warnings)
|
|
3279
|
+
if conflict_review_path is not None:
|
|
3280
|
+
summary += f" Conflict review: {conflict_review_path}"
|
|
3281
|
+
|
|
3282
|
+
finished_at = now_iso()
|
|
3283
|
+
report_verify_commands = verify_commands if pr and verify_mode != "off" else []
|
|
3284
|
+
report_path = write_task_report(
|
|
3285
|
+
paths=paths,
|
|
3286
|
+
task=task,
|
|
3287
|
+
result="success" if success else "failure",
|
|
3288
|
+
result_kind=result_kind,
|
|
3289
|
+
summary=summary,
|
|
3290
|
+
started_at=started_at,
|
|
3291
|
+
finished_at=finished_at,
|
|
3292
|
+
changed_files=changed_files,
|
|
3293
|
+
verify_commands=report_verify_commands,
|
|
3294
|
+
patch_path=patch_path,
|
|
3295
|
+
budget_artifact_path=budget_artifact_path,
|
|
3296
|
+
execution_log_artifacts=exec_artifacts,
|
|
3297
|
+
verify_artifact_path=verify_path if verify_path.exists() else None,
|
|
3298
|
+
verify_summary=verify_summary,
|
|
3299
|
+
verify_payload=verify_payload,
|
|
3300
|
+
verify_command_source=verify_command_source,
|
|
3301
|
+
base_branch=pr_base_branch,
|
|
3302
|
+
task_branch=pr_task_branch,
|
|
3303
|
+
commit_hash=commit_hash,
|
|
3304
|
+
merge_commit_hash=merge_commit_hash,
|
|
3305
|
+
merge_result=merge_result,
|
|
3306
|
+
salvaged_nonzero_exit=bool(pr_nonzero_salvage_attempted and success),
|
|
3307
|
+
noop_reason=noop_reason,
|
|
3308
|
+
remote_lines=_remote_report_lines(remote_record),
|
|
3309
|
+
scope_amendments=scope_amendment_payloads,
|
|
3310
|
+
scope_amended_patterns=scope_amended_patterns,
|
|
3311
|
+
)
|
|
3312
|
+
persisted_capture = persist_execution_knowledge_capture(
|
|
3313
|
+
paths=paths,
|
|
3314
|
+
task=task,
|
|
3315
|
+
source="forge_exec",
|
|
3316
|
+
assistant_message=recording_surface.final_assistant_message,
|
|
3317
|
+
artifact_dir=(
|
|
3318
|
+
paths.execution_knowledge_capture_dir
|
|
3319
|
+
/ _safe_task_file_component(task_id)
|
|
3320
|
+
/ _safe_task_file_component(started_at)
|
|
3321
|
+
),
|
|
3322
|
+
report_path=report_path,
|
|
3323
|
+
patch_path=patch_path,
|
|
3324
|
+
verify_artifact_path=verify_path if verify_path.exists() else None,
|
|
3325
|
+
budget_artifact_path=budget_artifact_path,
|
|
3326
|
+
session_artifact_dir=exec_artifacts.session_artifact_dir,
|
|
3327
|
+
)
|
|
3328
|
+
if success and verification_unavailable_completion:
|
|
3329
|
+
# The work is kept, but "validated" would be a lie: nothing ran against it.
|
|
3330
|
+
mark_knowledge_capture_promotion_skipped(
|
|
3331
|
+
artifact_dir=persisted_capture.artifact_dir,
|
|
3332
|
+
reason="task completed without any authoritative verification",
|
|
3333
|
+
)
|
|
3334
|
+
elif success:
|
|
3335
|
+
promote_validated_knowledge_capture(
|
|
3336
|
+
paths=paths,
|
|
3337
|
+
task=task,
|
|
3338
|
+
artifact_dir=persisted_capture.artifact_dir,
|
|
3339
|
+
)
|
|
3340
|
+
else:
|
|
3341
|
+
mark_knowledge_capture_promotion_skipped(
|
|
3342
|
+
artifact_dir=persisted_capture.artifact_dir,
|
|
3343
|
+
reason="task execution outcome was not accepted",
|
|
3344
|
+
)
|
|
3345
|
+
|
|
3346
|
+
write_task_attempt_entry(
|
|
3347
|
+
paths=paths,
|
|
3348
|
+
task=task,
|
|
3349
|
+
source="forge_exec",
|
|
3350
|
+
result="success" if success else "failure",
|
|
3351
|
+
summary=summary,
|
|
3352
|
+
changed_files=changed_files,
|
|
3353
|
+
verify_summary=verify_summary,
|
|
3354
|
+
report_path=report_path,
|
|
3355
|
+
patch_path=patch_path,
|
|
3356
|
+
verify_artifact_path=verify_path if verify_path.exists() else None,
|
|
3357
|
+
budget_artifact_path=budget_artifact_path,
|
|
3358
|
+
session_artifact_dir=exec_artifacts.session_artifact_dir,
|
|
3359
|
+
acceptance_state="accepted" if success else "rejected",
|
|
3360
|
+
extra_tags=[
|
|
3361
|
+
"execution",
|
|
3362
|
+
"sequential",
|
|
3363
|
+
],
|
|
3364
|
+
)
|
|
3365
|
+
issue_paths = changed_files or list(allowed_scope)
|
|
3366
|
+
if runtime_artifacts_changed:
|
|
3367
|
+
write_issue_entry(
|
|
3368
|
+
paths=paths,
|
|
3369
|
+
task=task,
|
|
3370
|
+
source="forge_exec",
|
|
3371
|
+
title=f"{task_id}: protected .alysis mutation attempt",
|
|
3372
|
+
summary="Engineer execution attempted to modify protected .alysis runtime state.",
|
|
3373
|
+
paths_in_scope=issue_paths,
|
|
3374
|
+
report_path=report_path,
|
|
3375
|
+
patch_path=patch_path,
|
|
3376
|
+
verify_artifact_path=verify_path if verify_path.exists() else None,
|
|
3377
|
+
budget_artifact_path=budget_artifact_path,
|
|
3378
|
+
session_artifact_dir=exec_artifacts.session_artifact_dir,
|
|
3379
|
+
tags=["protected_runtime_mutation"],
|
|
3380
|
+
)
|
|
3381
|
+
elif verify_blocked:
|
|
3382
|
+
write_issue_entry(
|
|
3383
|
+
paths=paths,
|
|
3384
|
+
task=task,
|
|
3385
|
+
source="forge_exec",
|
|
3386
|
+
title=f"{task_id}: verification failed",
|
|
3387
|
+
summary=verify_summary or "Verification blocked task completion.",
|
|
3388
|
+
paths_in_scope=issue_paths,
|
|
3389
|
+
report_path=report_path,
|
|
3390
|
+
patch_path=patch_path,
|
|
3391
|
+
verify_artifact_path=verify_path if verify_path.exists() else None,
|
|
3392
|
+
budget_artifact_path=budget_artifact_path,
|
|
3393
|
+
session_artifact_dir=exec_artifacts.session_artifact_dir,
|
|
3394
|
+
tags=["verification_failure"],
|
|
3395
|
+
)
|
|
3396
|
+
elif review_blocked:
|
|
3397
|
+
write_issue_entry(
|
|
3398
|
+
paths=paths,
|
|
3399
|
+
task=task,
|
|
3400
|
+
source="forge_exec",
|
|
3401
|
+
title=f"{task_id}: review requested changes",
|
|
3402
|
+
summary=summary,
|
|
3403
|
+
paths_in_scope=issue_paths,
|
|
3404
|
+
report_path=report_path,
|
|
3405
|
+
patch_path=patch_path,
|
|
3406
|
+
verify_artifact_path=verify_path if verify_path.exists() else None,
|
|
3407
|
+
budget_artifact_path=budget_artifact_path,
|
|
3408
|
+
session_artifact_dir=exec_artifacts.session_artifact_dir,
|
|
3409
|
+
tags=["review_blocked"],
|
|
3410
|
+
)
|
|
3411
|
+
elif merge_conflict_detected:
|
|
3412
|
+
write_issue_entry(
|
|
3413
|
+
paths=paths,
|
|
3414
|
+
task=task,
|
|
3415
|
+
source="forge_exec",
|
|
3416
|
+
title=f"{task_id}: merge conflict remains unresolved",
|
|
3417
|
+
summary=summary,
|
|
3418
|
+
paths_in_scope=issue_paths,
|
|
3419
|
+
report_path=report_path,
|
|
3420
|
+
patch_path=patch_path,
|
|
3421
|
+
verify_artifact_path=verify_path if verify_path.exists() else None,
|
|
3422
|
+
budget_artifact_path=budget_artifact_path,
|
|
3423
|
+
session_artifact_dir=exec_artifacts.session_artifact_dir,
|
|
3424
|
+
tags=["merge_conflict"],
|
|
3425
|
+
)
|
|
3426
|
+
elif not success:
|
|
3427
|
+
write_issue_entry(
|
|
3428
|
+
paths=paths,
|
|
3429
|
+
task=task,
|
|
3430
|
+
source="forge_exec",
|
|
3431
|
+
title=f"{task_id}: task execution failed",
|
|
3432
|
+
summary=summary,
|
|
3433
|
+
paths_in_scope=issue_paths,
|
|
3434
|
+
report_path=report_path,
|
|
3435
|
+
patch_path=patch_path,
|
|
3436
|
+
verify_artifact_path=verify_path if verify_path.exists() else None,
|
|
3437
|
+
budget_artifact_path=budget_artifact_path,
|
|
3438
|
+
session_artifact_dir=exec_artifacts.session_artifact_dir,
|
|
3439
|
+
tags=(
|
|
3440
|
+
["execution_failure", "scope_violation"]
|
|
3441
|
+
if scope_violation_files
|
|
3442
|
+
else ["execution_failure"]
|
|
3443
|
+
),
|
|
3444
|
+
)
|
|
3445
|
+
rebuild_knowledge_index(paths)
|
|
3446
|
+
|
|
3447
|
+
if success and verification_unavailable_completion:
|
|
3448
|
+
status = TASK_STATUS_COMPLETED_UNVERIFIED
|
|
3449
|
+
elif success:
|
|
3450
|
+
status = "done"
|
|
3451
|
+
elif merge_conflict_detected:
|
|
3452
|
+
status = "merge_conflict"
|
|
3453
|
+
elif verify_blocked:
|
|
3454
|
+
status = "verify_failed"
|
|
3455
|
+
elif review_blocked:
|
|
3456
|
+
status = "changes_requested"
|
|
3457
|
+
else:
|
|
3458
|
+
status = "failed"
|
|
3459
|
+
set_task_status(plan, task_id, status)
|
|
3460
|
+
save_plan(paths, plan)
|
|
3461
|
+
|
|
3462
|
+
console.print(f"Task: {task_id} ({task.get('title', '')})")
|
|
3463
|
+
if success and verification_unavailable_completion:
|
|
3464
|
+
console.print("Result: completed_unverified (kept, but nothing verified it)")
|
|
3465
|
+
else:
|
|
3466
|
+
console.print(f"Result: {'success' if success else 'failure'}")
|
|
3467
|
+
console.print(f"Report: {report_path}")
|
|
3468
|
+
console.print(f"Patch: {patch_path}")
|
|
3469
|
+
_print_usage_summary_from_logs(
|
|
3470
|
+
console=console,
|
|
3471
|
+
title=f"Usage Summary ({task_id})",
|
|
3472
|
+
log_paths=([exec_artifacts.log_copy_path] if exec_artifacts.log_retained else []),
|
|
3473
|
+
)
|
|
3474
|
+
if conflict_review_path is not None:
|
|
3475
|
+
console.print(f"Conflict Review: {conflict_review_path}")
|
|
3476
|
+
|
|
3477
|
+
task_payload: dict[str, Any] = {
|
|
3478
|
+
"task_id": task_id,
|
|
3479
|
+
"title": str(task.get("title", "")),
|
|
3480
|
+
"status": status,
|
|
3481
|
+
"source": "exec",
|
|
3482
|
+
"success": bool(success),
|
|
3483
|
+
"report": os.fspath(report_path),
|
|
3484
|
+
"patch": os.fspath(patch_path),
|
|
3485
|
+
"verify_summary": verify_summary,
|
|
3486
|
+
"verify_blocked": bool(verify_blocked),
|
|
3487
|
+
"verification_unavailable": bool(verification_unavailable_completion),
|
|
3488
|
+
"verification_repair": (
|
|
3489
|
+
verification_repair_payload if verification_repair_payload else None
|
|
3490
|
+
),
|
|
3491
|
+
"review_blocked": bool(review_blocked),
|
|
3492
|
+
"merge_conflict": bool(merge_conflict_detected),
|
|
3493
|
+
"merge_result": merge_result,
|
|
3494
|
+
"branch": pr_task_branch,
|
|
3495
|
+
"base_branch": pr_base_branch,
|
|
3496
|
+
"commit": commit_hash,
|
|
3497
|
+
"merge_commit": merge_commit_hash,
|
|
3498
|
+
"scope_warnings": list(scope_warnings),
|
|
3499
|
+
"scope_amendments": list(scope_amendment_payloads),
|
|
3500
|
+
"scope_amended_patterns": list(scope_amended_patterns),
|
|
3501
|
+
}
|
|
3502
|
+
if conflict_review_path is not None:
|
|
3503
|
+
task_payload["conflict_review"] = os.fspath(conflict_review_path)
|
|
3504
|
+
events.emit(
|
|
3505
|
+
EVENT_TASK_COMPLETED if success else EVENT_TASK_FAILED,
|
|
3506
|
+
task_payload,
|
|
3507
|
+
)
|
|
3508
|
+
# A task that ran and was not accepted is a genuine execution failure, so exit 1
|
|
3509
|
+
# stays. Exit 2 is reserved for the command itself failing, which leaves this
|
|
3510
|
+
# function as ForgeTaskExecutionError rather than as an outcome.
|
|
3511
|
+
return TaskExecutionOutcome(
|
|
3512
|
+
task_id=task_id,
|
|
3513
|
+
title=str(task.get("title", "")),
|
|
3514
|
+
status=status,
|
|
3515
|
+
success=bool(success),
|
|
3516
|
+
exit_code=EXIT_OK if success else EXIT_NOT_ACCEPTED,
|
|
3517
|
+
summary=summary,
|
|
3518
|
+
report_path=report_path,
|
|
3519
|
+
patch_path=patch_path,
|
|
3520
|
+
merge_conflict=bool(merge_conflict_detected),
|
|
3521
|
+
conflict_review_path=conflict_review_path,
|
|
3522
|
+
payload=task_payload,
|
|
3523
|
+
)
|
|
3524
|
+
|
|
3525
|
+
|
|
3526
|
+
def forge_exec(
|
|
3527
|
+
task_id: str = typer.Argument(..., help="Task id from plan.json (for example T01)."),
|
|
3528
|
+
path: Path = typer.Option(
|
|
3529
|
+
Path("."),
|
|
3530
|
+
"--path",
|
|
3531
|
+
help="Workspace path or repository subdirectory.",
|
|
3532
|
+
),
|
|
3533
|
+
mode: Mode | None = typer.Option(None, "--mode", help="Mode override."),
|
|
3534
|
+
model: str | None = typer.Option(None, "--model", help="Model override."),
|
|
3535
|
+
base_url: str | None = typer.Option(None, "--base-url", help="Base URL override."),
|
|
3536
|
+
temperature: float | None = typer.Option(None, "--temperature", help="Sampling temperature."),
|
|
3537
|
+
stream: bool | None = typer.Option(
|
|
3538
|
+
None,
|
|
3539
|
+
"--stream/--no-stream",
|
|
3540
|
+
help="Enable streamed assistant output.",
|
|
3541
|
+
),
|
|
3542
|
+
max_steps: int | None = typer.Option(
|
|
3543
|
+
None,
|
|
3544
|
+
"--max-steps",
|
|
3545
|
+
help="Optional safety limit on each managed agent task.",
|
|
3546
|
+
),
|
|
3547
|
+
no_log: bool = typer.Option(False, "--no-log", help="Disable JSONL session logging."),
|
|
3548
|
+
api_key_env: str | None = typer.Option(
|
|
3549
|
+
None,
|
|
3550
|
+
"--api-key-env",
|
|
3551
|
+
help=(
|
|
3552
|
+
"Read API key from this environment variable (overrides ALYSIS_API_KEY/OPENAI_API_KEY)."
|
|
3553
|
+
),
|
|
3554
|
+
),
|
|
3555
|
+
api_key_stdin: bool = typer.Option(
|
|
3556
|
+
False,
|
|
3557
|
+
"--api-key-stdin",
|
|
3558
|
+
help="Prompt for API key (hidden input). Key is kept in memory for this run only.",
|
|
3559
|
+
),
|
|
3560
|
+
api_key: str | None = typer.Option(
|
|
3561
|
+
None,
|
|
3562
|
+
"--api-key",
|
|
3563
|
+
help=(
|
|
3564
|
+
"UNSAFE: Provide API key via CLI argument (may leak via shell history / process list). "
|
|
3565
|
+
"Prefer --api-key-stdin or --api-key-env."
|
|
3566
|
+
),
|
|
3567
|
+
),
|
|
3568
|
+
pr: bool = typer.Option(
|
|
3569
|
+
False,
|
|
3570
|
+
"--pr/--no-pr",
|
|
3571
|
+
help="Run task in PR-like git flow (branch, commit, patch, merge).",
|
|
3572
|
+
),
|
|
3573
|
+
review: bool = typer.Option(
|
|
3574
|
+
False,
|
|
3575
|
+
"--review",
|
|
3576
|
+
help="Run automated PR review gate before merge (requires --pr).",
|
|
3577
|
+
),
|
|
3578
|
+
base_branch: str | None = typer.Option(
|
|
3579
|
+
None,
|
|
3580
|
+
"--base-branch",
|
|
3581
|
+
help="Base branch for --pr mode (defaults to current branch).",
|
|
3582
|
+
),
|
|
3583
|
+
keep_branch: bool = typer.Option(
|
|
3584
|
+
False,
|
|
3585
|
+
"--keep-branch",
|
|
3586
|
+
help="Keep task branch after successful merge in --pr mode.",
|
|
3587
|
+
),
|
|
3588
|
+
scope: str = typer.Option(
|
|
3589
|
+
"strict",
|
|
3590
|
+
"--scope",
|
|
3591
|
+
help="Write-scope enforcement: strict by default; use warn or off to opt out.",
|
|
3592
|
+
),
|
|
3593
|
+
verify: str = typer.Option(
|
|
3594
|
+
"warn",
|
|
3595
|
+
"--verify",
|
|
3596
|
+
help="Verification policy for PR flow: off, warn, or strict.",
|
|
3597
|
+
),
|
|
3598
|
+
verify_cmd: list[str] | None = typer.Option(
|
|
3599
|
+
None,
|
|
3600
|
+
"--verify-cmd",
|
|
3601
|
+
help="Override verify command for this run (repeatable).",
|
|
3602
|
+
),
|
|
3603
|
+
yes: bool = typer.Option(
|
|
3604
|
+
False,
|
|
3605
|
+
"--yes",
|
|
3606
|
+
help="In auto mode, skip confirmations for sensitive commands (hard blocks still apply).",
|
|
3607
|
+
),
|
|
3608
|
+
# Appended after ``yes`` on purpose: `forge_exec_impl` forwards these positionally,
|
|
3609
|
+
# so a new parameter inserted mid-list would silently shift every later argument.
|
|
3610
|
+
verify_repair_attempts: int | None = typer.Option(
|
|
3611
|
+
None,
|
|
3612
|
+
"--verify-repair-attempts",
|
|
3613
|
+
help=(
|
|
3614
|
+
"How many times a failing strict verification is fed back to the agent "
|
|
3615
|
+
"to repair before the task fails (default 2; 0 disables)."
|
|
3616
|
+
),
|
|
3617
|
+
),
|
|
3618
|
+
auto_resolve_conflicts: bool = typer.Option(
|
|
3619
|
+
False,
|
|
3620
|
+
"--auto-resolve-conflicts",
|
|
3621
|
+
help=(
|
|
3622
|
+
"On merge conflict, start a resolver agent in a dedicated worktree instead "
|
|
3623
|
+
"of stopping with a conflict report. Off by default: the sequential path "
|
|
3624
|
+
"reports the conflict and tells you how to land it."
|
|
3625
|
+
),
|
|
3626
|
+
),
|
|
3627
|
+
events: Any = None,
|
|
3628
|
+
) -> None:
|
|
3629
|
+
events = _events_or_null(events)
|
|
3630
|
+
console = _console()
|
|
3631
|
+
cfg = load_config()
|
|
3632
|
+
effective = clone_cfg(cfg)
|
|
3633
|
+
current_ctx = get_current_context(silent=True)
|
|
3634
|
+
max_steps_source = (
|
|
3635
|
+
current_ctx.get_parameter_source("max_steps") if current_ctx is not None else None
|
|
3636
|
+
)
|
|
3637
|
+
max_steps_provided = max_steps is not None
|
|
3638
|
+
if current_ctx is not None:
|
|
3639
|
+
max_steps_provided = (
|
|
3640
|
+
max_steps_source is not None and max_steps_source is not ParameterSource.DEFAULT
|
|
3641
|
+
)
|
|
3642
|
+
if base_url is not None:
|
|
3643
|
+
effective.base_url = base_url
|
|
3644
|
+
if model is not None:
|
|
3645
|
+
effective.model = model
|
|
3646
|
+
if temperature is not None:
|
|
3647
|
+
_apply_temperature_override(effective, temperature)
|
|
3648
|
+
if stream is not None:
|
|
3649
|
+
effective.stream = stream
|
|
3650
|
+
if max_steps is not None:
|
|
3651
|
+
effective.max_steps = max_steps
|
|
3652
|
+
effective_mode = (mode.value if mode else effective.default_mode) or "review"
|
|
3653
|
+
scope_mode = "strict"
|
|
3654
|
+
verify_mode = "warn"
|
|
3655
|
+
verify_commands: list[str] = []
|
|
3656
|
+
verify_command_source: str | None = None
|
|
3657
|
+
run_cfg = clone_cfg(effective)
|
|
3658
|
+
# Called as a plain function (not through Typer) this arrives as an OptionInfo,
|
|
3659
|
+
# which is neither an int nor None -- normalize before it reaches the budget.
|
|
3660
|
+
repair_attempts_override = (
|
|
3661
|
+
verify_repair_attempts
|
|
3662
|
+
if isinstance(verify_repair_attempts, int) and not isinstance(verify_repair_attempts, bool)
|
|
3663
|
+
else None
|
|
3664
|
+
)
|
|
3665
|
+
verify_repair_budget = resolve_repair_attempt_budget(repair_attempts_override)
|
|
3666
|
+
|
|
3667
|
+
try:
|
|
3668
|
+
scope_mode = _normalize_scope_mode(scope)
|
|
3669
|
+
verify_mode = _normalize_verify_mode(verify)
|
|
3670
|
+
api_key_override = _resolve_api_key_override(
|
|
3671
|
+
api_key=api_key,
|
|
3672
|
+
api_key_env=api_key_env,
|
|
3673
|
+
api_key_stdin=api_key_stdin,
|
|
3674
|
+
)
|
|
3675
|
+
paths = load_current_run_paths(path)
|
|
3676
|
+
events.set_run_id(paths.run_id)
|
|
3677
|
+
plan = load_plan(paths)
|
|
3678
|
+
run_cfg.model = resolve_model_for_role(
|
|
3679
|
+
cfg=effective,
|
|
3680
|
+
role=ROLE_CODING,
|
|
3681
|
+
plan=plan,
|
|
3682
|
+
prefer_context="forge",
|
|
3683
|
+
)
|
|
3684
|
+
except (ConfigError, ForgeError) as e:
|
|
3685
|
+
raise _exec_error_exit(console, events, str(e), task_id=task_id) from e
|
|
3686
|
+
|
|
3687
|
+
task = find_task(plan, task_id)
|
|
3688
|
+
if task is None:
|
|
3689
|
+
message = f"Task not found: {task_id}"
|
|
3690
|
+
events.emit(
|
|
3691
|
+
EVENT_PLAN_INVALID,
|
|
3692
|
+
{"reason": message, "source": "task_lookup", "task_id": task_id},
|
|
3693
|
+
)
|
|
3694
|
+
raise _exec_error_exit(console, events, message, task_id=task_id)
|
|
3695
|
+
task_status = canonical_task_status(str(task.get("status") or ""))
|
|
3696
|
+
if task_status in {"superseded", "invalidated"}:
|
|
3697
|
+
message = (
|
|
3698
|
+
f"Task is non-executable obsolete work ({task_status}): {task_id}. "
|
|
3699
|
+
"Use an active planned replacement task instead."
|
|
3700
|
+
)
|
|
3701
|
+
events.emit(
|
|
3702
|
+
EVENT_PLAN_INVALID,
|
|
3703
|
+
{
|
|
3704
|
+
"reason": message,
|
|
3705
|
+
"source": "task_status",
|
|
3706
|
+
"task_id": task_id,
|
|
3707
|
+
"status": task_status,
|
|
3708
|
+
},
|
|
3709
|
+
)
|
|
3710
|
+
raise _exec_error_exit(console, events, message, task_id=task_id)
|
|
3711
|
+
if verify_mode != "off":
|
|
3712
|
+
verify_selection = resolve_authoritative_task_verify_command_selection(
|
|
3713
|
+
cfg=effective,
|
|
3714
|
+
verify_cmd=verify_cmd,
|
|
3715
|
+
task=task,
|
|
3716
|
+
root=paths.root,
|
|
3717
|
+
plan_requirements=[
|
|
3718
|
+
str(item).strip() for item in (plan.get("requirements") or []) if str(item).strip()
|
|
3719
|
+
],
|
|
3720
|
+
)
|
|
3721
|
+
verify_commands = list(verify_selection.commands)
|
|
3722
|
+
verify_command_source = verify_selection.source
|
|
3723
|
+
run_cfg.verify_commands = list(verify_commands)
|
|
3724
|
+
|
|
3725
|
+
blockers = _task_dependency_blockers(plan, task)
|
|
3726
|
+
if blockers:
|
|
3727
|
+
message = "Dependencies are not done: " + ", ".join(blockers)
|
|
3728
|
+
events.emit(
|
|
3729
|
+
EVENT_PLAN_INVALID,
|
|
3730
|
+
{
|
|
3731
|
+
"reason": message,
|
|
3732
|
+
"source": "dependencies",
|
|
3733
|
+
"task_id": task_id,
|
|
3734
|
+
"blockers": list(blockers),
|
|
3735
|
+
},
|
|
3736
|
+
)
|
|
3737
|
+
raise _exec_error_exit(console, events, message, task_id=task_id)
|
|
3738
|
+
if review and not pr:
|
|
3739
|
+
raise _exec_error_exit(
|
|
3740
|
+
console,
|
|
3741
|
+
events,
|
|
3742
|
+
"--review requires --pr.",
|
|
3743
|
+
task_id=task_id,
|
|
3744
|
+
kind="usage_error",
|
|
3745
|
+
)
|
|
3746
|
+
|
|
3747
|
+
try:
|
|
3748
|
+
run_mutation_guard = acquire_swarm_mutation_guard(
|
|
3749
|
+
paths,
|
|
3750
|
+
mode=f"forge_exec:{task_id}",
|
|
3751
|
+
on_wait=lambda info: _print_forge_lock_wait_notice(console, info),
|
|
3752
|
+
)
|
|
3753
|
+
except ForgeError as e:
|
|
3754
|
+
raise _exec_error_exit(console, events, str(e), task_id=task_id) from e
|
|
3755
|
+
|
|
3756
|
+
try:
|
|
3757
|
+
if bool(getattr(run_mutation_guard, "acquired_after_wait", False)):
|
|
3758
|
+
plan = load_plan(paths)
|
|
3759
|
+
task = find_task(plan, task_id)
|
|
3760
|
+
if task is None:
|
|
3761
|
+
message = (
|
|
3762
|
+
"Queued Forge exec revalidated the current plan "
|
|
3763
|
+
f"and task no longer exists: {task_id}"
|
|
3764
|
+
)
|
|
3765
|
+
events.emit(
|
|
3766
|
+
EVENT_PLAN_INVALID,
|
|
3767
|
+
{"reason": message, "source": "task_lookup", "task_id": task_id},
|
|
3768
|
+
)
|
|
3769
|
+
raise _exec_error_exit(console, events, message, task_id=task_id)
|
|
3770
|
+
task_status = canonical_task_status(str(task.get("status") or ""))
|
|
3771
|
+
if task_status in {"done", "superseded", "invalidated"}:
|
|
3772
|
+
message = (
|
|
3773
|
+
"Queued Forge exec revalidated the current plan "
|
|
3774
|
+
f"and task is no longer executable ({task_status}): {task_id}."
|
|
3775
|
+
)
|
|
3776
|
+
events.emit(
|
|
3777
|
+
EVENT_PLAN_INVALID,
|
|
3778
|
+
{
|
|
3779
|
+
"reason": message,
|
|
3780
|
+
"source": "task_status",
|
|
3781
|
+
"task_id": task_id,
|
|
3782
|
+
"status": task_status,
|
|
3783
|
+
},
|
|
3784
|
+
)
|
|
3785
|
+
raise _exec_error_exit(console, events, message, task_id=task_id)
|
|
3786
|
+
run_cfg.model = resolve_model_for_role(
|
|
3787
|
+
cfg=effective,
|
|
3788
|
+
role=ROLE_CODING,
|
|
3789
|
+
plan=plan,
|
|
3790
|
+
prefer_context="forge",
|
|
3791
|
+
)
|
|
3792
|
+
verify_commands = []
|
|
3793
|
+
verify_command_source = None
|
|
3794
|
+
if verify_mode != "off":
|
|
3795
|
+
verify_selection = resolve_authoritative_task_verify_command_selection(
|
|
3796
|
+
cfg=effective,
|
|
3797
|
+
verify_cmd=verify_cmd,
|
|
3798
|
+
task=task,
|
|
3799
|
+
root=paths.root,
|
|
3800
|
+
plan_requirements=[
|
|
3801
|
+
str(item).strip()
|
|
3802
|
+
for item in (plan.get("requirements") or [])
|
|
3803
|
+
if str(item).strip()
|
|
3804
|
+
],
|
|
3805
|
+
)
|
|
3806
|
+
verify_commands = list(verify_selection.commands)
|
|
3807
|
+
verify_command_source = verify_selection.source
|
|
3808
|
+
run_cfg.verify_commands = list(verify_commands)
|
|
3809
|
+
blockers = _task_dependency_blockers(plan, task)
|
|
3810
|
+
if blockers:
|
|
3811
|
+
message = "Dependencies are not done: " + ", ".join(blockers)
|
|
3812
|
+
events.emit(
|
|
3813
|
+
EVENT_PLAN_INVALID,
|
|
3814
|
+
{
|
|
3815
|
+
"reason": message,
|
|
3816
|
+
"source": "dependencies",
|
|
3817
|
+
"task_id": task_id,
|
|
3818
|
+
"blockers": list(blockers),
|
|
3819
|
+
},
|
|
3820
|
+
)
|
|
3821
|
+
raise _exec_error_exit(console, events, message, task_id=task_id)
|
|
3822
|
+
|
|
3823
|
+
_mark_run_status(
|
|
3824
|
+
paths,
|
|
3825
|
+
RUN_STATUS_RUNNING,
|
|
3826
|
+
reason=f"forge exec started for {task_id}",
|
|
3827
|
+
plan=plan,
|
|
3828
|
+
mode=f"forge_exec:{task_id}",
|
|
3829
|
+
)
|
|
3830
|
+
outcome = execute_forge_task(
|
|
3831
|
+
console=console,
|
|
3832
|
+
events=events,
|
|
3833
|
+
paths=paths,
|
|
3834
|
+
plan=plan,
|
|
3835
|
+
task=task,
|
|
3836
|
+
task_id=task_id,
|
|
3837
|
+
effective=effective,
|
|
3838
|
+
run_cfg=run_cfg,
|
|
3839
|
+
effective_mode=effective_mode,
|
|
3840
|
+
scope_mode=scope_mode,
|
|
3841
|
+
verify_mode=verify_mode,
|
|
3842
|
+
verify_commands=verify_commands,
|
|
3843
|
+
verify_command_source=verify_command_source,
|
|
3844
|
+
verify_repair_budget=verify_repair_budget,
|
|
3845
|
+
api_key_override=api_key_override,
|
|
3846
|
+
yes=yes,
|
|
3847
|
+
no_log=no_log,
|
|
3848
|
+
max_steps_provided=max_steps_provided,
|
|
3849
|
+
pr=pr,
|
|
3850
|
+
review=review,
|
|
3851
|
+
base_branch=base_branch,
|
|
3852
|
+
keep_branch=keep_branch,
|
|
3853
|
+
auto_resolve_conflicts=auto_resolve_conflicts is True,
|
|
3854
|
+
)
|
|
3855
|
+
terminal_status, terminal_reason = _terminal_run_status_for(
|
|
3856
|
+
plan=plan,
|
|
3857
|
+
any_failure=not outcome.success,
|
|
3858
|
+
)
|
|
3859
|
+
_mark_run_status(paths, terminal_status, reason=terminal_reason)
|
|
3860
|
+
except ForgeTaskExecutionError as e:
|
|
3861
|
+
raise _exec_error_exit(
|
|
3862
|
+
console,
|
|
3863
|
+
events,
|
|
3864
|
+
str(e),
|
|
3865
|
+
task_id=e.task_id or task_id,
|
|
3866
|
+
kind=e.kind,
|
|
3867
|
+
) from e
|
|
3868
|
+
finally:
|
|
3869
|
+
_mark_run_interrupted_if_still_running(
|
|
3870
|
+
paths,
|
|
3871
|
+
reason=f"forge exec for {task_id} exited without recording an outcome",
|
|
3872
|
+
)
|
|
3873
|
+
run_mutation_guard.release()
|
|
3874
|
+
|
|
3875
|
+
events.run_completed(
|
|
3876
|
+
ok=outcome.success,
|
|
3877
|
+
exit_code=outcome.exit_code,
|
|
3878
|
+
data={"task": outcome.payload},
|
|
3879
|
+
)
|
|
3880
|
+
raise typer.Exit(code=outcome.exit_code)
|
|
3881
|
+
|
|
3882
|
+
|
|
3883
|
+
def forge_run(
|
|
3884
|
+
path: Path = typer.Option(
|
|
3885
|
+
Path("."),
|
|
3886
|
+
"--path",
|
|
3887
|
+
help="Workspace path or repository subdirectory.",
|
|
3888
|
+
),
|
|
3889
|
+
allow_broad_workspace: bool = typer.Option(
|
|
3890
|
+
False,
|
|
3891
|
+
"--allow-broad-workspace",
|
|
3892
|
+
help="Allow guarded broad workspaces instead of requiring a narrower project path.",
|
|
3893
|
+
),
|
|
3894
|
+
only: str | None = typer.Option(
|
|
3895
|
+
None,
|
|
3896
|
+
"--only",
|
|
3897
|
+
help="Comma-separated task ids to execute (dependencies are still enforced).",
|
|
3898
|
+
),
|
|
3899
|
+
max_tasks: int | None = typer.Option(
|
|
3900
|
+
None,
|
|
3901
|
+
"--max-tasks",
|
|
3902
|
+
min=1,
|
|
3903
|
+
help="Stop after this many tasks have been executed.",
|
|
3904
|
+
),
|
|
3905
|
+
max_attempts: int | None = typer.Option(
|
|
3906
|
+
None,
|
|
3907
|
+
"--max-attempts",
|
|
3908
|
+
min=1,
|
|
3909
|
+
help="Skip tasks that already reached this many recorded attempts.",
|
|
3910
|
+
),
|
|
3911
|
+
retry_failed: bool = typer.Option(
|
|
3912
|
+
False,
|
|
3913
|
+
"--retry-failed",
|
|
3914
|
+
help="Include tasks currently marked failed.",
|
|
3915
|
+
),
|
|
3916
|
+
retry_changes_requested: bool = typer.Option(
|
|
3917
|
+
False,
|
|
3918
|
+
"--retry-changes-requested",
|
|
3919
|
+
help="Include tasks currently marked changes_requested.",
|
|
3920
|
+
),
|
|
3921
|
+
keep_going: bool = typer.Option(
|
|
3922
|
+
False,
|
|
3923
|
+
"--keep-going",
|
|
3924
|
+
help=(
|
|
3925
|
+
"Continue with the next independent task after a failure instead of stopping. "
|
|
3926
|
+
"Default is to stop, because a failed task usually leaves later tasks unsound."
|
|
3927
|
+
),
|
|
3928
|
+
),
|
|
3929
|
+
dry_run: bool = typer.Option(
|
|
3930
|
+
False,
|
|
3931
|
+
"--dry-run",
|
|
3932
|
+
help="Print the execution order and exit without running anything.",
|
|
3933
|
+
),
|
|
3934
|
+
scope: str = typer.Option(
|
|
3935
|
+
"strict",
|
|
3936
|
+
"--scope",
|
|
3937
|
+
help="Write-scope enforcement: strict by default; use warn or off to opt out.",
|
|
3938
|
+
),
|
|
3939
|
+
verify: str = typer.Option(
|
|
3940
|
+
"warn",
|
|
3941
|
+
"--verify",
|
|
3942
|
+
help="Per-task verification policy: off, warn, or strict.",
|
|
3943
|
+
),
|
|
3944
|
+
verify_cmd: list[str] | None = typer.Option(
|
|
3945
|
+
None,
|
|
3946
|
+
"--verify-cmd",
|
|
3947
|
+
help="Override verify command for this run (repeatable).",
|
|
3948
|
+
),
|
|
3949
|
+
verify_repair_attempts: int | None = typer.Option(
|
|
3950
|
+
None,
|
|
3951
|
+
"--verify-repair-attempts",
|
|
3952
|
+
help=(
|
|
3953
|
+
"How many times a failing strict verification is fed back to the agent "
|
|
3954
|
+
"to repair before the task fails (default 2; 0 disables)."
|
|
3955
|
+
),
|
|
3956
|
+
),
|
|
3957
|
+
pr: bool = typer.Option(
|
|
3958
|
+
True,
|
|
3959
|
+
"--pr/--no-pr",
|
|
3960
|
+
help=(
|
|
3961
|
+
"Run each task as a branch/commit/verify/merge cycle in the main checkout "
|
|
3962
|
+
"(default). --no-pr leaves the changes uncommitted and runs no verification "
|
|
3963
|
+
"gate, so use it only where git flow is unavailable."
|
|
3964
|
+
),
|
|
3965
|
+
),
|
|
3966
|
+
review: bool = typer.Option(
|
|
3967
|
+
False,
|
|
3968
|
+
"--review",
|
|
3969
|
+
help="Run the automated review gate before merging each task (requires --pr).",
|
|
3970
|
+
),
|
|
3971
|
+
base_branch: str | None = typer.Option(
|
|
3972
|
+
None,
|
|
3973
|
+
"--base-branch",
|
|
3974
|
+
help="Base branch for --pr mode (defaults to the current branch).",
|
|
3975
|
+
),
|
|
3976
|
+
keep_branch: bool = typer.Option(
|
|
3977
|
+
False,
|
|
3978
|
+
"--keep-branch",
|
|
3979
|
+
help="Keep each task branch after a successful merge.",
|
|
3980
|
+
),
|
|
3981
|
+
auto_resolve_conflicts: bool = typer.Option(
|
|
3982
|
+
False,
|
|
3983
|
+
"--auto-resolve-conflicts",
|
|
3984
|
+
help=(
|
|
3985
|
+
"On merge conflict, start a resolver agent in a dedicated worktree instead of "
|
|
3986
|
+
"stopping with a conflict report. Off by default."
|
|
3987
|
+
),
|
|
3988
|
+
),
|
|
3989
|
+
mode: Mode | None = typer.Option(None, "--mode", help="Mode override."),
|
|
3990
|
+
model: str | None = typer.Option(None, "--model", help="Model override."),
|
|
3991
|
+
base_url: str | None = typer.Option(None, "--base-url", help="Base URL override."),
|
|
3992
|
+
temperature: float | None = typer.Option(None, "--temperature", help="Sampling temperature."),
|
|
3993
|
+
stream: bool | None = typer.Option(
|
|
3994
|
+
None,
|
|
3995
|
+
"--stream/--no-stream",
|
|
3996
|
+
help="Enable streamed assistant output.",
|
|
3997
|
+
),
|
|
3998
|
+
max_steps: int | None = typer.Option(
|
|
3999
|
+
None,
|
|
4000
|
+
"--max-steps",
|
|
4001
|
+
help="Optional safety limit on each managed agent task.",
|
|
4002
|
+
),
|
|
4003
|
+
no_log: bool = typer.Option(False, "--no-log", help="Disable JSONL session logging."),
|
|
4004
|
+
api_key_env: str | None = typer.Option(
|
|
4005
|
+
None,
|
|
4006
|
+
"--api-key-env",
|
|
4007
|
+
help=(
|
|
4008
|
+
"Read API key from this environment variable (overrides ALYSIS_API_KEY/OPENAI_API_KEY)."
|
|
4009
|
+
),
|
|
4010
|
+
),
|
|
4011
|
+
api_key_stdin: bool = typer.Option(
|
|
4012
|
+
False,
|
|
4013
|
+
"--api-key-stdin",
|
|
4014
|
+
help="Prompt for API key (hidden input). Key is kept in memory for this run only.",
|
|
4015
|
+
),
|
|
4016
|
+
api_key: str | None = typer.Option(
|
|
4017
|
+
None,
|
|
4018
|
+
"--api-key",
|
|
4019
|
+
help=(
|
|
4020
|
+
"UNSAFE: Provide API key via CLI argument (may leak via shell history / process list). "
|
|
4021
|
+
"Prefer --api-key-stdin or --api-key-env."
|
|
4022
|
+
),
|
|
4023
|
+
),
|
|
4024
|
+
yes: bool = typer.Option(
|
|
4025
|
+
False,
|
|
4026
|
+
"--yes",
|
|
4027
|
+
help="In auto mode, skip confirmations for sensitive commands (hard blocks still apply).",
|
|
4028
|
+
),
|
|
4029
|
+
cli_ctx: Any = None,
|
|
4030
|
+
events: Any = None,
|
|
4031
|
+
) -> None:
|
|
4032
|
+
"""Execute every ready task sequentially, in dependency order, in this checkout.
|
|
4033
|
+
|
|
4034
|
+
No worktrees, no parallelism, no batch integration gate: one task at a time
|
|
4035
|
+
through :func:`execute_forge_task`, the same core `forge exec` uses. The run
|
|
4036
|
+
stops at the first failure unless ``--keep-going`` says otherwise.
|
|
4037
|
+
"""
|
|
4038
|
+
events = _events_or_null(events)
|
|
4039
|
+
console = _console()
|
|
4040
|
+
cfg = load_config()
|
|
4041
|
+
effective = clone_cfg(cfg)
|
|
4042
|
+
# The public Typer command forwards its own context explicitly. Prefer it
|
|
4043
|
+
# over Click's ambient lookup: this implementation is called through a
|
|
4044
|
+
# delegation layer, where the ambient context may be the parent ``forge``
|
|
4045
|
+
# group and cannot report whether run-specific options were supplied.
|
|
4046
|
+
current_ctx = cli_ctx if cli_ctx is not None else get_current_context(silent=True)
|
|
4047
|
+
max_steps_provided = max_steps is not None
|
|
4048
|
+
if current_ctx is not None:
|
|
4049
|
+
source = current_ctx.get_parameter_source("max_steps")
|
|
4050
|
+
# Typer may provide its vendored Click enum here, so compare the stable
|
|
4051
|
+
# semantic name rather than enum identity across package boundaries.
|
|
4052
|
+
max_steps_provided = (
|
|
4053
|
+
source is not None and getattr(source, "name", None) != ParameterSource.DEFAULT.name
|
|
4054
|
+
)
|
|
4055
|
+
pr_explicit = False
|
|
4056
|
+
if current_ctx is not None:
|
|
4057
|
+
pr_source = current_ctx.get_parameter_source("pr")
|
|
4058
|
+
pr_explicit = (
|
|
4059
|
+
pr_source is not None
|
|
4060
|
+
and getattr(pr_source, "name", None) != ParameterSource.DEFAULT.name
|
|
4061
|
+
)
|
|
4062
|
+
|
|
4063
|
+
if base_url is not None:
|
|
4064
|
+
effective.base_url = base_url
|
|
4065
|
+
if model is not None:
|
|
4066
|
+
effective.model = model
|
|
4067
|
+
if temperature is not None:
|
|
4068
|
+
_apply_temperature_override(effective, temperature)
|
|
4069
|
+
if stream is not None:
|
|
4070
|
+
effective.stream = stream
|
|
4071
|
+
if max_steps is not None:
|
|
4072
|
+
effective.max_steps = max_steps
|
|
4073
|
+
effective_mode = (mode.value if mode else effective.default_mode) or "review"
|
|
4074
|
+
|
|
4075
|
+
# Called as a plain function these arrive as truthy typer OptionInfo objects,
|
|
4076
|
+
# so every boolean is normalized before it can silently flip a policy.
|
|
4077
|
+
pr_requested = pr is True
|
|
4078
|
+
review_requested = review is True
|
|
4079
|
+
keep_branch_requested = keep_branch is True
|
|
4080
|
+
keep_going_requested = keep_going is True
|
|
4081
|
+
dry_run_requested = dry_run is True
|
|
4082
|
+
retry_failed_requested = retry_failed is True
|
|
4083
|
+
retry_changes_requested_requested = retry_changes_requested is True
|
|
4084
|
+
auto_resolve_requested = auto_resolve_conflicts is True
|
|
4085
|
+
repair_attempts_override = (
|
|
4086
|
+
verify_repair_attempts
|
|
4087
|
+
if isinstance(verify_repair_attempts, int) and not isinstance(verify_repair_attempts, bool)
|
|
4088
|
+
else None
|
|
4089
|
+
)
|
|
4090
|
+
verify_repair_budget = resolve_repair_attempt_budget(repair_attempts_override)
|
|
4091
|
+
only_ids = _parse_only_task_ids(only)
|
|
4092
|
+
task_limit = (
|
|
4093
|
+
max_tasks if isinstance(max_tasks, int) and not isinstance(max_tasks, bool) else None
|
|
4094
|
+
)
|
|
4095
|
+
attempt_limit = (
|
|
4096
|
+
max_attempts
|
|
4097
|
+
if isinstance(max_attempts, int) and not isinstance(max_attempts, bool)
|
|
4098
|
+
else None
|
|
4099
|
+
)
|
|
4100
|
+
|
|
4101
|
+
started_at = now_iso()
|
|
4102
|
+
outcomes: list[TaskExecutionOutcome] = []
|
|
4103
|
+
run_log = _SequentialRunLog()
|
|
4104
|
+
paths: Any = None
|
|
4105
|
+
|
|
4106
|
+
try:
|
|
4107
|
+
scope_mode = _normalize_scope_mode(scope)
|
|
4108
|
+
verify_mode = _normalize_verify_mode(verify)
|
|
4109
|
+
if review_requested and not pr_requested:
|
|
4110
|
+
raise ForgeError("--review requires --pr.")
|
|
4111
|
+
api_key_override = _resolve_api_key_override(
|
|
4112
|
+
api_key=api_key,
|
|
4113
|
+
api_key_env=api_key_env,
|
|
4114
|
+
api_key_stdin=api_key_stdin,
|
|
4115
|
+
)
|
|
4116
|
+
binding = resolve_workspace_binding(
|
|
4117
|
+
path,
|
|
4118
|
+
create_if_missing=False,
|
|
4119
|
+
allow_broad_workspace=allow_broad_workspace,
|
|
4120
|
+
source=_path_binding_source(cli_ctx, path),
|
|
4121
|
+
)
|
|
4122
|
+
ensure_workspace_policy(
|
|
4123
|
+
binding,
|
|
4124
|
+
action=WorkspaceAction.FORGE_RUN,
|
|
4125
|
+
allow_broad_workspace=allow_broad_workspace,
|
|
4126
|
+
)
|
|
4127
|
+
try:
|
|
4128
|
+
paths = load_current_run_paths(binding.workspace_context.focus_path)
|
|
4129
|
+
except ForgeError as e:
|
|
4130
|
+
if "current_run.json" in str(e):
|
|
4131
|
+
raise _missing_swarm_run_error(binding=binding) from e
|
|
4132
|
+
raise
|
|
4133
|
+
events.set_run_id(paths.run_id)
|
|
4134
|
+
run_log.bind(paths)
|
|
4135
|
+
plan = load_plan(paths)
|
|
4136
|
+
|
|
4137
|
+
# PR flow is the default because it is what makes per-task verification and a
|
|
4138
|
+
# per-task resume point possible. A workspace without a git HEAD cannot do it,
|
|
4139
|
+
# so an unstated default degrades loudly instead of failing; an explicit --pr
|
|
4140
|
+
# still fails, because silently ignoring a flag the user typed is worse.
|
|
4141
|
+
git_flow_available = bool(paths.has_head_commit)
|
|
4142
|
+
if pr_requested and not git_flow_available:
|
|
4143
|
+
# --review only exists inside git flow, so degrading would drop it too.
|
|
4144
|
+
# Both count as "the user typed it", and neither gets dropped silently.
|
|
4145
|
+
explicit_flag = "--pr" if pr_explicit else ("--review" if review_requested else None)
|
|
4146
|
+
if explicit_flag is not None:
|
|
4147
|
+
raise ForgeError(
|
|
4148
|
+
f"{explicit_flag} needs a git repository with at least one commit; this "
|
|
4149
|
+
f"workspace ({os.fspath(paths.root)}) has none. Commit once, or drop "
|
|
4150
|
+
f"{explicit_flag}."
|
|
4151
|
+
)
|
|
4152
|
+
pr_requested = False
|
|
4153
|
+
review_requested = False
|
|
4154
|
+
console.print(
|
|
4155
|
+
"[yellow]No git HEAD in this workspace:[/yellow] running without git flow. "
|
|
4156
|
+
"Tasks execute in place, nothing is committed, and per-task verification "
|
|
4157
|
+
"does not run."
|
|
4158
|
+
)
|
|
4159
|
+
events.emit(
|
|
4160
|
+
EVENT_VERIFICATION_UNAVAILABLE,
|
|
4161
|
+
{
|
|
4162
|
+
"scope": "run",
|
|
4163
|
+
"policy": verify_mode,
|
|
4164
|
+
"reason": (
|
|
4165
|
+
"sequential run degraded to --no-pr: workspace has no git HEAD, so no "
|
|
4166
|
+
"per-task verification gate runs"
|
|
4167
|
+
),
|
|
4168
|
+
"blocking": False,
|
|
4169
|
+
},
|
|
4170
|
+
)
|
|
4171
|
+
|
|
4172
|
+
# The base branch is pinned once for the whole run rather than re-derived per
|
|
4173
|
+
# task from the current branch. A failed task leaves the checkout on its own
|
|
4174
|
+
# branch, so re-deriving would make the next task branch off rejected work --
|
|
4175
|
+
# which is exactly what --keep-going would otherwise do.
|
|
4176
|
+
resolved_base_branch: str | None = (
|
|
4177
|
+
base_branch.strip() if isinstance(base_branch, str) and base_branch.strip() else None
|
|
4178
|
+
)
|
|
4179
|
+
if pr_requested and resolved_base_branch is None:
|
|
4180
|
+
try:
|
|
4181
|
+
resolved_base_branch = (current_branch(paths.root) or "").strip() or None
|
|
4182
|
+
except GitOpsError as e:
|
|
4183
|
+
raise ForgeError(f"could not determine the base branch for git flow: {e}") from e
|
|
4184
|
+
if resolved_base_branch is None:
|
|
4185
|
+
raise ForgeError(
|
|
4186
|
+
"could not determine the base branch for git flow; pass --base-branch."
|
|
4187
|
+
)
|
|
4188
|
+
|
|
4189
|
+
run_mutation_guard = acquire_swarm_mutation_guard(
|
|
4190
|
+
paths,
|
|
4191
|
+
mode="forge_run:cli",
|
|
4192
|
+
on_wait=lambda info: _print_forge_lock_wait_notice(console, info),
|
|
4193
|
+
)
|
|
4194
|
+
try:
|
|
4195
|
+
if bool(getattr(run_mutation_guard, "acquired_after_wait", False)):
|
|
4196
|
+
plan = load_plan(paths)
|
|
4197
|
+
reconciliation_result, _ = _reconcile_plan_for_paths(
|
|
4198
|
+
paths=paths,
|
|
4199
|
+
plan=plan,
|
|
4200
|
+
refresh_if_stale=True,
|
|
4201
|
+
)
|
|
4202
|
+
if reconciliation_result.changed:
|
|
4203
|
+
save_plan(paths, plan)
|
|
4204
|
+
if reconciliation_result.warnings:
|
|
4205
|
+
console.print("[yellow]Plan reconciliation warnings:[/yellow]")
|
|
4206
|
+
for warning in reconciliation_result.warnings:
|
|
4207
|
+
console.print(f"- {warning}")
|
|
4208
|
+
validation_warnings = _validate_forge_plan_for_paths(paths, plan)
|
|
4209
|
+
_write_plan_validation_artifact(
|
|
4210
|
+
paths=paths,
|
|
4211
|
+
reconciliation_result=reconciliation_result,
|
|
4212
|
+
validation_warnings=validation_warnings,
|
|
4213
|
+
)
|
|
4214
|
+
repair_payload = plan_repair_event_payload(plan)
|
|
4215
|
+
readiness_message = _forge_no_execution_ready_tasks_message(plan)
|
|
4216
|
+
if readiness_message is not None:
|
|
4217
|
+
events.emit(
|
|
4218
|
+
EVENT_PLAN_INVALID,
|
|
4219
|
+
{
|
|
4220
|
+
"reason": readiness_message,
|
|
4221
|
+
"source": "execution_readiness",
|
|
4222
|
+
"warnings": list(validation_warnings),
|
|
4223
|
+
**repair_payload,
|
|
4224
|
+
},
|
|
4225
|
+
)
|
|
4226
|
+
raise ForgeError(readiness_message)
|
|
4227
|
+
try:
|
|
4228
|
+
raise_for_execution_ready_plan(
|
|
4229
|
+
plan,
|
|
4230
|
+
retry_failed=retry_failed_requested,
|
|
4231
|
+
retry_changes_requested=retry_changes_requested_requested,
|
|
4232
|
+
retry_merge_conflicts=False,
|
|
4233
|
+
only=only,
|
|
4234
|
+
)
|
|
4235
|
+
except PlannerFailedError as e:
|
|
4236
|
+
events.emit(
|
|
4237
|
+
EVENT_PLAN_INVALID,
|
|
4238
|
+
{
|
|
4239
|
+
"reason": str(e),
|
|
4240
|
+
"source": "plan_validation",
|
|
4241
|
+
"failure_category": getattr(e, "failure_category", None),
|
|
4242
|
+
**repair_payload,
|
|
4243
|
+
},
|
|
4244
|
+
)
|
|
4245
|
+
err = ForgeError(str(e))
|
|
4246
|
+
err.failure_category = e.failure_category # type: ignore[attr-defined]
|
|
4247
|
+
raise err from e
|
|
4248
|
+
|
|
4249
|
+
projected = _projected_sequential_order(
|
|
4250
|
+
plan,
|
|
4251
|
+
retry_failed=retry_failed_requested,
|
|
4252
|
+
retry_changes_requested=retry_changes_requested_requested,
|
|
4253
|
+
max_attempts=attempt_limit,
|
|
4254
|
+
only_ids=only_ids,
|
|
4255
|
+
max_tasks=task_limit,
|
|
4256
|
+
)
|
|
4257
|
+
# A run boundary in the append-only log, so a second `forge run` against the
|
|
4258
|
+
# same plan reads as a second run rather than as more of the first.
|
|
4259
|
+
run_log.append(
|
|
4260
|
+
"run_started",
|
|
4261
|
+
run_id=paths.run_id,
|
|
4262
|
+
order=list(projected),
|
|
4263
|
+
git_flow=pr_requested,
|
|
4264
|
+
verify=verify_mode,
|
|
4265
|
+
scope=scope_mode,
|
|
4266
|
+
dry_run=dry_run_requested,
|
|
4267
|
+
)
|
|
4268
|
+
console.rule("[bold]forge run[/bold]")
|
|
4269
|
+
console.print(f"Run ID: {paths.run_id}")
|
|
4270
|
+
console.print(f"Workspace: {os.fspath(paths.root)}")
|
|
4271
|
+
console.print(
|
|
4272
|
+
f"Mode: sequential, in-checkout (no worktrees) | "
|
|
4273
|
+
f"git flow: {'on' if pr_requested else 'off'} | verify: {verify_mode} | "
|
|
4274
|
+
f"scope: {scope_mode}"
|
|
4275
|
+
)
|
|
4276
|
+
if pr_requested and resolved_base_branch:
|
|
4277
|
+
console.print(f"Base branch: {resolved_base_branch}")
|
|
4278
|
+
_print_sequential_order(console, plan, projected)
|
|
4279
|
+
|
|
4280
|
+
if dry_run_requested:
|
|
4281
|
+
data = {
|
|
4282
|
+
"dry_run": True,
|
|
4283
|
+
"order": list(projected),
|
|
4284
|
+
"task_count": len(projected),
|
|
4285
|
+
}
|
|
4286
|
+
events.run_completed(ok=True, exit_code=EXIT_OK, data=data)
|
|
4287
|
+
raise typer.Exit(code=EXIT_OK)
|
|
4288
|
+
|
|
4289
|
+
# From here on the workspace has an owner on record. Anything that stops
|
|
4290
|
+
# this process without reaching the terminal transition below leaves the
|
|
4291
|
+
# pointer saying `running` with a dead owner, which the next command
|
|
4292
|
+
# reconciles to `interrupted`.
|
|
4293
|
+
_mark_run_status(
|
|
4294
|
+
paths,
|
|
4295
|
+
RUN_STATUS_RUNNING,
|
|
4296
|
+
reason=f"forge run started ({len(projected)} task(s) projected)",
|
|
4297
|
+
plan=plan,
|
|
4298
|
+
mode="forge_run:cli",
|
|
4299
|
+
)
|
|
4300
|
+
|
|
4301
|
+
stopped_reason = "completed"
|
|
4302
|
+
attempted: set[str] = set()
|
|
4303
|
+
while True:
|
|
4304
|
+
if task_limit is not None and len(outcomes) >= task_limit:
|
|
4305
|
+
stopped_reason = "max_tasks"
|
|
4306
|
+
break
|
|
4307
|
+
candidate = _next_sequential_task(
|
|
4308
|
+
plan,
|
|
4309
|
+
retry_failed=retry_failed_requested,
|
|
4310
|
+
retry_changes_requested=retry_changes_requested_requested,
|
|
4311
|
+
max_attempts=attempt_limit,
|
|
4312
|
+
only_ids=only_ids,
|
|
4313
|
+
exclude_ids=attempted,
|
|
4314
|
+
)
|
|
4315
|
+
if candidate is None:
|
|
4316
|
+
break
|
|
4317
|
+
task = candidate.task
|
|
4318
|
+
task_id = candidate.task_id
|
|
4319
|
+
attempted.add(task_id)
|
|
4320
|
+
|
|
4321
|
+
run_cfg = clone_cfg(effective)
|
|
4322
|
+
run_cfg.model = resolve_model_for_role(
|
|
4323
|
+
cfg=effective,
|
|
4324
|
+
role=ROLE_CODING,
|
|
4325
|
+
plan=plan,
|
|
4326
|
+
prefer_context="forge",
|
|
4327
|
+
)
|
|
4328
|
+
verify_commands: list[str] = []
|
|
4329
|
+
verify_command_source: str | None = None
|
|
4330
|
+
if verify_mode != "off":
|
|
4331
|
+
selection = resolve_authoritative_task_verify_command_selection(
|
|
4332
|
+
cfg=effective,
|
|
4333
|
+
verify_cmd=verify_cmd,
|
|
4334
|
+
task=task,
|
|
4335
|
+
root=paths.root,
|
|
4336
|
+
plan_requirements=[
|
|
4337
|
+
str(item).strip()
|
|
4338
|
+
for item in (plan.get("requirements") or [])
|
|
4339
|
+
if str(item).strip()
|
|
4340
|
+
],
|
|
4341
|
+
)
|
|
4342
|
+
verify_commands = list(selection.commands)
|
|
4343
|
+
verify_command_source = selection.source
|
|
4344
|
+
run_cfg.verify_commands = list(verify_commands)
|
|
4345
|
+
|
|
4346
|
+
position = len(outcomes) + 1
|
|
4347
|
+
console.rule(
|
|
4348
|
+
f"[bold]{position}. {task_id}[/bold] {str(task.get('title') or '').strip()}"
|
|
4349
|
+
)
|
|
4350
|
+
run_log.append(
|
|
4351
|
+
"task_started",
|
|
4352
|
+
task_id=task_id,
|
|
4353
|
+
title=str(task.get("title") or ""),
|
|
4354
|
+
position=position,
|
|
4355
|
+
)
|
|
4356
|
+
try:
|
|
4357
|
+
outcome = execute_forge_task(
|
|
4358
|
+
console=console,
|
|
4359
|
+
events=events,
|
|
4360
|
+
paths=paths,
|
|
4361
|
+
plan=plan,
|
|
4362
|
+
task=task,
|
|
4363
|
+
task_id=task_id,
|
|
4364
|
+
effective=effective,
|
|
4365
|
+
run_cfg=run_cfg,
|
|
4366
|
+
effective_mode=effective_mode,
|
|
4367
|
+
scope_mode=scope_mode,
|
|
4368
|
+
verify_mode=verify_mode,
|
|
4369
|
+
verify_commands=verify_commands,
|
|
4370
|
+
verify_command_source=verify_command_source,
|
|
4371
|
+
verify_repair_budget=verify_repair_budget,
|
|
4372
|
+
api_key_override=api_key_override,
|
|
4373
|
+
yes=yes is True,
|
|
4374
|
+
no_log=no_log is True,
|
|
4375
|
+
max_steps_provided=max_steps_provided,
|
|
4376
|
+
pr=pr_requested,
|
|
4377
|
+
review=review_requested,
|
|
4378
|
+
base_branch=resolved_base_branch,
|
|
4379
|
+
keep_branch=keep_branch_requested,
|
|
4380
|
+
auto_resolve_conflicts=auto_resolve_requested,
|
|
4381
|
+
)
|
|
4382
|
+
except ForgeTaskExecutionError as e:
|
|
4383
|
+
# The task could not be executed at all. That is a command error,
|
|
4384
|
+
# and continuing would run later tasks on top of an unknown repo
|
|
4385
|
+
# state, so the run ends here.
|
|
4386
|
+
run_log.append(
|
|
4387
|
+
"task_error",
|
|
4388
|
+
task_id=task_id,
|
|
4389
|
+
error=str(e),
|
|
4390
|
+
kind=e.kind,
|
|
4391
|
+
)
|
|
4392
|
+
_write_sequential_summary(
|
|
4393
|
+
paths=paths,
|
|
4394
|
+
run_id=paths.run_id,
|
|
4395
|
+
started_at=started_at,
|
|
4396
|
+
outcomes=outcomes,
|
|
4397
|
+
stopped_reason="task_error",
|
|
4398
|
+
exit_code=EXIT_ERROR,
|
|
4399
|
+
pr=pr_requested,
|
|
4400
|
+
verify_mode=verify_mode,
|
|
4401
|
+
scope_mode=scope_mode,
|
|
4402
|
+
error=str(e),
|
|
4403
|
+
)
|
|
4404
|
+
err = ForgeError(str(e))
|
|
4405
|
+
err.failure_category = e.kind # type: ignore[attr-defined]
|
|
4406
|
+
raise err from e
|
|
4407
|
+
|
|
4408
|
+
outcomes.append(outcome)
|
|
4409
|
+
# Recorded after the fact, not before: `execute_forge_task` derives the
|
|
4410
|
+
# task's step budget from the count of *previous* attempts, so bumping
|
|
4411
|
+
# first would make every first attempt look like a retry -- and the whole
|
|
4412
|
+
# point of sharing the core is that `forge run` and `forge exec` give a
|
|
4413
|
+
# task the same budget.
|
|
4414
|
+
attempts = _bump_task_attempt(paths, plan, task)
|
|
4415
|
+
run_log.append(
|
|
4416
|
+
"task_finished",
|
|
4417
|
+
task_id=task_id,
|
|
4418
|
+
status=outcome.status,
|
|
4419
|
+
success=outcome.success,
|
|
4420
|
+
summary=outcome.summary,
|
|
4421
|
+
report=os.fspath(outcome.report_path),
|
|
4422
|
+
attempts=attempts,
|
|
4423
|
+
)
|
|
4424
|
+
if not outcome.success and not keep_going_requested:
|
|
4425
|
+
stopped_reason = "task_failed"
|
|
4426
|
+
break
|
|
4427
|
+
|
|
4428
|
+
remaining = _unfinished_task_ids(plan)
|
|
4429
|
+
terminal_status, terminal_reason = _terminal_run_status_for(
|
|
4430
|
+
plan=plan,
|
|
4431
|
+
any_failure=any(not item.success for item in outcomes),
|
|
4432
|
+
)
|
|
4433
|
+
_mark_run_status(paths, terminal_status, reason=terminal_reason)
|
|
4434
|
+
finally:
|
|
4435
|
+
_mark_run_interrupted_if_still_running(
|
|
4436
|
+
paths,
|
|
4437
|
+
reason="forge run exited without recording an outcome",
|
|
4438
|
+
)
|
|
4439
|
+
run_mutation_guard.release()
|
|
4440
|
+
except (ConfigError, ForgeError, GitOpsError, WorkspaceBindingError) as e:
|
|
4441
|
+
console.print(f"[red]Forge error:[/red] {e}")
|
|
4442
|
+
events.error(
|
|
4443
|
+
message=str(e),
|
|
4444
|
+
kind="forge_error",
|
|
4445
|
+
exit_code=EXIT_ERROR,
|
|
4446
|
+
data={"failure_category": getattr(e, "failure_category", None)},
|
|
4447
|
+
)
|
|
4448
|
+
raise typer.Exit(code=EXIT_ERROR) from e
|
|
4449
|
+
except typer.Exit:
|
|
4450
|
+
raise
|
|
4451
|
+
except Exception as e: # noqa: BLE001
|
|
4452
|
+
# An unexpected exception is the command failing, not "tasks were rejected".
|
|
4453
|
+
console.print(f"[red]Forge error:[/red] {e}")
|
|
4454
|
+
events.error(
|
|
4455
|
+
message=str(e) or e.__class__.__name__,
|
|
4456
|
+
kind="exception",
|
|
4457
|
+
exit_code=EXIT_ERROR,
|
|
4458
|
+
data={"exception_type": e.__class__.__name__},
|
|
4459
|
+
)
|
|
4460
|
+
raise typer.Exit(code=EXIT_ERROR) from e
|
|
4461
|
+
|
|
4462
|
+
# Exit 1 means "ran to completion, work not accepted". Tasks left unexecuted
|
|
4463
|
+
# because of --only or --max-tasks are a scope the caller chose, not rejected
|
|
4464
|
+
# work, so only an actual task failure moves the exit code.
|
|
4465
|
+
failed = [item for item in outcomes if not item.success]
|
|
4466
|
+
exit_code = EXIT_NOT_ACCEPTED if failed else EXIT_OK
|
|
4467
|
+
|
|
4468
|
+
summary_payload = _write_sequential_summary(
|
|
4469
|
+
paths=paths,
|
|
4470
|
+
run_id=paths.run_id,
|
|
4471
|
+
started_at=started_at,
|
|
4472
|
+
outcomes=outcomes,
|
|
4473
|
+
stopped_reason=stopped_reason,
|
|
4474
|
+
exit_code=exit_code,
|
|
4475
|
+
pr=pr_requested,
|
|
4476
|
+
verify_mode=verify_mode,
|
|
4477
|
+
scope_mode=scope_mode,
|
|
4478
|
+
remaining=remaining,
|
|
4479
|
+
)
|
|
4480
|
+
_print_sequential_summary(console, summary_payload)
|
|
4481
|
+
_print_usage_summary_from_logs(
|
|
4482
|
+
console=console,
|
|
4483
|
+
title="Sequential Run Usage Summary",
|
|
4484
|
+
log_paths=sorted(paths.execution_logs_dir.glob("*.jsonl")),
|
|
4485
|
+
)
|
|
4486
|
+
events.run_completed(
|
|
4487
|
+
ok=exit_code == EXIT_OK, exit_code=exit_code, data={"outcome": summary_payload}
|
|
4488
|
+
)
|
|
4489
|
+
raise typer.Exit(code=exit_code)
|
|
4490
|
+
|
|
4491
|
+
|
|
4492
|
+
def forge_plan_impl(cli_mod: Any, *args: Any, **kwargs: Any) -> Any:
|
|
4493
|
+
_sync_cli_globals(cli_mod)
|
|
4494
|
+
return forge_plan(*args, **kwargs)
|
|
4495
|
+
|
|
4496
|
+
|
|
4497
|
+
def forge_swarm_impl(cli_mod: Any, *args: Any, **kwargs: Any) -> Any:
|
|
4498
|
+
_sync_cli_globals(cli_mod)
|
|
4499
|
+
return forge_swarm(*args, **kwargs)
|
|
4500
|
+
|
|
4501
|
+
|
|
4502
|
+
def forge_exec_impl(cli_mod: Any, *args: Any, **kwargs: Any) -> Any:
|
|
4503
|
+
_sync_cli_globals(cli_mod)
|
|
4504
|
+
return forge_exec(*args, **kwargs)
|
|
4505
|
+
|
|
4506
|
+
|
|
4507
|
+
def forge_run_impl(cli_mod: Any, *args: Any, **kwargs: Any) -> Any:
|
|
4508
|
+
_sync_cli_globals(cli_mod)
|
|
4509
|
+
return forge_run(*args, **kwargs)
|