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