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,293 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from dataclasses import dataclass
5
+ from typing import Any
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class DirectionChange:
10
+ old_terms: tuple[str, ...]
11
+ new_terms: tuple[str, ...]
12
+ reason: str
13
+ raw_text: str
14
+
15
+
16
+ _TERM_TOKEN_RE = re.compile(r"[a-z0-9_]+")
17
+ _CLAUSE_END_RE = r"(?=[;,\n]|(?:\.\s)|\.$|$)"
18
+ _DIRECTION_BOUNDARY_RE = (
19
+ r"(?=(?:\s+entirely|\s+from\b|\s+and\s+use\b|\s+use\b|\s+any\s+more|"
20
+ r"\s+anymore|\s+instead\b|[;,\n]|(?:\.\s)|\.$|$))"
21
+ )
22
+ _STOPWORDS = frozenset(
23
+ {
24
+ "a",
25
+ "an",
26
+ "and",
27
+ "any",
28
+ "anymore",
29
+ "approach",
30
+ "entirely",
31
+ "from",
32
+ "more",
33
+ "old",
34
+ "plan",
35
+ "previous",
36
+ "the",
37
+ "to",
38
+ "use",
39
+ "using",
40
+ "with",
41
+ "work",
42
+ }
43
+ )
44
+ _GENERIC_MATCH_TOKENS = frozenset(
45
+ {
46
+ "config",
47
+ "configuration",
48
+ "file",
49
+ "flag",
50
+ "implementation",
51
+ "setting",
52
+ "settings",
53
+ "task",
54
+ "tasks",
55
+ }
56
+ )
57
+
58
+ _DIRECTION_PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = (
59
+ (
60
+ "replace",
61
+ re.compile(
62
+ rf"\breplace\s+(?:the\s+)?(?P<old>.+?)\s+with\s+(?P<new>.+?){_CLAUSE_END_RE}",
63
+ re.IGNORECASE | re.DOTALL,
64
+ ),
65
+ ),
66
+ (
67
+ "switch",
68
+ re.compile(
69
+ rf"\bswitch\s+from\s+(?:the\s+)?(?P<old>.+?)\s+to\s+(?P<new>.+?){_CLAUSE_END_RE}",
70
+ re.IGNORECASE | re.DOTALL,
71
+ ),
72
+ ),
73
+ (
74
+ "instead_of",
75
+ re.compile(
76
+ rf"\b(?:use|using)\s+(?P<new>.+?)\s+instead\s+of\s+(?:the\s+)?(?P<old>.+?){_CLAUSE_END_RE}",
77
+ re.IGNORECASE | re.DOTALL,
78
+ ),
79
+ ),
80
+ (
81
+ "drop",
82
+ re.compile(
83
+ rf"\b(?:drop|remove|abandon|forget)\s+(?:the\s+)?(?P<old>.+?){_DIRECTION_BOUNDARY_RE}",
84
+ re.IGNORECASE | re.DOTALL,
85
+ ),
86
+ ),
87
+ (
88
+ "do_not_implement",
89
+ re.compile(
90
+ rf"\bdo\s+not\s+(?:implement|use|build|add|create)\s+(?:the\s+)?(?P<old>.+?){_DIRECTION_BOUNDARY_RE}",
91
+ re.IGNORECASE | re.DOTALL,
92
+ ),
93
+ ),
94
+ (
95
+ "no_longer",
96
+ re.compile(
97
+ r"\bno\s+(?P<old>[a-z0-9_ .+\-/]{2,120}?)\s+(?:anymore|any\s+more)\b",
98
+ re.IGNORECASE,
99
+ ),
100
+ ),
101
+ )
102
+ _USE_INSTEAD_RE = re.compile(
103
+ r"\b(?:use|using)\s+(?P<new>.+?)\s+instead\b",
104
+ re.IGNORECASE | re.DOTALL,
105
+ )
106
+ _NEGATED_DROP_PREFIX_RE = re.compile(
107
+ r"(?:^|[\s.;:,])(?:do\s+not|don't|dont|never|must\s+not|should\s+not|without|not)\s+$",
108
+ re.IGNORECASE,
109
+ )
110
+
111
+
112
+ def _dedupe_keep_order(values: list[str]) -> tuple[str, ...]:
113
+ seen: set[str] = set()
114
+ out: list[str] = []
115
+ for value in values:
116
+ text = value.strip()
117
+ key = text.casefold()
118
+ if not text or key in seen:
119
+ continue
120
+ seen.add(key)
121
+ out.append(text)
122
+ return tuple(out)
123
+
124
+
125
+ def _clean_term(value: str) -> str:
126
+ text = re.sub(r"\s+", " ", str(value or "").replace("`", " ").replace('"', " ")).strip()
127
+ text = re.sub(r"^(?:the|a|an)\s+", "", text, flags=re.IGNORECASE)
128
+ text = re.sub(
129
+ r"\s+(?:from\s+the\s+plan|from\s+plan|any\s+more|anymore|entirely|instead)$",
130
+ "",
131
+ text,
132
+ flags=re.IGNORECASE,
133
+ ).strip()
134
+ return text
135
+
136
+
137
+ def _tokens(value: str) -> tuple[str, ...]:
138
+ normalized = str(value or "").casefold().replace("-", " ").replace("/", " ")
139
+ tokens: list[str] = []
140
+ for token in _TERM_TOKEN_RE.findall(normalized):
141
+ if token in _STOPWORDS:
142
+ continue
143
+ tokens.append(token)
144
+ if token == "env":
145
+ tokens.append("environment")
146
+ elif token == "environment":
147
+ tokens.append("env")
148
+ elif token == "var":
149
+ tokens.append("variable")
150
+ elif token == "variable":
151
+ tokens.append("var")
152
+ return _dedupe_keep_order(tokens)
153
+
154
+
155
+ def _term_groups(terms: tuple[str, ...]) -> tuple[tuple[str, ...], ...]:
156
+ groups: list[tuple[str, ...]] = []
157
+ for term in terms:
158
+ tokens = _tokens(term)
159
+ if tokens:
160
+ groups.append(tokens)
161
+ return tuple(groups)
162
+
163
+
164
+ def _group_matches(tokens: set[str], group: tuple[str, ...]) -> bool:
165
+ if not group:
166
+ return False
167
+ rare_tokens = [token for token in group if token not in _GENERIC_MATCH_TOKENS]
168
+ if rare_tokens:
169
+ return any(token in tokens for token in rare_tokens)
170
+ return all(token in tokens for token in group)
171
+
172
+
173
+ def _text_tokens(text: str) -> set[str]:
174
+ return set(_tokens(text))
175
+
176
+
177
+ def _extract_new_terms(text: str) -> tuple[str, ...]:
178
+ found: list[str] = []
179
+ for match in _USE_INSTEAD_RE.finditer(text or ""):
180
+ cleaned = _clean_term(match.group("new"))
181
+ if cleaned:
182
+ found.append(cleaned)
183
+ return _dedupe_keep_order(found)
184
+
185
+
186
+ def detect_direction_change(text: str) -> DirectionChange | None:
187
+ raw_text = str(text or "").strip()
188
+ if not raw_text:
189
+ return None
190
+
191
+ old_terms: list[str] = []
192
+ new_terms: list[str] = []
193
+ reasons: list[str] = []
194
+ for reason, pattern in _DIRECTION_PATTERNS:
195
+ for match in pattern.finditer(raw_text):
196
+ if reason == "drop" and _match_has_negated_drop_prefix(raw_text, match.start()):
197
+ continue
198
+ old = _clean_term(match.groupdict().get("old", ""))
199
+ if not old:
200
+ continue
201
+ old_terms.append(old)
202
+ reasons.append(reason)
203
+ new = _clean_term(match.groupdict().get("new", ""))
204
+ if new:
205
+ new_terms.append(new)
206
+
207
+ new_terms.extend(_extract_new_terms(raw_text))
208
+ deduped_old = _dedupe_keep_order(old_terms)
209
+ if not deduped_old:
210
+ return None
211
+ return DirectionChange(
212
+ old_terms=deduped_old,
213
+ new_terms=_dedupe_keep_order(new_terms),
214
+ reason=", ".join(_dedupe_keep_order(reasons)) or "direction_change",
215
+ raw_text=raw_text,
216
+ )
217
+
218
+
219
+ def _match_has_negated_drop_prefix(text: str, start: int) -> bool:
220
+ prefix = str(text or "")[: max(0, start)]
221
+ return _NEGATED_DROP_PREFIX_RE.search(prefix[-40:]) is not None
222
+
223
+
224
+ def direction_change_to_record(change: DirectionChange) -> dict[str, Any]:
225
+ return {
226
+ "type": "direction_change",
227
+ "reason": change.reason,
228
+ "old_terms": list(change.old_terms),
229
+ "new_terms": list(change.new_terms),
230
+ "user_text": change.raw_text,
231
+ }
232
+
233
+
234
+ def direction_change_from_record(record: Any) -> DirectionChange | None:
235
+ if not isinstance(record, dict):
236
+ return None
237
+ old_terms = _dedupe_keep_order([str(item) for item in record.get("old_terms") or []])
238
+ if not old_terms:
239
+ return None
240
+ new_terms = _dedupe_keep_order([str(item) for item in record.get("new_terms") or []])
241
+ return DirectionChange(
242
+ old_terms=old_terms,
243
+ new_terms=new_terms,
244
+ reason=str(record.get("reason") or "direction_change").strip() or "direction_change",
245
+ raw_text=str(record.get("user_text") or "").strip(),
246
+ )
247
+
248
+
249
+ def text_matches_obsolete_direction(text: str, change: DirectionChange) -> bool:
250
+ tokens = _text_tokens(text)
251
+ if not tokens:
252
+ return False
253
+ old_match = any(_group_matches(tokens, group) for group in _term_groups(change.old_terms))
254
+ if not old_match:
255
+ return False
256
+ new_match = any(_group_matches(tokens, group) for group in _term_groups(change.new_terms))
257
+ return not new_match
258
+
259
+
260
+ def _direction_changes_for_path_filter(
261
+ *,
262
+ latest_user_text: str = "",
263
+ task_text: str = "",
264
+ ) -> tuple[DirectionChange, ...]:
265
+ changes: list[DirectionChange] = []
266
+ for text in (latest_user_text, task_text):
267
+ change = detect_direction_change(text)
268
+ if change is not None and change not in changes:
269
+ changes.append(change)
270
+ return tuple(changes)
271
+
272
+
273
+ def filter_obsolete_direction_paths(
274
+ paths: list[str],
275
+ *,
276
+ latest_user_text: str = "",
277
+ task_text: str = "",
278
+ ) -> tuple[list[str], list[str]]:
279
+ changes = _direction_changes_for_path_filter(
280
+ latest_user_text=latest_user_text,
281
+ task_text=task_text,
282
+ )
283
+ if not changes:
284
+ return paths, []
285
+
286
+ kept: list[str] = []
287
+ dropped: list[str] = []
288
+ for path in paths:
289
+ if any(text_matches_obsolete_direction(path, change) for change in changes):
290
+ dropped.append(path)
291
+ continue
292
+ kept.append(path)
293
+ return kept, dropped
@@ -0,0 +1,306 @@
1
+ """Dispatch timing policy: cancellable waits, promotion thresholds, overhead.
2
+
3
+ This module is deliberately dependency-light -- standard library only, and no
4
+ imports from the rest of the package -- for the same two reasons as
5
+ ``budget_policy``:
6
+
7
+ * it is loaded by ``agent/tools_assembly.py`` on the shell dispatch hot path
8
+ and must not drag anything in;
9
+ * it holds the decision logic that has to be unit-testable in a bare
10
+ interpreter, without executing ``alysis_code/__init__`` (which pulls
11
+ in httpx/pydantic/rich). ``tests/test_dispatch_timing.py`` loads this file
12
+ directly by path.
13
+
14
+ What went wrong in production
15
+ ----------------------------
16
+ Terminal-Bench telemetry showed ``tool_dispatch`` averaging 30.4s over 72
17
+ dispatches on ``compile-compcert`` -- 2,190s of a 3,484s run -- with the
18
+ maximum pinned at 60.08s. That bucket is not generic dispatch overhead:
19
+ ``_deadline_operation_for_tool_name`` special-cases only ``shell_run`` and
20
+ ``shell_background``, so **``shell_wait`` falls through into
21
+ ``tool_dispatch``**. The 60.08s ceiling is ``shell_wait``'s own cap
22
+ (``min(wait_seconds, 60.0)``, schema ``maximum: 60``).
23
+
24
+ Two defects follow from that, and this module addresses both:
25
+
26
+ 1. **A wait in flight could not be preempted.** The terminal manager waits on a
27
+ ``threading.Condition`` that knows nothing about the run budget, so PR2's
28
+ watchdog -- which sets a ``threading.Event`` -- could not interrupt a wait
29
+ already running. A 60s wait armed just before the deadline outlived it.
30
+ :func:`run_cancellable_wait` fixes this by driving the *existing*
31
+ completion-driven wait in short slices and re-reading the cancellation token
32
+ between them, so a budget stop is observed within one slice instead of at
33
+ the end of the full wait. The wait stays completion-driven: each slice
34
+ returns the instant the process emits output or exits, so nothing is made
35
+ slower, and no busy-spinning is introduced.
36
+
37
+ 2. **Dispatch overhead was unmeasurable.** ``shell_tool`` timed the whole
38
+ dispatch, including the workspace walks that mutation detection performs
39
+ before and after every command, so the cost of the machinery could not be
40
+ separated from the cost of the command.
41
+ :class:`DispatchOverheadAccount` does that subtraction, and the result is
42
+ reported under :data:`DISPATCH_OVERHEAD_OPERATION` as its own
43
+ ``duration_observations`` category.
44
+
45
+ Environment lookups go through :data:`os.environ` rather than
46
+ ``branding.env_get`` to keep this module free of package imports; ``env_get``
47
+ is itself just ``os.environ.get``, so the behaviour is identical.
48
+ """
49
+
50
+ from __future__ import annotations
51
+
52
+ import math
53
+ import os
54
+ import time
55
+ from collections.abc import Callable, Mapping
56
+ from dataclasses import dataclass
57
+
58
+ # How long a foreground command may hold a blocking dispatch slot before it is
59
+ # considered "long running". Small on purpose: the point of the threshold is
60
+ # that a build or install stops occupying the agent loop almost immediately.
61
+ DEFAULT_BACKGROUND_PROMOTION_SECONDS = 10.0
62
+
63
+ # Granularity at which a long wait re-reads the cancellation token. Each slice
64
+ # is a real completion-driven wait, not a sleep: it returns early the moment
65
+ # output arrives or the process exits, so the slice size bounds *cancellation
66
+ # latency* only, never how quickly a finished command is noticed.
67
+ DEFAULT_WAIT_SLICE_SECONDS = 0.5
68
+
69
+ BACKGROUND_PROMOTION_SECONDS_ENV = "ALYSIS_SHELL_BACKGROUND_PROMOTION_SECONDS"
70
+ WAIT_SLICE_SECONDS_ENV = "ALYSIS_SHELL_WAIT_SLICE_SECONDS"
71
+
72
+ # ``duration_observations`` category for time spent inside the dispatch
73
+ # machinery, excluding the runtime of the command being dispatched. Kept as one
74
+ # constant so the telemetry key cannot drift from the allowlist in
75
+ # ``crash_diagnostics``.
76
+ DISPATCH_OVERHEAD_OPERATION = "dispatch_overhead_seconds"
77
+
78
+ WAIT_OUTCOME_COMPLETED = "completed"
79
+ WAIT_OUTCOME_TIMED_OUT = "timed_out"
80
+ WAIT_OUTCOME_CANCELLED = "cancelled"
81
+
82
+
83
+ def _env_value(name: str, environ: Mapping[str, str] | None = None) -> str | None:
84
+ source: Mapping[str, str] = os.environ if environ is None else environ
85
+ raw = source.get(name)
86
+ if raw is None and name.startswith("ALYSIS_"):
87
+ raw = source.get("SYLLIPTOR_" + name.removeprefix("ALYSIS_"))
88
+ if raw is None:
89
+ return None
90
+ cleaned = str(raw).strip()
91
+ return cleaned or None
92
+
93
+
94
+ def _positive_float(raw: str | None, fallback: float) -> float:
95
+ """Parse a strictly positive finite float, falling back on anything else.
96
+
97
+ A misconfigured knob must never be able to disable the mechanism it
98
+ configures: every bad value -- unparseable, negative, zero, NaN, inf --
99
+ resolves to the documented default.
100
+ """
101
+ if raw is None:
102
+ return fallback
103
+ try:
104
+ value = float(raw)
105
+ except (TypeError, ValueError):
106
+ return fallback
107
+ if not math.isfinite(value) or value <= 0.0:
108
+ return fallback
109
+ return value
110
+
111
+
112
+ def _finite_or(value: object, fallback: float) -> float:
113
+ """Coerce ``value`` to a finite float, else ``fallback``."""
114
+ try:
115
+ number = float(value) # type: ignore[arg-type]
116
+ except (TypeError, ValueError):
117
+ return fallback
118
+ if not math.isfinite(number):
119
+ return fallback
120
+ return number
121
+
122
+
123
+ def resolve_background_promotion_seconds(environ: Mapping[str, str] | None = None) -> float:
124
+ """Seconds a foreground command may block dispatch before it counts as long.
125
+
126
+ ``ALYSIS_SHELL_BACKGROUND_PROMOTION_SECONDS`` overrides
127
+ :data:`DEFAULT_BACKGROUND_PROMOTION_SECONDS`.
128
+ """
129
+ return _positive_float(
130
+ _env_value(BACKGROUND_PROMOTION_SECONDS_ENV, environ),
131
+ DEFAULT_BACKGROUND_PROMOTION_SECONDS,
132
+ )
133
+
134
+
135
+ def resolve_wait_slice_seconds(environ: Mapping[str, str] | None = None) -> float:
136
+ """Cancellation-check granularity for a long wait, in seconds.
137
+
138
+ ``ALYSIS_SHELL_WAIT_SLICE_SECONDS`` overrides
139
+ :data:`DEFAULT_WAIT_SLICE_SECONDS`.
140
+ """
141
+ return _positive_float(
142
+ _env_value(WAIT_SLICE_SECONDS_ENV, environ),
143
+ DEFAULT_WAIT_SLICE_SECONDS,
144
+ )
145
+
146
+
147
+ def should_promote_to_background(
148
+ elapsed_seconds: float,
149
+ threshold_seconds: float | None = None,
150
+ ) -> bool:
151
+ """True when a command has blocked dispatch for at least the threshold.
152
+
153
+ Boundary is inclusive: a command that has run *exactly* the threshold has
154
+ already held the slot for as long as the policy allows.
155
+ """
156
+ threshold = (
157
+ resolve_background_promotion_seconds()
158
+ if threshold_seconds is None
159
+ else _finite_or(threshold_seconds, DEFAULT_BACKGROUND_PROMOTION_SECONDS)
160
+ )
161
+ elapsed = _finite_or(elapsed_seconds, 0.0)
162
+ if elapsed < 0.0:
163
+ return False
164
+ return elapsed >= max(0.0, threshold)
165
+
166
+
167
+ def clamp_wait_seconds(
168
+ requested_seconds: float,
169
+ *,
170
+ remaining_seconds: float | None = None,
171
+ reserve_seconds: float = 0.0,
172
+ minimum_seconds: float = 0.0,
173
+ ) -> float:
174
+ """Clamp a requested wait to what the deadline can still afford.
175
+
176
+ ``remaining_seconds`` of ``None`` means "no deadline applies", in which case
177
+ the request stands. Otherwise the wait is capped at the remaining time less
178
+ ``reserve_seconds`` (the cleanup reserve), floored at ``minimum_seconds`` so
179
+ a caller can always make one non-blocking probe rather than being told to
180
+ wait for a negative duration.
181
+ """
182
+ requested = max(0.0, _finite_or(requested_seconds, 0.0))
183
+ floor = max(0.0, _finite_or(minimum_seconds, 0.0))
184
+ if remaining_seconds is None:
185
+ return max(requested, floor)
186
+ remaining = _finite_or(remaining_seconds, 0.0)
187
+ reserve = max(0.0, _finite_or(reserve_seconds, 0.0))
188
+ affordable = remaining - reserve
189
+ if affordable <= 0.0:
190
+ return floor
191
+ return max(floor, min(requested, affordable))
192
+
193
+
194
+ def next_wait_slice(remaining_seconds: float, slice_seconds: float | None = None) -> float:
195
+ """Duration of the next wait slice.
196
+
197
+ Slicing exists solely to bound cancellation latency, so a non-positive
198
+ slice size means "do not slice" and the whole remaining wait is taken in
199
+ one step -- which is exactly the pre-fix behaviour.
200
+ """
201
+ remaining = max(0.0, _finite_or(remaining_seconds, 0.0))
202
+ if slice_seconds is None:
203
+ step = resolve_wait_slice_seconds()
204
+ else:
205
+ step = _finite_or(slice_seconds, 0.0)
206
+ if step <= 0.0:
207
+ return remaining
208
+ return min(remaining, step)
209
+
210
+
211
+ @dataclass(frozen=True)
212
+ class WaitResult:
213
+ """Outcome of a sliced, cancellable wait."""
214
+
215
+ outcome: str
216
+ elapsed_seconds: float
217
+ slices: int
218
+
219
+ @property
220
+ def completed(self) -> bool:
221
+ return self.outcome == WAIT_OUTCOME_COMPLETED
222
+
223
+ @property
224
+ def cancelled(self) -> bool:
225
+ return self.outcome == WAIT_OUTCOME_CANCELLED
226
+
227
+ @property
228
+ def timed_out(self) -> bool:
229
+ return self.outcome == WAIT_OUTCOME_TIMED_OUT
230
+
231
+
232
+ def run_cancellable_wait(
233
+ *,
234
+ wait_once: Callable[[float], bool],
235
+ total_seconds: float,
236
+ is_cancelled: Callable[[], bool] | None = None,
237
+ slice_seconds: float | None = None,
238
+ monotonic: Callable[[], float] = time.monotonic,
239
+ ) -> WaitResult:
240
+ """Wait up to ``total_seconds``, re-reading cancellation between slices.
241
+
242
+ ``wait_once`` performs one real completion-driven wait of the given
243
+ duration and returns ``True`` when the condition it waits on is satisfied.
244
+ Because each slice returns early on completion, slicing costs nothing in
245
+ responsiveness to the process; it only bounds how long a cancelled run
246
+ stays parked here.
247
+
248
+ ``wait_once`` is always invoked at least once, even for a zero-length wait,
249
+ so a ``wait_seconds=0`` probe keeps its existing "read what is there right
250
+ now" semantics.
251
+ """
252
+ started = monotonic()
253
+ total = max(0.0, _finite_or(total_seconds, 0.0))
254
+ slices = 0
255
+ # Tracked against the original total rather than recomputed from a deadline,
256
+ # so the first slice is exactly what the caller asked for. That matters for
257
+ # the unsliced path: it must hand the wait primitive the identical timeout
258
+ # it received today, not that value minus the microseconds spent getting
259
+ # here.
260
+ remaining = total
261
+
262
+ def _elapsed() -> float:
263
+ return max(0.0, monotonic() - started)
264
+
265
+ # Cancelled before we ever blocked: report it without touching the process.
266
+ if is_cancelled is not None and is_cancelled():
267
+ return WaitResult(WAIT_OUTCOME_CANCELLED, _elapsed(), slices)
268
+
269
+ while True:
270
+ step = next_wait_slice(remaining, slice_seconds)
271
+ slices += 1
272
+ if wait_once(step):
273
+ return WaitResult(WAIT_OUTCOME_COMPLETED, _elapsed(), slices)
274
+ if is_cancelled is not None and is_cancelled():
275
+ return WaitResult(WAIT_OUTCOME_CANCELLED, _elapsed(), slices)
276
+ remaining = total - _elapsed()
277
+ if remaining <= 0.0:
278
+ return WaitResult(WAIT_OUTCOME_TIMED_OUT, _elapsed(), slices)
279
+
280
+
281
+ @dataclass(frozen=True)
282
+ class DispatchOverheadAccount:
283
+ """Split a dispatch into the command's own runtime and the machinery around it.
284
+
285
+ ``overhead_seconds`` is what the next campaign should watch: it excludes the
286
+ command, so it stays flat when a build legitimately takes twenty minutes and
287
+ rises only when the dispatch path itself is doing expensive work (the
288
+ pre/post workspace walks for mutation detection, evidence classification).
289
+ """
290
+
291
+ total_seconds: float
292
+ command_seconds: float
293
+
294
+ @property
295
+ def overhead_seconds(self) -> float:
296
+ # Clamped at zero: a command clock read slightly after the dispatch
297
+ # clock must never report negative overhead.
298
+ return max(0.0, self.total_seconds - self.command_seconds)
299
+
300
+ @classmethod
301
+ def from_totals(cls, total_seconds: float, command_seconds: float) -> DispatchOverheadAccount:
302
+ total = max(0.0, _finite_or(total_seconds, 0.0))
303
+ command = max(0.0, _finite_or(command_seconds, 0.0))
304
+ # A command cannot have run longer than the dispatch that contains it;
305
+ # if the clocks disagree, trust the enclosing measurement.
306
+ return cls(total_seconds=total, command_seconds=min(command, total))