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,1141 @@
1
+ """The lock that keeps two Forge executions off one workspace.
2
+
3
+ A lock file is only useful if it can also be *un*-stuck. The owner therefore
4
+ publishes two independent liveness signals, and a would-be acquirer needs only
5
+ one of them to conclude the owner is gone:
6
+
7
+ * **pid** -- the owner's process id on the owner's host. `kill -9` clears this
8
+ instantly, but it lies when the id has been recycled by an unrelated process.
9
+ * **heartbeat** -- a timestamp the owner refreshes every
10
+ ``heartbeat_interval_s`` and promises to keep fresher than ``heartbeat_ttl_s``.
11
+ A recycled pid cannot forge it, and a hung owner stops producing it.
12
+
13
+ Only a lock that *declared* the heartbeat contract can be reaped by it: a lock
14
+ written by an older build never promised to beat, so its silence proves nothing.
15
+ A heartbeat-expired lock is also re-read after one grace interval before it is
16
+ recovered, so a machine that merely came back from sleep gets to prove it is
17
+ alive instead of having its lock taken.
18
+
19
+ Everything else still fails closed. A lock owned by another host is ambiguous by
20
+ construction -- this process cannot probe that host's process table -- so it
21
+ blocks, and the error says exactly how old the lock is, who holds it, and which
22
+ command clears it.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import errno
28
+ import json
29
+ import os
30
+ import socket
31
+ import threading
32
+ import time
33
+ import uuid
34
+ from collections.abc import Callable
35
+ from contextlib import suppress
36
+ from dataclasses import dataclass
37
+ from datetime import UTC, datetime
38
+ from pathlib import Path
39
+ from typing import Any
40
+
41
+ from .atomic_io import atomic_write_json
42
+ from .branding import env_get
43
+ from .forge import ForgeError, now_iso
44
+
45
+ # 2 added the heartbeat contract (``heartbeat_interval_s``/``heartbeat_ttl_s``).
46
+ # Readers must keep understanding version 1 locks; they simply never qualify for
47
+ # TTL-based recovery.
48
+ LOCK_SCHEMA_VERSION = 2
49
+ _LOCK_FILE_NAME = "active_execution.lock.json"
50
+ _RECOVERY_FILE_NAME = "active_execution.recovering.json"
51
+ _EVENTS_FILE_NAME = "active_execution.events.jsonl"
52
+ _WAITING_FILE_PREFIX = "active_execution.waiting."
53
+ _WAITING_FILE_SUFFIX = ".json"
54
+ _DEFAULT_POLL_INTERVAL_S = 1.0
55
+ _WINDOWS_PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
56
+ _WINDOWS_STILL_ACTIVE = 259
57
+ _WINDOWS_ERROR_INVALID_PARAMETER = 87
58
+
59
+ DEFAULT_HEARTBEAT_INTERVAL_S = 15.0
60
+ # Eight missed beats. Long enough that a busy machine, a slow filesystem or a
61
+ # short suspend never trips it; short enough that a recycled pid unblocks the
62
+ # workspace in a couple of minutes rather than never.
63
+ DEFAULT_HEARTBEAT_TTL_S = 120.0
64
+ _MIN_HEARTBEAT_INTERVAL_S = 0.05
65
+ _MAX_RECOVERY_GRACE_S = 5.0
66
+
67
+ _HEARTBEAT_ENABLED_ENV = "ALYSIS_RUN_LOCK_HEARTBEAT"
68
+ _HEARTBEAT_INTERVAL_ENV = "ALYSIS_RUN_LOCK_HEARTBEAT_INTERVAL_S"
69
+ _HEARTBEAT_TTL_ENV = "ALYSIS_RUN_LOCK_TTL_S"
70
+
71
+ STALENESS_ACTIVE = "active"
72
+ STALENESS_STALE = "stale"
73
+ STALENESS_AMBIGUOUS = "ambiguous"
74
+
75
+ STALE_KIND_DEAD_PID = "dead_pid"
76
+ STALE_KIND_HEARTBEAT_EXPIRED = "heartbeat_expired"
77
+
78
+ UNLOCK_COMMAND = "alysis forge unlock"
79
+
80
+
81
+ class RunMutationConflictError(ForgeError):
82
+ def __init__(
83
+ self,
84
+ message: str,
85
+ *,
86
+ reason_code: str = "active_workspace_execution",
87
+ metadata: dict[str, Any] | None = None,
88
+ diagnostic: str | None = None,
89
+ ) -> None:
90
+ super().__init__(message)
91
+ self.reason_code = reason_code
92
+ self.metadata = metadata
93
+ self.diagnostic = diagnostic or message
94
+
95
+
96
+ class _HeartbeatWorker:
97
+ """Refreshes one lock's heartbeat on a daemon thread until it is stopped.
98
+
99
+ A lock whose owner stops beating gets reaped by its own TTL, so the one thing
100
+ this must never do is give up. A single refresh can fail for reasons that have
101
+ nothing to do with the owner's health -- on Windows an ``os.replace`` races a
102
+ concurrent reader of the same lock file and raises ``PermissionError`` -- and
103
+ treating that as fatal would silently hand the workspace to the next acquirer
104
+ while this process is still mutating it. So every failure is counted and
105
+ retried on the next tick, and only :meth:`stop` ends the loop.
106
+
107
+ Failures are never raised either: a heartbeat that cannot be written must not
108
+ take down the execution it exists to protect.
109
+ """
110
+
111
+ def __init__(self, *, refresh: Callable[[], None], interval_s: float) -> None:
112
+ self._refresh = refresh
113
+ self._interval_s = max(_MIN_HEARTBEAT_INTERVAL_S, float(interval_s))
114
+ self._stop = threading.Event()
115
+ self._consecutive_failures = 0
116
+ self._thread = threading.Thread(
117
+ target=self._loop,
118
+ name="alysis-run-lock-heartbeat",
119
+ daemon=True,
120
+ )
121
+
122
+ @property
123
+ def consecutive_failures(self) -> int:
124
+ return self._consecutive_failures
125
+
126
+ def start(self) -> None:
127
+ self._thread.start()
128
+
129
+ def stop(self) -> None:
130
+ self._stop.set()
131
+
132
+ def _loop(self) -> None:
133
+ while not self._stop.wait(self._interval_s):
134
+ try:
135
+ self._refresh()
136
+ except Exception: # noqa: BLE001 - see class docstring
137
+ self._consecutive_failures += 1
138
+ continue
139
+ self._consecutive_failures = 0
140
+
141
+
142
+ @dataclass(frozen=True)
143
+ class RunMutationGuard:
144
+ run_id: str
145
+ mode: str
146
+ run_dir: Path
147
+ workspace_root: Path
148
+ owner_token: str
149
+ lock_path: Path
150
+ recovery_path: Path
151
+ acquired_after_wait: bool = False
152
+ wait_started_at: str | None = None
153
+ wait_finished_at: str | None = None
154
+ wait_record_path: Path | None = None
155
+ heartbeat: _HeartbeatWorker | None = None
156
+
157
+ def __enter__(self) -> RunMutationGuard:
158
+ return self
159
+
160
+ def __exit__(self, exc_type: object, exc: object, tb: object) -> None:
161
+ _ = exc_type
162
+ _ = exc
163
+ _ = tb
164
+ self.release()
165
+
166
+ def __del__(self) -> None:
167
+ with suppress(Exception):
168
+ self.release()
169
+
170
+ def release(self) -> None:
171
+ if self.heartbeat is not None:
172
+ self.heartbeat.stop()
173
+ current = _load_metadata(self.lock_path)
174
+ if current is None:
175
+ return
176
+ if str(current.get("owner_token") or "").strip() != self.owner_token:
177
+ return
178
+ with suppress(FileNotFoundError):
179
+ self.lock_path.unlink()
180
+ if self.wait_record_path is not None:
181
+ with suppress(FileNotFoundError):
182
+ self.wait_record_path.unlink()
183
+ current_recovery = _load_metadata(self.recovery_path)
184
+ if current_recovery is None:
185
+ return
186
+ if str(current_recovery.get("owner_token") or "").strip() != self.owner_token:
187
+ return
188
+ with suppress(FileNotFoundError):
189
+ self.recovery_path.unlink()
190
+
191
+ def refresh_heartbeat(self) -> None:
192
+ current = _load_metadata(self.lock_path)
193
+ if current is None:
194
+ return
195
+ if str(current.get("owner_token") or "").strip() != self.owner_token:
196
+ return
197
+ current["last_heartbeat_at"] = now_iso()
198
+ atomic_write_json(self.lock_path, current)
199
+
200
+
201
+ def acquire_run_mutation_guard(
202
+ *,
203
+ run_id: str,
204
+ mode: str,
205
+ run_dir: Path,
206
+ workspace_root: Path,
207
+ wait: bool = False,
208
+ wait_timeout_s: float | None = None,
209
+ poll_interval_s: float = _DEFAULT_POLL_INTERVAL_S,
210
+ on_wait: Callable[[dict[str, Any]], None] | None = None,
211
+ owner_session_id: str | None = None,
212
+ ) -> RunMutationGuard:
213
+ run_dir.mkdir(parents=True, exist_ok=True)
214
+ lock_path = run_dir / _LOCK_FILE_NAME
215
+ recovery_path = run_dir / _RECOVERY_FILE_NAME
216
+ events_path = run_dir / _EVENTS_FILE_NAME
217
+ owner_token = uuid.uuid4().hex
218
+ owner_id = _owner_id(owner_token=owner_token, owner_session_id=owner_session_id)
219
+ wait_started_monotonic = time.monotonic()
220
+ wait_started_at: str | None = None
221
+ wait_finished_at: str | None = None
222
+ wait_record_path: Path | None = None
223
+ acquired_after_wait = False
224
+ wait_notice_emitted = False
225
+
226
+ while True:
227
+ _clear_stale_recovery_claim(
228
+ recovery_path=recovery_path,
229
+ run_id=run_id,
230
+ mode=mode,
231
+ )
232
+ metadata = _build_metadata(
233
+ run_id=run_id,
234
+ mode=mode,
235
+ workspace_root=workspace_root,
236
+ run_dir=run_dir,
237
+ owner_token=owner_token,
238
+ owner_id=owner_id,
239
+ owner_session_id=owner_session_id,
240
+ kind="lock",
241
+ )
242
+ metadata_text = _metadata_text(metadata)
243
+ try:
244
+ _write_exclusive(lock_path, metadata_text)
245
+ except FileExistsError:
246
+ existing_metadata, existing_text = _load_metadata_with_text(lock_path)
247
+ if existing_metadata is None or existing_text is None:
248
+ raise _conflict_error(
249
+ run_id=run_id,
250
+ mode=mode,
251
+ metadata=None,
252
+ note="the active run lock exists but its metadata is unreadable, so recovery is blocked",
253
+ ) from None
254
+ staleness = assess_lock_staleness(existing_metadata)
255
+ stale_reason = staleness.reason if staleness.recoverable else None
256
+ if stale_reason is None:
257
+ conflict = _conflict_error(
258
+ run_id=run_id,
259
+ mode=mode,
260
+ metadata=existing_metadata,
261
+ )
262
+ if not wait:
263
+ raise conflict from None
264
+ now_monotonic = time.monotonic()
265
+ if wait_timeout_s is not None and now_monotonic - wait_started_monotonic >= max(
266
+ 0.0, wait_timeout_s
267
+ ):
268
+ if wait_record_path is not None:
269
+ _append_event(
270
+ events_path,
271
+ {
272
+ "schema_version": LOCK_SCHEMA_VERSION,
273
+ "event": "queued_wait_timed_out",
274
+ "reason_code": "active_execution_wait_timeout",
275
+ "run_id": run_id,
276
+ "mode": mode,
277
+ "owner_id": owner_id,
278
+ "workspace_root": _safe_resolved_path(workspace_root),
279
+ "workspace_identity": normalize_workspace_identity_path(
280
+ workspace_root
281
+ ),
282
+ "run_dir": _safe_resolved_path(run_dir),
283
+ "wait_started_at": wait_started_at,
284
+ },
285
+ )
286
+ with suppress(FileNotFoundError):
287
+ wait_record_path.unlink()
288
+ raise _conflict_error(
289
+ run_id=run_id,
290
+ mode=mode,
291
+ metadata=existing_metadata,
292
+ note=(
293
+ "queued execution timed out waiting for the active mutation guard "
294
+ "to finish"
295
+ ),
296
+ reason_code="active_execution_wait_timeout",
297
+ ) from None
298
+ if wait_started_at is None:
299
+ wait_started_at = now_iso()
300
+ acquired_after_wait = True
301
+ wait_record_path = run_dir / (
302
+ f"{_WAITING_FILE_PREFIX}{owner_token}{_WAITING_FILE_SUFFIX}"
303
+ )
304
+ wait_payload = _build_wait_metadata(
305
+ run_id=run_id,
306
+ mode=mode,
307
+ workspace_root=workspace_root,
308
+ run_dir=run_dir,
309
+ owner_token=owner_token,
310
+ owner_id=owner_id,
311
+ owner_session_id=owner_session_id,
312
+ blocked_by=existing_metadata,
313
+ started_at=wait_started_at,
314
+ diagnostic=conflict.diagnostic,
315
+ )
316
+ atomic_write_json(wait_record_path, wait_payload)
317
+ _append_event(events_path, {**wait_payload, "event": "queued_wait_started"})
318
+ if on_wait is not None and not wait_notice_emitted:
319
+ on_wait(
320
+ {
321
+ "reason_code": conflict.reason_code,
322
+ "diagnostic": conflict.diagnostic,
323
+ "blocked_by": _public_lock_metadata(existing_metadata),
324
+ "run_id": run_id,
325
+ "mode": mode,
326
+ "wait_started_at": wait_started_at,
327
+ }
328
+ )
329
+ wait_notice_emitted = True
330
+ time.sleep(max(0.05, min(float(poll_interval_s), 5.0)))
331
+ continue
332
+ recovery_metadata = _build_metadata(
333
+ run_id=run_id,
334
+ mode=mode,
335
+ workspace_root=workspace_root,
336
+ run_dir=run_dir,
337
+ owner_token=owner_token,
338
+ owner_id=owner_id,
339
+ owner_session_id=owner_session_id,
340
+ kind="recovery",
341
+ recovery_reason=stale_reason,
342
+ )
343
+ try:
344
+ _write_exclusive(recovery_path, _metadata_text(recovery_metadata))
345
+ except FileExistsError:
346
+ _clear_stale_recovery_claim(
347
+ recovery_path=recovery_path,
348
+ run_id=run_id,
349
+ mode=mode,
350
+ )
351
+ raise _conflict_error(
352
+ run_id=run_id,
353
+ mode=mode,
354
+ metadata=existing_metadata,
355
+ note="another execution is already recovering the stale run lock",
356
+ ) from None
357
+ try:
358
+ # A lock that only *looks* expired gets one grace interval to prove
359
+ # otherwise. A machine coming back from suspend refreshes its
360
+ # heartbeat within one interval, and the unchanged-text check below
361
+ # then reads it as active. A dead pid needs no such courtesy.
362
+ if staleness.kind == STALE_KIND_HEARTBEAT_EXPIRED:
363
+ time.sleep(_recovery_grace_s(existing_metadata))
364
+ current_metadata, current_text = _load_metadata_with_text(lock_path)
365
+ if current_metadata is None or current_text is None:
366
+ raise _conflict_error(
367
+ run_id=run_id,
368
+ mode=mode,
369
+ metadata=None,
370
+ note="the active run lock disappeared or became unreadable during recovery",
371
+ )
372
+ if current_text != existing_text:
373
+ raise _conflict_error(
374
+ run_id=run_id,
375
+ mode=mode,
376
+ metadata=current_metadata,
377
+ note="the active run lock changed while recovery was in progress",
378
+ )
379
+ if not assess_lock_staleness(current_metadata).recoverable:
380
+ raise _conflict_error(
381
+ run_id=run_id,
382
+ mode=mode,
383
+ metadata=current_metadata,
384
+ note="the active run lock is no longer definitely stale",
385
+ )
386
+ lock_path.unlink()
387
+ _append_event(
388
+ events_path,
389
+ {
390
+ **recovery_metadata,
391
+ "event": "stale_lock_recovered",
392
+ "recovered_owner": _public_lock_metadata(current_metadata),
393
+ },
394
+ )
395
+ try:
396
+ metadata = _build_metadata(
397
+ run_id=run_id,
398
+ mode=mode,
399
+ workspace_root=workspace_root,
400
+ run_dir=run_dir,
401
+ owner_token=owner_token,
402
+ owner_id=owner_id,
403
+ owner_session_id=owner_session_id,
404
+ kind="lock",
405
+ )
406
+ metadata_text = _metadata_text(metadata)
407
+ _write_exclusive(lock_path, metadata_text)
408
+ except FileExistsError:
409
+ replacement_metadata = _load_metadata(lock_path)
410
+ raise _conflict_error(
411
+ run_id=run_id,
412
+ mode=mode,
413
+ metadata=replacement_metadata,
414
+ note="another execution claimed the run while stale-lock recovery was finalizing",
415
+ ) from None
416
+ finally:
417
+ current_recovery = _load_metadata(recovery_path)
418
+ if (
419
+ current_recovery is not None
420
+ and str(current_recovery.get("owner_token") or "").strip() == owner_token
421
+ ):
422
+ with suppress(FileNotFoundError):
423
+ recovery_path.unlink()
424
+ break
425
+ if acquired_after_wait:
426
+ wait_finished_at = now_iso()
427
+ _append_event(
428
+ events_path,
429
+ {
430
+ "schema_version": LOCK_SCHEMA_VERSION,
431
+ "event": "queued_wait_finished",
432
+ "reason_code": "queued_execution_acquired_lock",
433
+ "run_id": run_id,
434
+ "mode": mode,
435
+ "owner_id": owner_id,
436
+ "workspace_root": _safe_resolved_path(workspace_root),
437
+ "workspace_identity": normalize_workspace_identity_path(workspace_root),
438
+ "run_dir": _safe_resolved_path(run_dir),
439
+ "wait_started_at": wait_started_at,
440
+ "wait_finished_at": wait_finished_at,
441
+ },
442
+ )
443
+ guard = RunMutationGuard(
444
+ run_id=run_id,
445
+ mode=mode,
446
+ run_dir=run_dir,
447
+ workspace_root=workspace_root,
448
+ owner_token=owner_token,
449
+ lock_path=lock_path,
450
+ recovery_path=recovery_path,
451
+ acquired_after_wait=acquired_after_wait,
452
+ wait_started_at=wait_started_at,
453
+ wait_finished_at=wait_finished_at,
454
+ wait_record_path=wait_record_path,
455
+ )
456
+ worker = _start_heartbeat_worker(guard)
457
+ if worker is not None:
458
+ # The guard is frozen so callers cannot repoint it at another lock; the
459
+ # heartbeat is owner state that only exists once the guard does.
460
+ object.__setattr__(guard, "heartbeat", worker)
461
+ return guard
462
+
463
+
464
+ def _start_heartbeat_worker(guard: RunMutationGuard) -> _HeartbeatWorker | None:
465
+ if not heartbeat_enabled():
466
+ return None
467
+ worker = _HeartbeatWorker(
468
+ refresh=guard.refresh_heartbeat,
469
+ interval_s=heartbeat_interval_s(),
470
+ )
471
+ worker.start()
472
+ return worker
473
+
474
+
475
+ def inspect_run_mutation_lock(run_dir: Path) -> dict[str, Any] | None:
476
+ return _load_metadata(run_dir / _LOCK_FILE_NAME)
477
+
478
+
479
+ def run_mutation_lock_path(run_dir: Path) -> Path:
480
+ return run_dir / _LOCK_FILE_NAME
481
+
482
+
483
+ def heartbeat_enabled() -> bool:
484
+ raw = env_get(_HEARTBEAT_ENABLED_ENV)
485
+ if raw is None:
486
+ return True
487
+ return raw.strip().lower() not in {"0", "false", "no", "off"}
488
+
489
+
490
+ def heartbeat_interval_s() -> float:
491
+ return _env_positive_float(_HEARTBEAT_INTERVAL_ENV, DEFAULT_HEARTBEAT_INTERVAL_S)
492
+
493
+
494
+ def heartbeat_ttl_s() -> float:
495
+ ttl = _env_positive_float(_HEARTBEAT_TTL_ENV, DEFAULT_HEARTBEAT_TTL_S)
496
+ # A TTL at or below the beat interval would reap healthy owners between beats.
497
+ return max(ttl, heartbeat_interval_s() * 2.0)
498
+
499
+
500
+ def _env_positive_float(name: str, default: float) -> float:
501
+ raw = env_get(name)
502
+ if raw is None or not raw.strip():
503
+ return default
504
+ try:
505
+ value = float(raw)
506
+ except ValueError:
507
+ return default
508
+ return value if value > 0 else default
509
+
510
+
511
+ @dataclass(frozen=True)
512
+ class LockStaleness:
513
+ """Why one lock file is (or is not) safe to take over.
514
+
515
+ ``verdict`` is the only thing callers should branch on:
516
+
517
+ * ``active`` -- the owner is provably alive. Never recover.
518
+ * ``ambiguous`` -- liveness cannot be established from here (another host, an
519
+ unreadable pid, a lock that never promised a heartbeat). Never recover.
520
+ * ``stale`` -- the owner is provably gone. Safe to recover.
521
+ """
522
+
523
+ verdict: str
524
+ reason: str
525
+ kind: str | None = None
526
+ same_host: bool = False
527
+ pid: int | None = None
528
+ hostname: str | None = None
529
+ owner_id: str | None = None
530
+ mode: str | None = None
531
+ age_s: float | None = None
532
+ heartbeat_age_s: float | None = None
533
+ heartbeat_ttl_s: float | None = None
534
+ process_running: bool | None = None
535
+
536
+ @property
537
+ def recoverable(self) -> bool:
538
+ return self.verdict == STALENESS_STALE
539
+
540
+ def to_json(self) -> dict[str, Any]:
541
+ return {
542
+ "verdict": self.verdict,
543
+ "reason": self.reason,
544
+ "kind": self.kind,
545
+ "same_host": self.same_host,
546
+ "pid": self.pid,
547
+ "hostname": self.hostname,
548
+ "owner_id": self.owner_id,
549
+ "mode": self.mode,
550
+ "age_s": self.age_s,
551
+ "heartbeat_age_s": self.heartbeat_age_s,
552
+ "heartbeat_ttl_s": self.heartbeat_ttl_s,
553
+ "process_running": self.process_running,
554
+ }
555
+
556
+
557
+ def assess_lock_staleness(
558
+ metadata: dict[str, Any] | None,
559
+ *,
560
+ now: datetime | None = None,
561
+ ) -> LockStaleness:
562
+ """Classify a lock file's owner as active, ambiguous, or provably gone."""
563
+ if metadata is None:
564
+ return LockStaleness(
565
+ verdict=STALENESS_AMBIGUOUS,
566
+ reason="lock metadata is missing or unreadable",
567
+ )
568
+ reference = now or datetime.now(UTC)
569
+ hostname = _clean_str(metadata.get("hostname"))
570
+ owner_id = _clean_str(metadata.get("owner_id"))
571
+ mode = _clean_str(metadata.get("mode"))
572
+ pid = _coerce_pid(metadata.get("pid"))
573
+ age_s = _age_seconds(metadata.get("acquired_at"), reference)
574
+ heartbeat_age_s = _age_seconds(metadata.get("last_heartbeat_at"), reference)
575
+ declared_ttl = _coerce_positive_float(metadata.get("heartbeat_ttl_s"))
576
+ same_host = hostname == socket.gethostname()
577
+
578
+ base = {
579
+ "kind": None,
580
+ "same_host": same_host,
581
+ "pid": pid,
582
+ "hostname": hostname,
583
+ "owner_id": owner_id,
584
+ "mode": mode,
585
+ "age_s": age_s,
586
+ "heartbeat_age_s": heartbeat_age_s,
587
+ "heartbeat_ttl_s": declared_ttl,
588
+ }
589
+
590
+ if not same_host:
591
+ return LockStaleness(
592
+ verdict=STALENESS_AMBIGUOUS,
593
+ reason=(
594
+ f"lock is held by another host ({hostname or 'unknown'}); this machine "
595
+ "cannot tell whether that process is still running"
596
+ ),
597
+ **base,
598
+ )
599
+
600
+ process_running = _process_is_running(pid) if pid is not None else None
601
+ base["process_running"] = process_running
602
+
603
+ if process_running is False:
604
+ return LockStaleness(
605
+ verdict=STALENESS_STALE,
606
+ reason="owner process is no longer running",
607
+ **{**base, "kind": STALE_KIND_DEAD_PID},
608
+ )
609
+
610
+ # A pid that still resolves is not proof of life: process ids get recycled, and
611
+ # the new holder of the id knows nothing about this lock. The heartbeat is what
612
+ # separates "same process, still working" from "same number, different process".
613
+ if declared_ttl is not None and heartbeat_age_s is not None and heartbeat_age_s > declared_ttl:
614
+ return LockStaleness(
615
+ verdict=STALENESS_STALE,
616
+ reason=(
617
+ f"heartbeat is {_format_age(heartbeat_age_s)} old, past the "
618
+ f"{_format_age(declared_ttl)} the owner promised to stay within"
619
+ ),
620
+ **{**base, "kind": STALE_KIND_HEARTBEAT_EXPIRED},
621
+ )
622
+
623
+ if pid is None:
624
+ return LockStaleness(
625
+ verdict=STALENESS_AMBIGUOUS,
626
+ reason="lock records no usable owner pid",
627
+ **base,
628
+ )
629
+ if process_running is None:
630
+ return LockStaleness(
631
+ verdict=STALENESS_AMBIGUOUS,
632
+ reason=f"owner pid {pid} could not be probed on this host",
633
+ **base,
634
+ )
635
+ if declared_ttl is None:
636
+ return LockStaleness(
637
+ verdict=STALENESS_ACTIVE,
638
+ reason=(
639
+ f"owner pid {pid} is running and the lock declares no heartbeat "
640
+ "contract, so its age proves nothing"
641
+ ),
642
+ **base,
643
+ )
644
+ return LockStaleness(
645
+ verdict=STALENESS_ACTIVE,
646
+ reason=f"owner pid {pid} is running and its heartbeat is current",
647
+ **base,
648
+ )
649
+
650
+
651
+ def describe_run_mutation_lock(
652
+ run_dir: Path,
653
+ *,
654
+ label: str = "",
655
+ ) -> dict[str, Any] | None:
656
+ """Public, token-free description of one lock plus its staleness verdict."""
657
+ lock_path = run_dir / _LOCK_FILE_NAME
658
+ metadata, raw_text = _load_metadata_with_text(lock_path)
659
+ if metadata is None and raw_text is None:
660
+ return None
661
+ staleness = assess_lock_staleness(metadata)
662
+ return {
663
+ "label": label or run_dir.name,
664
+ "lock_path": _safe_resolved_path(lock_path),
665
+ "run_dir": _safe_resolved_path(run_dir),
666
+ "readable": metadata is not None,
667
+ "lock": _public_lock_metadata(metadata),
668
+ "staleness": staleness.to_json(),
669
+ "recovery_command": _unlock_command(metadata),
670
+ }
671
+
672
+
673
+ def clear_run_mutation_lock(run_dir: Path, *, force: bool = False) -> dict[str, Any]:
674
+ """Delete one lock file when it is provably stale (or when ``force``).
675
+
676
+ Returns the same description :func:`describe_run_mutation_lock` produces, plus
677
+ ``cleared`` and ``forced``. Never raises for a lock it declines to clear --
678
+ refusing is a reportable outcome, not an error.
679
+ """
680
+ description = describe_run_mutation_lock(run_dir)
681
+ if description is None:
682
+ return {
683
+ "label": run_dir.name,
684
+ "lock_path": _safe_resolved_path(run_dir / _LOCK_FILE_NAME),
685
+ "run_dir": _safe_resolved_path(run_dir),
686
+ "present": False,
687
+ "cleared": False,
688
+ "forced": False,
689
+ }
690
+ verdict = str(description["staleness"]["verdict"])
691
+ should_clear = force or verdict == STALENESS_STALE
692
+ cleared = False
693
+ if should_clear:
694
+ with suppress(FileNotFoundError):
695
+ (run_dir / _LOCK_FILE_NAME).unlink()
696
+ cleared = True
697
+ # A recovery claim outlives its lock only when the claiming process also
698
+ # died; leaving it behind would block the next acquirer on a file the user
699
+ # was just told they had cleared.
700
+ with suppress(FileNotFoundError):
701
+ (run_dir / _RECOVERY_FILE_NAME).unlink()
702
+ _append_event(
703
+ run_dir / _EVENTS_FILE_NAME,
704
+ {
705
+ "schema_version": LOCK_SCHEMA_VERSION,
706
+ "event": "lock_cleared_by_operator",
707
+ "reason_code": "forced_unlock"
708
+ if force and verdict != STALENESS_STALE
709
+ else "stale_lock_cleared",
710
+ "forced": bool(force and verdict != STALENESS_STALE),
711
+ "verdict": verdict,
712
+ "cleared_by_pid": os.getpid(),
713
+ "cleared_by_host": socket.gethostname(),
714
+ "cleared_lock": description["lock"],
715
+ },
716
+ )
717
+ return {
718
+ **description,
719
+ "present": True,
720
+ "cleared": cleared,
721
+ "forced": bool(force and verdict != STALENESS_STALE),
722
+ }
723
+
724
+
725
+ def lock_is_live(run_dir: Path) -> bool:
726
+ """True when a lock file exists and its owner is not provably gone.
727
+
728
+ Deliberately conservative in the opposite direction from recovery: an
729
+ ambiguous lock counts as live, because a caller asking "is something running
730
+ here?" must not be told "no" on the strength of a question this host cannot
731
+ answer.
732
+ """
733
+ metadata, raw_text = _load_metadata_with_text(run_dir / _LOCK_FILE_NAME)
734
+ if metadata is None and raw_text is None:
735
+ return False
736
+ return not assess_lock_staleness(metadata).recoverable
737
+
738
+
739
+ def workspace_mutation_run_id(workspace_root: Path | str) -> str:
740
+ return f"workspace:{normalize_workspace_identity_path(workspace_root)}"
741
+
742
+
743
+ def normalize_workspace_identity_path(workspace_root: Path | str) -> str:
744
+ raw = os.fspath(workspace_root).strip().replace("\\", "/")
745
+ while raw.startswith("./"):
746
+ raw = raw[2:]
747
+ raw = raw.rstrip("/") or raw
748
+ if _looks_like_windows_drive_path(raw):
749
+ drive = raw[:2].lower()
750
+ rest = "/".join(part for part in raw[2:].split("/") if part)
751
+ return f"{drive}/{rest}".rstrip("/").casefold()
752
+ if raw.startswith("/mnt/") and len(raw) >= 7 and raw[6:7] == "/":
753
+ drive = raw[5:6].lower()
754
+ tail = "/".join(part for part in raw[7:].split("/") if part)
755
+ return f"{drive}:/{tail}".rstrip("/").casefold()
756
+ try:
757
+ return os.fspath(Path(raw).expanduser().resolve()).replace("\\", "/").rstrip("/")
758
+ except OSError:
759
+ return raw
760
+
761
+
762
+ def write_run_mutation_lock_metadata(path: Path, payload: dict[str, Any]) -> None:
763
+ atomic_write_json(path, payload)
764
+
765
+
766
+ def lock_diagnostic(metadata: dict[str, Any] | None, *, run_id: str, mode: str) -> dict[str, Any]:
767
+ error = _conflict_error(run_id=run_id, mode=mode, metadata=metadata)
768
+ return {
769
+ "reason_code": error.reason_code,
770
+ "diagnostic": error.diagnostic,
771
+ "blocked_by": _public_lock_metadata(metadata),
772
+ "staleness": assess_lock_staleness(metadata).to_json(),
773
+ "recovery_command": _unlock_command(metadata),
774
+ }
775
+
776
+
777
+ def _owner_id(*, owner_token: str, owner_session_id: str | None) -> str:
778
+ session = str(owner_session_id or "").strip()
779
+ if session:
780
+ return session
781
+ return f"{socket.gethostname()}:{os.getpid()}:{owner_token[:12]}"
782
+
783
+
784
+ def _safe_resolved_path(path: Path | str) -> str:
785
+ try:
786
+ return os.fspath(Path(path).expanduser().resolve(strict=False)).replace("\\", "/")
787
+ except (OSError, RuntimeError, ValueError):
788
+ return os.fspath(path).replace("\\", "/")
789
+
790
+
791
+ def _looks_like_windows_drive_path(raw: str) -> bool:
792
+ return len(raw) >= 2 and raw[0].isalpha() and raw[1] == ":"
793
+
794
+
795
+ def _public_lock_metadata(metadata: dict[str, Any] | None) -> dict[str, Any] | None:
796
+ if metadata is None:
797
+ return None
798
+ public_keys = {
799
+ "schema_version",
800
+ "run_id",
801
+ "mode",
802
+ "kind",
803
+ "pid",
804
+ "hostname",
805
+ "acquired_at",
806
+ "started_at",
807
+ "last_heartbeat_at",
808
+ "owner_id",
809
+ "owner_session_id",
810
+ "workspace_root",
811
+ "workspace_identity",
812
+ "run_dir",
813
+ "stale_policy",
814
+ "reason_code",
815
+ "diagnostic",
816
+ "recovery_reason",
817
+ "heartbeat_interval_s",
818
+ "heartbeat_ttl_s",
819
+ }
820
+ return {key: value for key, value in metadata.items() if key in public_keys}
821
+
822
+
823
+ def _append_event(path: Path, payload: dict[str, Any]) -> None:
824
+ path.parent.mkdir(parents=True, exist_ok=True)
825
+ event_payload = dict(payload)
826
+ event_payload.pop("owner_token", None)
827
+ event_payload.setdefault("event_at", now_iso())
828
+ with path.open("a", encoding="utf-8") as handle:
829
+ handle.write(json.dumps(event_payload, sort_keys=True) + "\n")
830
+
831
+
832
+ def _build_metadata(
833
+ *,
834
+ run_id: str,
835
+ mode: str,
836
+ workspace_root: Path,
837
+ run_dir: Path,
838
+ owner_token: str,
839
+ owner_id: str,
840
+ owner_session_id: str | None,
841
+ kind: str,
842
+ recovery_reason: str | None = None,
843
+ ) -> dict[str, Any]:
844
+ acquired_at = now_iso()
845
+ payload: dict[str, Any] = {
846
+ "schema_version": LOCK_SCHEMA_VERSION,
847
+ "run_id": run_id,
848
+ "mode": mode,
849
+ "kind": kind,
850
+ "pid": os.getpid(),
851
+ "hostname": socket.gethostname(),
852
+ "acquired_at": acquired_at,
853
+ "started_at": acquired_at,
854
+ "last_heartbeat_at": acquired_at,
855
+ "owner_id": owner_id,
856
+ "owner_session_id": owner_session_id,
857
+ "owner_token": owner_token,
858
+ "workspace_root": _safe_resolved_path(workspace_root),
859
+ "workspace_identity": normalize_workspace_identity_path(workspace_root),
860
+ "run_dir": _safe_resolved_path(run_dir),
861
+ "heartbeat_interval_s": heartbeat_interval_s(),
862
+ "heartbeat_ttl_s": heartbeat_ttl_s(),
863
+ "stale_policy": (
864
+ "same-host owner with a dead pid or a heartbeat older than heartbeat_ttl_s "
865
+ "is recoverable; other-host and unprobeable locks stay active"
866
+ ),
867
+ "reason_code": "stale_lock_recovery" if kind == "recovery" else "active_execution_lock",
868
+ "diagnostic": (
869
+ "Recovering a definitely stale Forge mutation lock."
870
+ if kind == "recovery"
871
+ else "Forge execution is mutating this workspace/run."
872
+ ),
873
+ }
874
+ if recovery_reason:
875
+ payload["recovery_reason"] = recovery_reason
876
+ return payload
877
+
878
+
879
+ def _build_wait_metadata(
880
+ *,
881
+ run_id: str,
882
+ mode: str,
883
+ workspace_root: Path,
884
+ run_dir: Path,
885
+ owner_token: str,
886
+ owner_id: str,
887
+ owner_session_id: str | None,
888
+ blocked_by: dict[str, Any],
889
+ started_at: str,
890
+ diagnostic: str,
891
+ ) -> dict[str, Any]:
892
+ return {
893
+ "schema_version": LOCK_SCHEMA_VERSION,
894
+ "kind": "queued_wait",
895
+ "reason_code": "blocked_by_active_workspace_execution",
896
+ "run_id": run_id,
897
+ "mode": mode,
898
+ "pid": os.getpid(),
899
+ "hostname": socket.gethostname(),
900
+ "started_at": started_at,
901
+ "last_heartbeat_at": started_at,
902
+ "owner_id": owner_id,
903
+ "owner_session_id": owner_session_id,
904
+ "owner_token": owner_token,
905
+ "workspace_root": _safe_resolved_path(workspace_root),
906
+ "workspace_identity": normalize_workspace_identity_path(workspace_root),
907
+ "run_dir": _safe_resolved_path(run_dir),
908
+ "blocked_by": _public_lock_metadata(blocked_by),
909
+ "stale_policy": "queued wait rechecks the lock and only recovers definitely stale locks",
910
+ "diagnostic": diagnostic,
911
+ }
912
+
913
+
914
+ def _metadata_text(payload: dict[str, Any]) -> str:
915
+ return json.dumps(payload, indent=2, sort_keys=True) + "\n"
916
+
917
+
918
+ def _write_exclusive(path: Path, text: str) -> None:
919
+ flags = os.O_CREAT | os.O_EXCL | os.O_WRONLY
920
+ fd = os.open(path, flags, 0o644)
921
+ try:
922
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
923
+ handle.write(text)
924
+ except Exception:
925
+ with suppress(FileNotFoundError):
926
+ path.unlink()
927
+ raise
928
+
929
+
930
+ def _load_metadata(path: Path) -> dict[str, Any] | None:
931
+ payload, _ = _load_metadata_with_text(path)
932
+ return payload
933
+
934
+
935
+ def _load_metadata_with_text(path: Path) -> tuple[dict[str, Any] | None, str | None]:
936
+ try:
937
+ text = path.read_text(encoding="utf-8")
938
+ except FileNotFoundError:
939
+ return None, None
940
+ except OSError:
941
+ return None, None
942
+ try:
943
+ payload = json.loads(text)
944
+ except json.JSONDecodeError:
945
+ return None, text
946
+ if not isinstance(payload, dict):
947
+ return None, text
948
+ return payload, text
949
+
950
+
951
+ def _clear_stale_recovery_claim(
952
+ *,
953
+ recovery_path: Path,
954
+ run_id: str,
955
+ mode: str,
956
+ ) -> None:
957
+ recovery_metadata = _load_metadata(recovery_path)
958
+ if recovery_metadata is None:
959
+ return
960
+ if assess_lock_staleness(recovery_metadata).recoverable:
961
+ with suppress(FileNotFoundError):
962
+ recovery_path.unlink()
963
+ return
964
+ raise _conflict_error(
965
+ run_id=run_id,
966
+ mode=mode,
967
+ metadata=recovery_metadata,
968
+ note="another execution is already recovering this run",
969
+ )
970
+
971
+
972
+ def _definitely_stale_reason(metadata: dict[str, Any]) -> str | None:
973
+ """Back-compatible thin wrapper over :func:`assess_lock_staleness`."""
974
+ staleness = assess_lock_staleness(metadata)
975
+ return staleness.reason if staleness.recoverable else None
976
+
977
+
978
+ def _clean_str(value: Any) -> str | None:
979
+ text = str(value or "").strip()
980
+ return text or None
981
+
982
+
983
+ def _coerce_pid(raw: Any) -> int | None:
984
+ try:
985
+ pid = int(raw)
986
+ except (TypeError, ValueError):
987
+ return None
988
+ return pid if pid > 0 else None
989
+
990
+
991
+ def _coerce_positive_float(raw: Any) -> float | None:
992
+ try:
993
+ value = float(raw)
994
+ except (TypeError, ValueError):
995
+ return None
996
+ return value if value > 0 else None
997
+
998
+
999
+ def _age_seconds(raw: Any, reference: datetime) -> float | None:
1000
+ text = _clean_str(raw)
1001
+ if text is None:
1002
+ return None
1003
+ try:
1004
+ parsed = datetime.fromisoformat(text)
1005
+ except ValueError:
1006
+ return None
1007
+ if parsed.tzinfo is None:
1008
+ parsed = parsed.replace(tzinfo=UTC)
1009
+ # Clamped at zero: a lock stamped by a host with a skewed clock must not read
1010
+ # as "negative age", which would print as nonsense and compare as fresh.
1011
+ return max(0.0, (reference - parsed).total_seconds())
1012
+
1013
+
1014
+ def _format_age(seconds: float | None) -> str:
1015
+ if seconds is None:
1016
+ return "unknown"
1017
+ if seconds < 90:
1018
+ return f"{seconds:.0f}s"
1019
+ if seconds < 5400:
1020
+ return f"{seconds / 60:.0f}m"
1021
+ if seconds < 172800:
1022
+ return f"{seconds / 3600:.1f}h"
1023
+ return f"{seconds / 86400:.1f}d"
1024
+
1025
+
1026
+ def _recovery_grace_s(metadata: dict[str, Any]) -> float:
1027
+ declared = _coerce_positive_float(metadata.get("heartbeat_interval_s"))
1028
+ interval = declared if declared is not None else heartbeat_interval_s()
1029
+ return max(_MIN_HEARTBEAT_INTERVAL_S, min(interval, _MAX_RECOVERY_GRACE_S))
1030
+
1031
+
1032
+ def _unlock_command(metadata: dict[str, Any] | None) -> str:
1033
+ workspace = _clean_str((metadata or {}).get("workspace_root"))
1034
+ if workspace is None:
1035
+ return f"{UNLOCK_COMMAND} --path <workspace>"
1036
+ return f'{UNLOCK_COMMAND} --path "{workspace}"'
1037
+
1038
+
1039
+ def _process_is_running(pid: int) -> bool | None:
1040
+ if os.name == "nt":
1041
+ return _windows_process_is_running(pid)
1042
+ try:
1043
+ os.kill(pid, 0)
1044
+ except ProcessLookupError:
1045
+ return False
1046
+ except PermissionError:
1047
+ return True
1048
+ except OSError as e:
1049
+ if getattr(e, "errno", None) == errno.ESRCH:
1050
+ return False
1051
+ return None
1052
+ return True
1053
+
1054
+
1055
+ def _windows_process_is_running(pid: int) -> bool | None:
1056
+ try:
1057
+ import ctypes
1058
+ from ctypes import wintypes
1059
+
1060
+ kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
1061
+ kernel32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
1062
+ kernel32.OpenProcess.restype = wintypes.HANDLE
1063
+ kernel32.GetExitCodeProcess.argtypes = [
1064
+ wintypes.HANDLE,
1065
+ ctypes.POINTER(wintypes.DWORD),
1066
+ ]
1067
+ kernel32.GetExitCodeProcess.restype = wintypes.BOOL
1068
+ kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
1069
+ kernel32.CloseHandle.restype = wintypes.BOOL
1070
+
1071
+ handle = kernel32.OpenProcess(
1072
+ _WINDOWS_PROCESS_QUERY_LIMITED_INFORMATION,
1073
+ False,
1074
+ pid,
1075
+ )
1076
+ if not handle:
1077
+ error = ctypes.get_last_error()
1078
+ if error == _WINDOWS_ERROR_INVALID_PARAMETER:
1079
+ return False
1080
+ return None
1081
+ exit_code = wintypes.DWORD()
1082
+ try:
1083
+ if not kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code)):
1084
+ return None
1085
+ return exit_code.value == _WINDOWS_STILL_ACTIVE
1086
+ finally:
1087
+ kernel32.CloseHandle(handle)
1088
+ except Exception:
1089
+ return None
1090
+
1091
+
1092
+ def _conflict_error(
1093
+ *,
1094
+ run_id: str,
1095
+ mode: str,
1096
+ metadata: dict[str, Any] | None,
1097
+ note: str | None = None,
1098
+ reason_code: str = "blocked_by_active_workspace_execution",
1099
+ ) -> RunMutationConflictError:
1100
+ staleness = assess_lock_staleness(metadata)
1101
+ details: list[str] = []
1102
+ if metadata is not None:
1103
+ owner_mode = str(metadata.get("mode") or "").strip()
1104
+ if owner_mode:
1105
+ details.append(f"owner mode={owner_mode}")
1106
+ owner_id = str(metadata.get("owner_id") or "").strip()
1107
+ if owner_id:
1108
+ details.append(f"owner={owner_id}")
1109
+ pid = str(metadata.get("pid") or "").strip()
1110
+ if pid:
1111
+ details.append(f"pid={pid}")
1112
+ hostname = str(metadata.get("hostname") or "").strip()
1113
+ if hostname:
1114
+ details.append(f"host={hostname}")
1115
+ acquired_at = str(metadata.get("acquired_at") or "").strip()
1116
+ if acquired_at:
1117
+ details.append(f"acquired_at={acquired_at}")
1118
+ if staleness.age_s is not None:
1119
+ details.append(f"age={_format_age(staleness.age_s)}")
1120
+ if staleness.heartbeat_age_s is not None:
1121
+ details.append(f"heartbeat_age={_format_age(staleness.heartbeat_age_s)}")
1122
+ detail_suffix = f" ({', '.join(details)})" if details else ""
1123
+ note_suffix = f" {note}." if note else ""
1124
+ target = "workspace" if run_id.startswith("workspace:") else "run"
1125
+ # The verdict is what makes the guidance actionable: "wait" and "inspect it" are
1126
+ # different instructions, and the old message gave both at once for every case.
1127
+ verdict_suffix = (
1128
+ f" Lock verdict: {staleness.verdict} -- {staleness.reason}." if metadata else ""
1129
+ )
1130
+ message = (
1131
+ f"Another Forge execution is already mutating this {target} "
1132
+ f"(run_id={run_id}, requested_mode={mode}){detail_suffix}.{note_suffix}"
1133
+ f"{verdict_suffix} Wait for the active execution to finish, or inspect and clear the "
1134
+ f"lock with: {_unlock_command(metadata)}"
1135
+ )
1136
+ return RunMutationConflictError(
1137
+ message,
1138
+ reason_code=reason_code,
1139
+ metadata=_public_lock_metadata(metadata),
1140
+ diagnostic=message,
1141
+ )