android-midscene-automation 0.2.4 → 1.1.1

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 (525) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +159 -29
  3. package/artemis-main/.dockerignore +15 -0
  4. package/artemis-main/.env.example +34 -0
  5. package/artemis-main/CONTRIBUTING.md +35 -0
  6. package/artemis-main/Dockerfile +68 -0
  7. package/artemis-main/LICENSE +202 -0
  8. package/artemis-main/Makefile +115 -0
  9. package/artemis-main/README.md +322 -0
  10. package/artemis-main/README_CN.md +320 -0
  11. package/artemis-main/apps/__init__.py +15 -0
  12. package/artemis-main/apps/admin_console/__init__.py +19 -0
  13. package/artemis-main/apps/admin_console/core/__init__.py +13 -0
  14. package/artemis-main/apps/admin_console/core/config.py +43 -0
  15. package/artemis-main/apps/admin_console/core/security.py +178 -0
  16. package/artemis-main/apps/admin_console/core/state.py +243 -0
  17. package/artemis-main/apps/admin_console/database/__init__.py +13 -0
  18. package/artemis-main/apps/admin_console/database/connection.py +60 -0
  19. package/artemis-main/apps/admin_console/database/repositories/__init__.py +13 -0
  20. package/artemis-main/apps/admin_console/database/repositories/session_repository.py +564 -0
  21. package/artemis-main/apps/admin_console/database/repositories/step_repository.py +512 -0
  22. package/artemis-main/apps/admin_console/database/repositories/trace_repository.py +133 -0
  23. package/artemis-main/apps/admin_console/index.html +3787 -0
  24. package/artemis-main/apps/admin_console/replay_manager.py +2585 -0
  25. package/artemis-main/apps/admin_console/routers/__init__.py +13 -0
  26. package/artemis-main/apps/admin_console/routers/media.py +198 -0
  27. package/artemis-main/apps/admin_console/routers/replay.py +91 -0
  28. package/artemis-main/apps/admin_console/routers/sessions.py +239 -0
  29. package/artemis-main/apps/admin_console/routers/steps.py +109 -0
  30. package/artemis-main/apps/admin_console/routers/stream.py +56 -0
  31. package/artemis-main/apps/admin_console/routers/system.py +761 -0
  32. package/artemis-main/apps/admin_console/routers/tasks.py +517 -0
  33. package/artemis-main/apps/admin_console/schemas/__init__.py +13 -0
  34. package/artemis-main/apps/admin_console/schemas/task_schema.py +47 -0
  35. package/artemis-main/apps/admin_console/server.py +508 -0
  36. package/artemis-main/apps/admin_console/services/__init__.py +13 -0
  37. package/artemis-main/apps/admin_console/services/device_stream_service.py +137 -0
  38. package/artemis-main/apps/admin_console/services/ipc_service.py +262 -0
  39. package/artemis-main/apps/admin_console/services/media_service.py +623 -0
  40. package/artemis-main/apps/admin_console/services/model_service.py +148 -0
  41. package/artemis-main/apps/admin_console/services/task_preset_catalog.py +514 -0
  42. package/artemis-main/apps/admin_console/services/task_queue_service.py +1719 -0
  43. package/artemis-main/apps/admin_console/services/worker_process_io.py +144 -0
  44. package/artemis-main/apps/showcase_ui/README.md +31 -0
  45. package/artemis-main/apps/showcase_ui/angular.json +132 -0
  46. package/artemis-main/apps/showcase_ui/package-lock.json +9472 -0
  47. package/artemis-main/apps/showcase_ui/package.json +39 -0
  48. package/artemis-main/apps/showcase_ui/proxy.conf.json +23 -0
  49. package/artemis-main/apps/showcase_ui/public/favicon.ico +0 -0
  50. package/artemis-main/apps/showcase_ui/public/logo.png +0 -0
  51. package/artemis-main/apps/showcase_ui/public/logo.svg +56 -0
  52. package/artemis-main/apps/showcase_ui/src/app/app.component.html +24 -0
  53. package/artemis-main/apps/showcase_ui/src/app/app.component.scss +49 -0
  54. package/artemis-main/apps/showcase_ui/src/app/app.component.spec.ts +42 -0
  55. package/artemis-main/apps/showcase_ui/src/app/app.component.ts +32 -0
  56. package/artemis-main/apps/showcase_ui/src/app/app.config.ts +29 -0
  57. package/artemis-main/apps/showcase_ui/src/app/app.routes.ts +27 -0
  58. package/artemis-main/apps/showcase_ui/src/app/components/agent-stream/agent-stream-startup.spec.ts +108 -0
  59. package/artemis-main/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.html +1436 -0
  60. package/artemis-main/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.scss +3973 -0
  61. package/artemis-main/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.ts +2235 -0
  62. package/artemis-main/apps/showcase_ui/src/app/components/chat-interface/chat-interface.component.html +380 -0
  63. package/artemis-main/apps/showcase_ui/src/app/components/chat-interface/chat-interface.component.scss +1171 -0
  64. package/artemis-main/apps/showcase_ui/src/app/components/chat-interface/chat-interface.component.ts +270 -0
  65. package/artemis-main/apps/showcase_ui/src/app/components/floating-video-player/floating-video-player.component.html +433 -0
  66. package/artemis-main/apps/showcase_ui/src/app/components/floating-video-player/floating-video-player.component.scss +987 -0
  67. package/artemis-main/apps/showcase_ui/src/app/components/floating-video-player/floating-video-player.component.ts +632 -0
  68. package/artemis-main/apps/showcase_ui/src/app/components/legacy-agent-stream/legacy-agent-stream.component.html +130 -0
  69. package/artemis-main/apps/showcase_ui/src/app/components/legacy-agent-stream/legacy-agent-stream.component.scss +406 -0
  70. package/artemis-main/apps/showcase_ui/src/app/components/legacy-agent-stream/legacy-agent-stream.component.ts +74 -0
  71. package/artemis-main/apps/showcase_ui/src/app/components/nav-switcher/nav-switcher.component.scss +115 -0
  72. package/artemis-main/apps/showcase_ui/src/app/components/nav-switcher/nav-switcher.component.ts +52 -0
  73. package/artemis-main/apps/showcase_ui/src/app/core/data/smart-tasks.data.ts +334 -0
  74. package/artemis-main/apps/showcase_ui/src/app/core/i18n/language.service.spec.ts +55 -0
  75. package/artemis-main/apps/showcase_ui/src/app/core/i18n/language.service.ts +61 -0
  76. package/artemis-main/apps/showcase_ui/src/app/core/i18n/translate.pipe.ts +11 -0
  77. package/artemis-main/apps/showcase_ui/src/app/core/i18n/zh-cn.ts +651 -0
  78. package/artemis-main/apps/showcase_ui/src/app/core/models/markdown.model.ts +43 -0
  79. package/artemis-main/apps/showcase_ui/src/app/core/models/pro-tuning.model.spec.ts +47 -0
  80. package/artemis-main/apps/showcase_ui/src/app/core/models/pro-tuning.model.ts +165 -0
  81. package/artemis-main/apps/showcase_ui/src/app/core/models/session.model.ts +85 -0
  82. package/artemis-main/apps/showcase_ui/src/app/core/models/stream.model.ts +246 -0
  83. package/artemis-main/apps/showcase_ui/src/app/core/models/system.model.ts +118 -0
  84. package/artemis-main/apps/showcase_ui/src/app/core/services/task-recommendation.service.ts +111 -0
  85. package/artemis-main/apps/showcase_ui/src/app/pages/home/home.component.html +2130 -0
  86. package/artemis-main/apps/showcase_ui/src/app/pages/home/home.component.scss +5204 -0
  87. package/artemis-main/apps/showcase_ui/src/app/pages/home/home.component.ts +1189 -0
  88. package/artemis-main/apps/showcase_ui/src/app/pages/legacy-workspace/legacy-workspace.component.html +39 -0
  89. package/artemis-main/apps/showcase_ui/src/app/pages/legacy-workspace/legacy-workspace.component.scss +151 -0
  90. package/artemis-main/apps/showcase_ui/src/app/pages/legacy-workspace/legacy-workspace.component.ts +58 -0
  91. package/artemis-main/apps/showcase_ui/src/app/pages/workspace/workspace.component.html +201 -0
  92. package/artemis-main/apps/showcase_ui/src/app/pages/workspace/workspace.component.scss +886 -0
  93. package/artemis-main/apps/showcase_ui/src/app/pages/workspace/workspace.component.ts +324 -0
  94. package/artemis-main/apps/showcase_ui/src/app/services/agent.service.spec.ts +418 -0
  95. package/artemis-main/apps/showcase_ui/src/app/services/agent.service.ts +2012 -0
  96. package/artemis-main/apps/showcase_ui/src/app/services/system.service.spec.ts +173 -0
  97. package/artemis-main/apps/showcase_ui/src/app/services/system.service.ts +620 -0
  98. package/artemis-main/apps/showcase_ui/src/app/utils/action-formatter.util.spec.ts +513 -0
  99. package/artemis-main/apps/showcase_ui/src/app/utils/action-formatter.util.ts +1015 -0
  100. package/artemis-main/apps/showcase_ui/src/app/utils/image-overlay.util.spec.ts +85 -0
  101. package/artemis-main/apps/showcase_ui/src/app/utils/image-overlay.util.ts +662 -0
  102. package/artemis-main/apps/showcase_ui/src/app/utils/markdown-parser.util.spec.ts +70 -0
  103. package/artemis-main/apps/showcase_ui/src/app/utils/markdown-parser.util.ts +383 -0
  104. package/artemis-main/apps/showcase_ui/src/app/utils/recording-timeline.util.spec.ts +107 -0
  105. package/artemis-main/apps/showcase_ui/src/app/utils/recording-timeline.util.ts +123 -0
  106. package/artemis-main/apps/showcase_ui/src/app/utils/run-info.util.spec.ts +92 -0
  107. package/artemis-main/apps/showcase_ui/src/app/utils/run-info.util.ts +116 -0
  108. package/artemis-main/apps/showcase_ui/src/app/utils/stream-aggregator.util.spec.ts +640 -0
  109. package/artemis-main/apps/showcase_ui/src/app/utils/stream-aggregator.util.ts +1021 -0
  110. package/artemis-main/apps/showcase_ui/src/app/utils/tool-formatter.util.spec.ts +375 -0
  111. package/artemis-main/apps/showcase_ui/src/app/utils/tool-formatter.util.ts +1419 -0
  112. package/artemis-main/apps/showcase_ui/src/index.html +37 -0
  113. package/artemis-main/apps/showcase_ui/src/main.ts +22 -0
  114. package/artemis-main/apps/showcase_ui/src/styles.scss +60 -0
  115. package/artemis-main/apps/showcase_ui/tsconfig.app.json +23 -0
  116. package/artemis-main/apps/showcase_ui/tsconfig.json +27 -0
  117. package/artemis-main/apps/showcase_ui/tsconfig.spec.json +23 -0
  118. package/artemis-main/artemis/__init__.py +44 -0
  119. package/artemis-main/artemis/__main__.py +18 -0
  120. package/artemis-main/artemis/_legacy_imports.py +94 -0
  121. package/artemis-main/artemis/_version.py +30 -0
  122. package/artemis-main/artemis/agents/__init__.py +17 -0
  123. package/artemis-main/artemis/agents/checker/checker.json +9 -0
  124. package/artemis-main/artemis/agents/checker/checker.py +859 -0
  125. package/artemis-main/artemis/agents/diagnoser/diagnoser.json +23 -0
  126. package/artemis-main/artemis/agents/diagnoser/diagnoser.py +608 -0
  127. package/artemis-main/artemis/agents/explorer/constants.py +52 -0
  128. package/artemis-main/artemis/agents/explorer/explorer.json +57 -0
  129. package/artemis-main/artemis/agents/explorer/explorer.py +473 -0
  130. package/artemis-main/artemis/agents/explorer/geometry.py +73 -0
  131. package/artemis-main/artemis/agents/explorer/native_runner.py +878 -0
  132. package/artemis-main/artemis/agents/explorer/perception_tools.py +632 -0
  133. package/artemis-main/artemis/agents/explorer/run_setup.py +403 -0
  134. package/artemis-main/artemis/agents/explorer/screen_index.py +266 -0
  135. package/artemis-main/artemis/agents/explorer/tiers.py +110 -0
  136. package/artemis-main/artemis/agents/explorer/tool_declarations.py +394 -0
  137. package/artemis-main/artemis/agents/explorer/universal_runner.py +317 -0
  138. package/artemis-main/artemis/agents/flash/context_compressor.py +452 -0
  139. package/artemis-main/artemis/agents/flash/flash_runner.md +51 -0
  140. package/artemis-main/artemis/agents/flash/flash_summarizer.md +53 -0
  141. package/artemis-main/artemis/agents/flash/flash_summarizer_single.md +54 -0
  142. package/artemis-main/artemis/agents/flash/runner.py +1167 -0
  143. package/artemis-main/artemis/agents/flash/summarizer.py +511 -0
  144. package/artemis-main/artemis/agents/history_analyzer/history_analyzer.md +8 -0
  145. package/artemis-main/artemis/agents/history_analyzer/history_analyzer.py +182 -0
  146. package/artemis-main/artemis/agents/image_processor/image_processor.md +74 -0
  147. package/artemis-main/artemis/agents/image_processor/image_processor.py +322 -0
  148. package/artemis-main/artemis/agents/log_analyzer/log_analyzer.md +15 -0
  149. package/artemis-main/artemis/agents/log_analyzer/log_analyzer.py +403 -0
  150. package/artemis-main/artemis/agents/log_analyzer/log_reader_sub_agent.md +8 -0
  151. package/artemis-main/artemis/agents/log_analyzer/output_analyzer.md +11 -0
  152. package/artemis-main/artemis/agents/log_analyzer/output_analyzer.py +163 -0
  153. package/artemis-main/artemis/agents/object_detector/object_detector.json +9 -0
  154. package/artemis-main/artemis/agents/object_detector/object_detector.py +211 -0
  155. package/artemis-main/artemis/agents/operator/operator.json +3 -0
  156. package/artemis-main/artemis/agents/operator/operator.py +1394 -0
  157. package/artemis-main/artemis/agents/operator/prompts.py +1064 -0
  158. package/artemis-main/artemis/agents/outputter/outputter.md +36 -0
  159. package/artemis-main/artemis/agents/outputter/outputter.py +552 -0
  160. package/artemis-main/artemis/agents/planner/planner.json +32 -0
  161. package/artemis-main/artemis/agents/planner/planner.py +436 -0
  162. package/artemis-main/artemis/agents/prompt_assembly.py +79 -0
  163. package/artemis-main/artemis/agents/summarizer/summarizer.py +190 -0
  164. package/artemis-main/artemis/agents/validator/action_execution.py +98 -0
  165. package/artemis-main/artemis/agents/validator/categories.py +33 -0
  166. package/artemis-main/artemis/agents/validator/execution_loop.py +488 -0
  167. package/artemis-main/artemis/agents/validator/incidents.py +120 -0
  168. package/artemis-main/artemis/agents/validator/pixel_safety_net.md +15 -0
  169. package/artemis-main/artemis/agents/validator/precondition_pixel.py +320 -0
  170. package/artemis-main/artemis/agents/validator/precondition_xml.py +672 -0
  171. package/artemis-main/artemis/agents/validator/tool_declarations.py +332 -0
  172. package/artemis-main/artemis/agents/validator/validator.py +188 -0
  173. package/artemis-main/artemis/agents/video_analyzer/__init__.py +15 -0
  174. package/artemis-main/artemis/agents/video_analyzer/audio_native.py +543 -0
  175. package/artemis-main/artemis/agents/video_analyzer/blackboard.py +821 -0
  176. package/artemis-main/artemis/agents/video_analyzer/chunk_agentic.py +455 -0
  177. package/artemis-main/artemis/agents/video_analyzer/chunk_conversation.py +491 -0
  178. package/artemis-main/artemis/agents/video_analyzer/chunk_native.py +437 -0
  179. package/artemis-main/artemis/agents/video_analyzer/conflict_resolution.py +137 -0
  180. package/artemis-main/artemis/agents/video_analyzer/gemini_files.py +162 -0
  181. package/artemis-main/artemis/agents/video_analyzer/native_coordinator.py +541 -0
  182. package/artemis-main/artemis/agents/video_analyzer/reliability.py +128 -0
  183. package/artemis-main/artemis/agents/video_analyzer/universal_engine.py +487 -0
  184. package/artemis-main/artemis/agents/video_analyzer/universal_tools.py +323 -0
  185. package/artemis-main/artemis/agents/video_analyzer/video_analyzer.md +51 -0
  186. package/artemis-main/artemis/agents/video_analyzer/video_analyzer.py +875 -0
  187. package/artemis-main/artemis/agents/video_analyzer/video_sub_agent.md +15 -0
  188. package/artemis-main/artemis/agents/video_analyzer/video_sub_agent_audio.md +12 -0
  189. package/artemis-main/artemis/clients/accessibility_client.py +438 -0
  190. package/artemis-main/artemis/clients/screen_client_factory.py +403 -0
  191. package/artemis-main/artemis/config/__init__.py +309 -0
  192. package/artemis-main/artemis/config/agent.py +1098 -0
  193. package/artemis-main/artemis/config/constants.py +151 -0
  194. package/artemis-main/artemis/config/core/__init__.py +24 -0
  195. package/artemis-main/artemis/config/core/engine.py +118 -0
  196. package/artemis-main/artemis/config/core/interpolator.py +78 -0
  197. package/artemis-main/artemis/config/llm.py +267 -0
  198. package/artemis-main/artemis/config/paths.py +244 -0
  199. package/artemis-main/artemis/config/runtime.py +197 -0
  200. package/artemis-main/artemis/config/settings.py +315 -0
  201. package/artemis-main/artemis/constants.py +47 -0
  202. package/artemis-main/artemis/context.py +289 -0
  203. package/artemis-main/artemis/controllers/__init__.py +41 -0
  204. package/artemis-main/artemis/controllers/controller_factory.py +41 -0
  205. package/artemis-main/artemis/controllers/device_controller.py +29 -0
  206. package/artemis-main/artemis/controllers/types.py +37 -0
  207. package/artemis-main/artemis/controllers/unified_controller.py +649 -0
  208. package/artemis-main/artemis/core/__init__.py +21 -0
  209. package/artemis-main/artemis/core/diagnostics/__init__.py +90 -0
  210. package/artemis-main/artemis/core/diagnostics/adb_keys.py +270 -0
  211. package/artemis-main/artemis/core/diagnostics/adb_server_connection.py +330 -0
  212. package/artemis-main/artemis/core/diagnostics/device_smoke.py +364 -0
  213. package/artemis-main/artemis/core/diagnostics/emulator_manager.py +501 -0
  214. package/artemis-main/artemis/core/diagnostics/engine.py +438 -0
  215. package/artemis-main/artemis/core/diagnostics/hierarchy_parity.py +216 -0
  216. package/artemis-main/artemis/core/diagnostics/probes/adb_probe.py +887 -0
  217. package/artemis-main/artemis/core/diagnostics/probes/base.py +45 -0
  218. package/artemis-main/artemis/core/diagnostics/probes/credentials_probe.py +296 -0
  219. package/artemis-main/artemis/core/diagnostics/probes/host_probe.py +427 -0
  220. package/artemis-main/artemis/core/diagnostics/probes/runtime_probe.py +169 -0
  221. package/artemis-main/artemis/core/diagnostics/probes/toolchain_probe.py +139 -0
  222. package/artemis-main/artemis/core/diagnostics/readiness.py +83 -0
  223. package/artemis-main/artemis/core/diagnostics/schema.py +118 -0
  224. package/artemis-main/artemis/core/tool_declaration.py +43 -0
  225. package/artemis-main/artemis/core/tool_failure.py +56 -0
  226. package/artemis-main/artemis/data_engine/context_vars.py +24 -0
  227. package/artemis-main/artemis/data_engine/engine.py +1615 -0
  228. package/artemis-main/artemis/data_engine/history_reader.py +142 -0
  229. package/artemis-main/artemis/data_engine/models.py +131 -0
  230. package/artemis-main/artemis/data_engine/storage.py +1245 -0
  231. package/artemis-main/artemis/data_engine/trace.py +727 -0
  232. package/artemis-main/artemis/drivers/__init__.py +48 -0
  233. package/artemis-main/artemis/drivers/android/adb_driver.py +507 -0
  234. package/artemis-main/artemis/drivers/android/hierarchy.py +64 -0
  235. package/artemis-main/artemis/drivers/android/input_ime.py +120 -0
  236. package/artemis-main/artemis/drivers/android/recorder.py +57 -0
  237. package/artemis-main/artemis/drivers/base.py +290 -0
  238. package/artemis-main/artemis/drivers/cloud/cloud_driver.py +117 -0
  239. package/artemis-main/artemis/drivers/factory.py +80 -0
  240. package/artemis-main/artemis/drivers/mock/mock_driver.py +198 -0
  241. package/artemis-main/artemis/drivers/types.py +67 -0
  242. package/artemis-main/artemis/graph/checkpoints.py +1243 -0
  243. package/artemis-main/artemis/graph/graph.py +1055 -0
  244. package/artemis-main/artemis/graph/perception.py +234 -0
  245. package/artemis-main/artemis/graph/state.py +151 -0
  246. package/artemis-main/artemis/graph/visibility.py +238 -0
  247. package/artemis-main/artemis/interfaces/cli/__init__.py +19 -0
  248. package/artemis-main/artemis/interfaces/cli/commands/__init__.py +15 -0
  249. package/artemis-main/artemis/interfaces/cli/commands/batch.py +278 -0
  250. package/artemis-main/artemis/interfaces/cli/commands/doctor.py +468 -0
  251. package/artemis-main/artemis/interfaces/cli/commands/helper.py +226 -0
  252. package/artemis-main/artemis/interfaces/cli/commands/init.py +193 -0
  253. package/artemis-main/artemis/interfaces/cli/commands/mcp.py +862 -0
  254. package/artemis-main/artemis/interfaces/cli/commands/run.py +463 -0
  255. package/artemis-main/artemis/interfaces/cli/commands/server.py +151 -0
  256. package/artemis-main/artemis/interfaces/cli/commands/server_lifecycle.py +328 -0
  257. package/artemis-main/artemis/interfaces/cli/commands/trace.py +111 -0
  258. package/artemis-main/artemis/interfaces/cli/commands/ui.py +404 -0
  259. package/artemis-main/artemis/interfaces/cli/main.py +109 -0
  260. package/artemis-main/artemis/interfaces/sdk/__init__.py +31 -0
  261. package/artemis-main/artemis/interfaces/sdk/client.py +19 -0
  262. package/artemis-main/artemis/interfaces/sdk/task.py +72 -0
  263. package/artemis-main/artemis/llm/__init__.py +25 -0
  264. package/artemis-main/artemis/llm/fake_model.py +179 -0
  265. package/artemis-main/artemis/llm/google/__init__.py +57 -0
  266. package/artemis-main/artemis/llm/google/provider.py +185 -0
  267. package/artemis-main/artemis/llm/google/usage.py +148 -0
  268. package/artemis-main/artemis/llm/parser.py +32 -0
  269. package/artemis-main/artemis/llm/reliability.py +267 -0
  270. package/artemis-main/artemis/llm/router.py +389 -0
  271. package/artemis-main/artemis/llm/schemas.py +36 -0
  272. package/artemis-main/artemis/llm/structured.py +196 -0
  273. package/artemis-main/artemis/main.py +58 -0
  274. package/artemis-main/artemis/mcp/__init__.py +20 -0
  275. package/artemis-main/artemis/mcp/action_executor.py +760 -0
  276. package/artemis-main/artemis/mcp/action_manifest.py +320 -0
  277. package/artemis-main/artemis/mcp/action_names.py +172 -0
  278. package/artemis-main/artemis/mcp/action_server.py +202 -0
  279. package/artemis-main/artemis/mcp/action_session.py +245 -0
  280. package/artemis-main/artemis/mcp/action_specs.py +868 -0
  281. package/artemis-main/artemis/mcp/action_types.py +86 -0
  282. package/artemis-main/artemis/mcp/actuators/__init__.py +21 -0
  283. package/artemis-main/artemis/mcp/actuators/adb.py +414 -0
  284. package/artemis-main/artemis/mcp/actuators/base.py +113 -0
  285. package/artemis-main/artemis/mcp/actuators/mock.py +70 -0
  286. package/artemis-main/artemis/mcp/adb_server.py +490 -0
  287. package/artemis-main/artemis/mcp/observation.py +133 -0
  288. package/artemis-main/artemis/mcp/xml_search_server.py +297 -0
  289. package/artemis-main/artemis/memory/__init__.py +90 -0
  290. package/artemis-main/artemis/memory/chunking.py +1843 -0
  291. package/artemis-main/artemis/memory/context_policy.py +226 -0
  292. package/artemis-main/artemis/memory/step_capsule.md +73 -0
  293. package/artemis-main/artemis/memory/step_memory.py +281 -0
  294. package/artemis-main/artemis/memory/transcript.py +798 -0
  295. package/artemis-main/artemis/platform/__init__.py +61 -0
  296. package/artemis-main/artemis/platform/base.py +104 -0
  297. package/artemis-main/artemis/platform/darwin.py +184 -0
  298. package/artemis-main/artemis/platform/linux.py +207 -0
  299. package/artemis-main/artemis/platform/windows.py +193 -0
  300. package/artemis-main/artemis/resources/__init__.py +46 -0
  301. package/artemis-main/artemis/runtime/__init__.py +108 -0
  302. package/artemis-main/artemis/runtime/adb_endpoint.py +173 -0
  303. package/artemis-main/artemis/runtime/awake_lease.py +283 -0
  304. package/artemis-main/artemis/runtime/awake_service.py +343 -0
  305. package/artemis-main/artemis/runtime/cancel_requests.py +254 -0
  306. package/artemis-main/artemis/runtime/daemon_client.py +422 -0
  307. package/artemis-main/artemis/runtime/device_lock.py +1013 -0
  308. package/artemis-main/artemis/runtime/device_pool.py +478 -0
  309. package/artemis-main/artemis/runtime/helper_manager.py +1030 -0
  310. package/artemis-main/artemis/runtime/process_probe.py +98 -0
  311. package/artemis-main/artemis/runtime/server_lifecycle.py +435 -0
  312. package/artemis-main/artemis/runtime/supervisor.py +121 -0
  313. package/artemis-main/artemis/runtime/trace_store.py +375 -0
  314. package/artemis-main/artemis/sdk/__init__.py +26 -0
  315. package/artemis-main/artemis/sdk/agent.py +485 -0
  316. package/artemis-main/artemis/sdk/builders/__init__.py +29 -0
  317. package/artemis-main/artemis/sdk/builders/agent_config_builder.py +474 -0
  318. package/artemis-main/artemis/sdk/constants.py +15 -0
  319. package/artemis-main/artemis/sdk/run_outcome.py +58 -0
  320. package/artemis-main/artemis/sdk/types/__init__.py +61 -0
  321. package/artemis-main/artemis/sdk/types/agent.py +89 -0
  322. package/artemis-main/artemis/sdk/utils.py +28 -0
  323. package/artemis-main/artemis/services/llm.py +1040 -0
  324. package/artemis-main/artemis/services/token_meter.py +267 -0
  325. package/artemis-main/artemis/telemetry/__init__.py +35 -0
  326. package/artemis-main/artemis/telemetry/models.py +64 -0
  327. package/artemis-main/artemis/telemetry/replay.py +34 -0
  328. package/artemis-main/artemis/telemetry/storage/json_storage.py +47 -0
  329. package/artemis-main/artemis/telemetry/tracer.py +85 -0
  330. package/artemis-main/artemis/toolchain/__init__.py +52 -0
  331. package/artemis-main/artemis/toolchain/descriptors.py +92 -0
  332. package/artemis-main/artemis/toolchain/resolver.py +185 -0
  333. package/artemis-main/artemis/tools/base.py +232 -0
  334. package/artemis-main/artemis/tools/command_tool.py +1240 -0
  335. package/artemis-main/artemis/tools/committee_prompts.json +5 -0
  336. package/artemis-main/artemis/tools/committee_tool.py +422 -0
  337. package/artemis-main/artemis/tools/diagnoser_submit_answer_tool.py +87 -0
  338. package/artemis-main/artemis/tools/diagnostic_tool.py +120 -0
  339. package/artemis-main/artemis/tools/explorer_tool.py +581 -0
  340. package/artemis-main/artemis/tools/history/__init__.py +398 -0
  341. package/artemis-main/artemis/tools/history/replay.py +120 -0
  342. package/artemis-main/artemis/tools/history/screenshot.py +139 -0
  343. package/artemis-main/artemis/tools/history/search.py +563 -0
  344. package/artemis-main/artemis/tools/image_processor_tool.py +124 -0
  345. package/artemis-main/artemis/tools/index.py +47 -0
  346. package/artemis-main/artemis/tools/log_tool.py +108 -0
  347. package/artemis-main/artemis/tools/mobile/launch_app.py +132 -0
  348. package/artemis-main/artemis/tools/mobile/log_utils.py +138 -0
  349. package/artemis-main/artemis/tools/mobile/ocr.py +202 -0
  350. package/artemis-main/artemis/tools/mobile/read_hierarchy.py +108 -0
  351. package/artemis-main/artemis/tools/mobile/read_logs.py +116 -0
  352. package/artemis-main/artemis/tools/mobile/search_logs.py +260 -0
  353. package/artemis-main/artemis/tools/object_detection_tool.py +244 -0
  354. package/artemis-main/artemis/tools/scratchpad.py +819 -0
  355. package/artemis-main/artemis/tools/system/adb_shell.py +31 -0
  356. package/artemis-main/artemis/tools/tool_wrapper.py +270 -0
  357. package/artemis-main/artemis/tools/video_tool.py +261 -0
  358. package/artemis-main/artemis/tools/wait_tool.py +97 -0
  359. package/artemis-main/artemis/utils/app_launch_utils.py +207 -0
  360. package/artemis-main/artemis/utils/coordinates.py +606 -0
  361. package/artemis-main/artemis/utils/credentials_validator.py +201 -0
  362. package/artemis-main/artemis/utils/cv_canvas.py +189 -0
  363. package/artemis-main/artemis/utils/cython_compat.py +45 -0
  364. package/artemis-main/artemis/utils/element_hit_test.py +80 -0
  365. package/artemis-main/artemis/utils/image_diff.py +335 -0
  366. package/artemis-main/artemis/utils/image_hash.py +80 -0
  367. package/artemis-main/artemis/utils/notes.py +470 -0
  368. package/artemis-main/artemis/utils/ocr_api.py +105 -0
  369. package/artemis-main/artemis/utils/ocr_xml_fusion.py +318 -0
  370. package/artemis-main/artemis/utils/plan_grammar.py +513 -0
  371. package/artemis-main/artemis/utils/python_executor.py +213 -0
  372. package/artemis-main/artemis/utils/startup_progress.py +60 -0
  373. package/artemis-main/artemis/utils/task_tree.py +1174 -0
  374. package/artemis-main/artemis/utils/text.py +37 -0
  375. package/artemis-main/artemis/utils/ui_filter.py +651 -0
  376. package/artemis-main/artemis/utils/video.py +685 -0
  377. package/artemis-main/artemis/utils/visualization.py +802 -0
  378. package/artemis-main/config/artemis.example.jsonc +12 -0
  379. package/artemis-main/mcp_server/README.md +111 -0
  380. package/artemis-main/mcp_server/__init__.py +21 -0
  381. package/artemis-main/mcp_server/__main__.py +20 -0
  382. package/artemis-main/mcp_server/background/__init__.py +15 -0
  383. package/artemis-main/mcp_server/background/task_runner.py +453 -0
  384. package/artemis-main/mcp_server/base.py +49 -0
  385. package/artemis-main/mcp_server/notifiers/__init__.py +74 -0
  386. package/artemis-main/mcp_server/notifiers/agentapi.py +289 -0
  387. package/artemis-main/mcp_server/notifiers/base.py +45 -0
  388. package/artemis-main/mcp_server/notifiers/composite.py +87 -0
  389. package/artemis-main/mcp_server/notifiers/desktop.py +108 -0
  390. package/artemis-main/mcp_server/notifiers/file.py +69 -0
  391. package/artemis-main/mcp_server/notifiers/script.py +90 -0
  392. package/artemis-main/mcp_server/notifiers/webhook.py +87 -0
  393. package/artemis-main/mcp_server/rules.md +68 -0
  394. package/artemis-main/mcp_server/server.py +106 -0
  395. package/artemis-main/mcp_server/tools/__init__.py +29 -0
  396. package/artemis-main/mcp_server/tools/device_state.py +107 -0
  397. package/artemis-main/mcp_server/tools/diagnose.py +1249 -0
  398. package/artemis-main/mcp_server/tools/inspect_trace.py +394 -0
  399. package/artemis-main/mcp_server/tools/task_manager.py +627 -0
  400. package/artemis-main/mcp_server/tools/task_runner.py +548 -0
  401. package/artemis-main/mcp_server/utils/__init__.py +15 -0
  402. package/artemis-main/mcp_server/utils/device_utils.py +180 -0
  403. package/artemis-main/mcp_server/utils/env_utils.py +65 -0
  404. package/artemis-main/packages/artemis-accessibility-helper/ArtemisAccessibilityHelper.apk +0 -0
  405. package/artemis-main/packages/artemis-accessibility-helper/app/build.gradle.kts +35 -0
  406. package/artemis-main/packages/artemis-accessibility-helper/app/src/main/AndroidManifest.xml +49 -0
  407. package/artemis-main/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/A11yNode.java +274 -0
  408. package/artemis-main/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/ArtemisAccessibilityService.java +227 -0
  409. package/artemis-main/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/CommandServer.java +463 -0
  410. package/artemis-main/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/DisplayUtils.java +111 -0
  411. package/artemis-main/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/GestureController.java +232 -0
  412. package/artemis-main/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/HierarchyDumper.java +832 -0
  413. package/artemis-main/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/TokenReceiver.java +36 -0
  414. package/artemis-main/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/TokenStore.java +38 -0
  415. package/artemis-main/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/XmlUtils.java +102 -0
  416. package/artemis-main/packages/artemis-accessibility-helper/app/src/main/res/values/strings.xml +4 -0
  417. package/artemis-main/packages/artemis-accessibility-helper/app/src/main/res/xml/accessibility_service_config.xml +10 -0
  418. package/artemis-main/packages/artemis-accessibility-helper/build.gradle.kts +3 -0
  419. package/artemis-main/packages/artemis-accessibility-helper/build_apk.sh +215 -0
  420. package/artemis-main/packages/artemis-accessibility-helper/debug.keystore +0 -0
  421. package/artemis-main/packages/artemis-accessibility-helper/gradle.properties +2 -0
  422. package/artemis-main/packages/artemis-accessibility-helper/helper_manifest.json +7 -0
  423. package/artemis-main/packages/artemis-accessibility-helper/settings.gradle.kts +17 -0
  424. package/artemis-main/packages/artemis-client/DESIGN.md +89 -0
  425. package/artemis-main/packages/artemis-client/README.md +112 -0
  426. package/artemis-main/packages/artemis-client/examples/basic.py +30 -0
  427. package/artemis-main/packages/artemis-client/pyproject.toml +36 -0
  428. package/artemis-main/packages/artemis-client/src/artemis_client/__init__.py +45 -0
  429. package/artemis-main/packages/artemis-client/src/artemis_client/client.py +401 -0
  430. package/artemis-main/packages/artemis-client/src/artemis_client/errors.py +66 -0
  431. package/artemis-main/packages/artemis-client/src/artemis_client/models.py +212 -0
  432. package/artemis-main/packages/artemis-client/src/artemis_client/py.typed +1 -0
  433. package/artemis-main/packages/artemis-client/src/artemis_client/transport.py +134 -0
  434. package/artemis-main/packages/artemis-client/tests/test_client.py +289 -0
  435. package/artemis-main/packages/artemis-client/tests/test_transport.py +85 -0
  436. package/artemis-main/pyproject.toml +259 -0
  437. package/artemis-main/pyright-core.json +17 -0
  438. package/artemis-main/scripts/check_dependency_sources.py +68 -0
  439. package/artemis-main/scripts/install_deps.ps1 +520 -0
  440. package/artemis-main/scripts/install_deps.sh +533 -0
  441. package/artemis-main/scripts/quality_ratchet.py +97 -0
  442. package/artemis-main/scripts/real_device_video_acceptance.py +483 -0
  443. package/artemis-main/scripts/run_edge_case_part.py +327 -0
  444. package/artemis-main/scripts/run_ui.py +242 -0
  445. package/artemis-main/scripts/start.ps1 +500 -0
  446. package/artemis-main/scripts/test_all_edge_cases.py +269 -0
  447. package/artemis-main/scripts/verify_concurrency_and_devices.py +385 -0
  448. package/artemis-main/setup.py +164 -0
  449. package/artemis-main/start.bat +35 -0
  450. package/artemis-main/start.sh +423 -0
  451. package/artemis-main/third_party/mobile_use/LICENSE +190 -0
  452. package/artemis-main/third_party/mobile_use/METADATA +27 -0
  453. package/artemis-main/third_party/mobile_use/NOTICE +12 -0
  454. package/artemis-main/third_party/mobile_use/__init__.py +19 -0
  455. package/artemis-main/third_party/mobile_use/agents/__init__.py +13 -0
  456. package/artemis-main/third_party/mobile_use/agents/hopper/__init__.py +13 -0
  457. package/artemis-main/third_party/mobile_use/agents/hopper/hopper.md +22 -0
  458. package/artemis-main/third_party/mobile_use/agents/hopper/hopper.py +80 -0
  459. package/artemis-main/third_party/mobile_use/clients/__init__.py +13 -0
  460. package/artemis-main/third_party/mobile_use/clients/ui_automator_client.py +482 -0
  461. package/artemis-main/third_party/mobile_use/config/__init__.py +13 -0
  462. package/artemis-main/third_party/mobile_use/config/llm.py +183 -0
  463. package/artemis-main/third_party/mobile_use/config/output.py +184 -0
  464. package/artemis-main/third_party/mobile_use/context.py +119 -0
  465. package/artemis-main/third_party/mobile_use/controllers/__init__.py +13 -0
  466. package/artemis-main/third_party/mobile_use/controllers/platform_specific_commands_controller.py +151 -0
  467. package/artemis-main/third_party/mobile_use/controllers/types.py +92 -0
  468. package/artemis-main/third_party/mobile_use/controllers/unified_controller.py +183 -0
  469. package/artemis-main/third_party/mobile_use/graph.py +54 -0
  470. package/artemis-main/third_party/mobile_use/llm_service.py +193 -0
  471. package/artemis-main/third_party/mobile_use/main.py +130 -0
  472. package/artemis-main/third_party/mobile_use/sdk/__init__.py +13 -0
  473. package/artemis-main/third_party/mobile_use/sdk/agent.py +1060 -0
  474. package/artemis-main/third_party/mobile_use/sdk/builders/__init__.py +13 -0
  475. package/artemis-main/third_party/mobile_use/sdk/builders/agent_config_builder.py +225 -0
  476. package/artemis-main/third_party/mobile_use/sdk/builders/index.py +33 -0
  477. package/artemis-main/third_party/mobile_use/sdk/builders/task_request_builder.py +243 -0
  478. package/artemis-main/third_party/mobile_use/sdk/types/__init__.py +13 -0
  479. package/artemis-main/third_party/mobile_use/sdk/types/agent.py +98 -0
  480. package/artemis-main/third_party/mobile_use/sdk/types/exceptions.py +109 -0
  481. package/artemis-main/third_party/mobile_use/sdk/types/task.py +234 -0
  482. package/artemis-main/third_party/mobile_use/testing.py +59 -0
  483. package/artemis-main/third_party/mobile_use/tools/__init__.py +13 -0
  484. package/artemis-main/third_party/mobile_use/tools/index.py +44 -0
  485. package/artemis-main/third_party/mobile_use/tools/mobile/__init__.py +13 -0
  486. package/artemis-main/third_party/mobile_use/tools/mobile/launch_app.py +89 -0
  487. package/artemis-main/third_party/mobile_use/tools/tool_wrapper.py +52 -0
  488. package/artemis-main/third_party/mobile_use/utils/__init__.py +13 -0
  489. package/artemis-main/third_party/mobile_use/utils/app_launch_utils.py +217 -0
  490. package/artemis-main/third_party/mobile_use/utils/cli_helpers.py +60 -0
  491. package/artemis-main/third_party/mobile_use/utils/decorators.py +158 -0
  492. package/artemis-main/third_party/mobile_use/utils/file.py +31 -0
  493. package/artemis-main/third_party/mobile_use/utils/logger.py +216 -0
  494. package/artemis-main/third_party/mobile_use/utils/media.py +213 -0
  495. package/artemis-main/third_party/mobile_use/utils/shell_utils.py +39 -0
  496. package/artemis-main/third_party/mobile_use/utils/video.py +398 -0
  497. package/artemis-main/uv.lock +4908 -0
  498. package/bin/android-midscene-automation.js +1 -5
  499. package/bin/artemis-config.mjs +15 -0
  500. package/bin/artemis-embedded.py +56 -0
  501. package/bin/start-artemis.mjs +30 -0
  502. package/bin/start-dev.mjs +112 -0
  503. package/bin/test_artemis_embedded.py +44 -0
  504. package/package.json +4 -2
  505. package/server/artemis.ts +34 -0
  506. package/server/http-api.ts +12 -0
  507. package/src/App.vue +10 -5
  508. package/src/api.ts +5 -0
  509. package/src/appium-recorder/AppiumPage.vue +1 -1
  510. package/src/appium-recorder/components/ComponentInspector.vue +1 -1
  511. package/src/appium-recorder/components/FlowNodeSearch.vue +3 -3
  512. package/src/appium-recorder/components/ImageCheckDialog.vue +3 -3
  513. package/src/appium-recorder/components/RecorderWorkspace.vue +4 -4
  514. package/src/appium-recorder/flow-appearance.ts +4 -4
  515. package/src/components/AppSidebar.vue +8 -7
  516. package/src/components/MidsceneMaintenanceNotice.vue +17 -0
  517. package/src/components/analytics/AnalysisCharts.vue +4 -4
  518. package/src/components/analytics/ReportAnalysis.vue +1 -1
  519. package/src/components/generator/TestCaseFileUpload.vue +1 -1
  520. package/src/components/help/HelpCenter.vue +56 -37
  521. package/src/pages/AiGeneratorPage.vue +1 -1
  522. package/src/pages/ArtemisPage.vue +72 -0
  523. package/src/pages/ConfigPage.vue +18 -19
  524. package/src/style.css +340 -121
  525. package/src/types.ts +1 -1
@@ -0,0 +1,1719 @@
1
+ # Copyright 2026 Google LLC
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ import asyncio
16
+ from datetime import datetime
17
+ import logging
18
+ import os
19
+ from pathlib import Path
20
+ import shutil
21
+ import sys
22
+ import threading
23
+ import time
24
+ from typing import Any
25
+ import uuid
26
+
27
+ try:
28
+ from admin_console.core.state import state
29
+ from admin_console.database.repositories.session_repository import session_repo
30
+ from admin_console.services import worker_process_io
31
+ from admin_console.services.media_service import media_service
32
+ except ImportError:
33
+ from apps.admin_console.core.state import state
34
+ from apps.admin_console.database.repositories.session_repository import session_repo
35
+ from apps.admin_console.services import worker_process_io
36
+ from apps.admin_console.services.media_service import media_service
37
+
38
+ from artemis.config import (
39
+ PAUSE_FILE,
40
+ TEST_DATA_DIR,
41
+ TEST_OUTPUTS_DIR,
42
+ WORKSPACE_ROOT,
43
+ )
44
+ from artemis.runtime import (
45
+ AdbEndpoint,
46
+ AdbTarget,
47
+ DeviceExecutionLock,
48
+ clear_cancel_request,
49
+ current_adb_endpoint,
50
+ pid_is_alive,
51
+ process_supervisor,
52
+ request_cancel,
53
+ trace_store,
54
+ )
55
+
56
+ logger = logging.getLogger(__name__)
57
+
58
+
59
+ class TaskQueueService:
60
+ """Service managing FIFO task execution, background worker, subprocess lifecycle,
61
+ and startup tasks.
62
+ """
63
+
64
+ # Strong references to in-flight _execute_task_item tasks (asyncio itself only
65
+ # keeps weak references to running tasks).
66
+ _run_tasks: set[asyncio.Task] = set()
67
+ # Deadline enforcers for graceful stops (see _stop_worker_gracefully).
68
+ _forced_stop_tasks: set[asyncio.Task] = set()
69
+
70
+ DEFAULT_CANCEL_GRACE_SECONDS = 45.0
71
+
72
+ @classmethod
73
+ def _cancel_grace_seconds(cls) -> float:
74
+ """How long a worker may finalize itself before it is killed.
75
+
76
+ ``ARTEMIS_CANCEL_GRACE_SECONDS=0`` restores the legacy immediate kill.
77
+ """
78
+ raw = os.getenv("ARTEMIS_CANCEL_GRACE_SECONDS")
79
+ if raw is None or not raw.strip():
80
+ return cls.DEFAULT_CANCEL_GRACE_SECONDS
81
+ try:
82
+ return max(0.0, float(raw))
83
+ except ValueError:
84
+ return cls.DEFAULT_CANCEL_GRACE_SECONDS
85
+
86
+ @staticmethod
87
+ def _hard_kill(pid: int, process_created_at: float = 0.0) -> bool:
88
+ if not pid:
89
+ return False
90
+ if process_created_at and process_created_at > 0:
91
+ return process_supervisor.terminate_tree_verified(pid, process_created_at)
92
+ try:
93
+ return process_supervisor.terminate_tree(pid)
94
+ except Exception:
95
+ return False
96
+
97
+ @classmethod
98
+ def _stop_worker_gracefully(
99
+ cls,
100
+ pid: Any,
101
+ process_created_at: float = 0.0,
102
+ session_id: str | None = None,
103
+ reason: str = "Task stopped from the Artemis frontend.",
104
+ ) -> tuple[bool, bool]:
105
+ """Ask a worker to cancel itself; hard-kill it once the grace period lapses.
106
+
107
+ Workers are isolated from the daemon's console (and may belong to
108
+ another ingress process), so instead of a signal the daemon drops a
109
+ cancel marker the worker polls for. Honouring it runs the worker's
110
+ normal cancellation path: the screen recording is stopped and remuxed,
111
+ the trace folder is compiled, and the device lease is released. A
112
+ worker that never picks the marker up is killed after the grace period.
113
+
114
+ Returns ``(stopped, deferred)``: ``stopped`` mirrors the legacy kill
115
+ result, ``deferred`` is True when the kill was handed to the deadline
116
+ enforcer instead of happening now.
117
+ """
118
+ try:
119
+ pid_int = int(pid) if pid else 0
120
+ except (TypeError, ValueError):
121
+ pid_int = 0
122
+ grace = cls._cancel_grace_seconds()
123
+ if not pid_int or grace <= 0:
124
+ return cls._hard_kill(pid_int, process_created_at), False
125
+
126
+ created_at = float(process_created_at or 0.0)
127
+ if created_at <= 0:
128
+ # PID markers carry the creation time so a leftover marker can never
129
+ # cancel a future process that reuses this PID.
130
+ try:
131
+ import psutil
132
+
133
+ created_at = float(psutil.Process(pid_int).create_time())
134
+ except Exception:
135
+ created_at = 0.0
136
+ if not pid_is_alive(pid_int, created_at or None):
137
+ return True, False
138
+
139
+ written = request_cancel(
140
+ session_id=str(session_id) if session_id else None,
141
+ pid=pid_int,
142
+ process_created_at=created_at,
143
+ reason=reason,
144
+ )
145
+ if not written:
146
+ return cls._hard_kill(pid_int, created_at), False
147
+
148
+ print(
149
+ f"[stop_tasks] Cancel requested for worker {pid_int}"
150
+ f" (session {session_id or 'n/a'}); forcing termination after {grace:.0f}s"
151
+ )
152
+ cls._schedule_forced_stop(pid_int, created_at, grace, session_id)
153
+ return True, True
154
+
155
+ @classmethod
156
+ def _stop_proc_gracefully(cls, proc: Any, session_id: Any) -> bool:
157
+ """Graceful variant for a locally spawned process; True when deferred."""
158
+ pid = getattr(proc, "pid", None)
159
+ if not pid or cls._cancel_grace_seconds() <= 0:
160
+ return False
161
+ try:
162
+ _stopped, deferred = cls._stop_worker_gracefully(
163
+ pid, session_id=str(session_id) if session_id else None
164
+ )
165
+ except Exception:
166
+ return False
167
+ return deferred
168
+
169
+ @classmethod
170
+ def _schedule_forced_stop(
171
+ cls, pid: int, process_created_at: float, grace: float, session_id: Any
172
+ ) -> None:
173
+ """Kill ``pid`` if it is still alive once ``grace`` seconds have passed."""
174
+
175
+ def _still_alive() -> bool:
176
+ return pid_is_alive(pid, process_created_at or None)
177
+
178
+ def _force() -> None:
179
+ print(
180
+ f"[stop_tasks] Worker {pid} (session {session_id or 'n/a'}) did not exit"
181
+ f" within {grace:.0f}s of the cancel request; forcing termination."
182
+ )
183
+ cls._hard_kill(pid, process_created_at)
184
+
185
+ async def _enforce_async() -> None:
186
+ deadline = time.monotonic() + grace
187
+ while time.monotonic() < deadline:
188
+ if not _still_alive():
189
+ break
190
+ await asyncio.sleep(0.5)
191
+ else:
192
+ _force()
193
+ clear_cancel_request(session_id=str(session_id) if session_id else None, pid=pid)
194
+
195
+ def _enforce_sync() -> None:
196
+ deadline = time.monotonic() + grace
197
+ while time.monotonic() < deadline:
198
+ if not _still_alive():
199
+ break
200
+ time.sleep(0.5)
201
+ else:
202
+ _force()
203
+ clear_cancel_request(session_id=str(session_id) if session_id else None, pid=pid)
204
+
205
+ try:
206
+ loop = asyncio.get_running_loop()
207
+ except RuntimeError:
208
+ loop = None
209
+ if loop is not None:
210
+ task = loop.create_task(_enforce_async())
211
+ cls._forced_stop_tasks.add(task)
212
+ task.add_done_callback(cls._forced_stop_tasks.discard)
213
+ return
214
+ threading.Thread(
215
+ target=_enforce_sync, name=f"artemis-forced-stop-{pid}", daemon=True
216
+ ).start()
217
+
218
+ @staticmethod
219
+ def _task_target(task_item: dict[str, Any]) -> AdbTarget:
220
+ endpoint_data = task_item.get("adb_endpoint")
221
+ endpoint = (
222
+ AdbEndpoint.from_mapping(endpoint_data)
223
+ if isinstance(endpoint_data, dict)
224
+ else current_adb_endpoint()
225
+ )
226
+ serial = task_item.get("device_serial")
227
+ return AdbTarget(endpoint=endpoint, serial=str(serial) if serial else None)
228
+
229
+ @classmethod
230
+ def _broadcast_event(cls, event_type: str, data: Any):
231
+ """Broadcasts an event safely to all registered subscribers."""
232
+ for cb in list(state.ipc_subscribers):
233
+ try:
234
+ cb(event_type, data)
235
+ except Exception:
236
+ # One broken subscriber must not block the others, but a
237
+ # silent drop hides it entirely.
238
+ logger.warning(
239
+ "Event subscriber %r failed for event %s",
240
+ cb,
241
+ event_type,
242
+ exc_info=True,
243
+ )
244
+
245
+ @classmethod
246
+ def _broadcast_startup_progress(cls, session_id: str | None, stage: str, message: str) -> None:
247
+ if not session_id:
248
+ return
249
+ data = {
250
+ "session_id": str(session_id),
251
+ "stage": stage,
252
+ "message": message,
253
+ "timestamp": time.time(),
254
+ }
255
+ state.record_startup_progress(data)
256
+ cls._broadcast_event("startup_progress", data)
257
+
258
+ @classmethod
259
+ def _get_next_pending_task(cls) -> dict[str, Any] | None:
260
+ """Finds and returns the first pending task from queue_items."""
261
+ for item in state.queue_items:
262
+ if isinstance(item, dict) and item.get("status") == "pending":
263
+ return item
264
+ return None
265
+
266
+ @classmethod
267
+ def _remove_task(cls, session_id: str | None):
268
+ """Removes a task from queue_items by session_id."""
269
+ if not session_id:
270
+ return
271
+ removed_items = [
272
+ t
273
+ for t in state.queue_items
274
+ if isinstance(t, dict) and t.get("session_id") == session_id
275
+ ]
276
+ for item in removed_items:
277
+ DeviceExecutionLock.cancel_reservation(item.get("queue_ticket"))
278
+ state.queue_items = [
279
+ t
280
+ for t in state.queue_items
281
+ if not (isinstance(t, dict) and t.get("session_id") == session_id)
282
+ ]
283
+
284
+ @staticmethod
285
+ def _resolve_terminal_status(
286
+ current_status: str | None,
287
+ returncode: int,
288
+ was_stopped_manually: bool,
289
+ ) -> tuple[str, bool]:
290
+ """Resolve final status while preserving an authoritative task result.
291
+
292
+ The worker exit code is only a fallback for sessions that have not
293
+ reached a terminal state. ``success`` is the DataEngine alias for the
294
+ UI-facing ``completed`` status and is normalized here.
295
+
296
+ Returns:
297
+ A tuple of ``(resolved_status, should_persist)``.
298
+ """
299
+ if was_stopped_manually:
300
+ return "cancelled", True
301
+
302
+ normalized = current_status.lower().strip() if isinstance(current_status, str) else None
303
+ if normalized == "success":
304
+ return "completed", True
305
+ if normalized in {"completed", "failed", "cancelled"}:
306
+ return normalized, False
307
+ return ("completed" if returncode == 0 else "failed"), True
308
+
309
+ # Worker subprocess I/O plumbing lives in worker_process_io; the historical
310
+ # private names stay bound here so callers and tests keep working unchanged.
311
+ _subprocess_creation_kwargs = staticmethod(worker_process_io.subprocess_creation_kwargs)
312
+ _forward_worker_output = staticmethod(worker_process_io.forward_worker_output)
313
+ _finish_output_forwarder = staticmethod(worker_process_io.finish_output_forwarder)
314
+ _wait_for_worker_process = staticmethod(worker_process_io.wait_for_worker_process)
315
+
316
+ @staticmethod
317
+ async def _terminate_worker_process(proc: asyncio.subprocess.Process | None) -> None:
318
+ """Stop a worker without changing the established POSIX behavior."""
319
+ if proc is None or proc.returncode is not None:
320
+ return
321
+ if sys.platform == "win32":
322
+ await process_supervisor.stop_process(proc)
323
+ return
324
+ try:
325
+ proc.kill()
326
+ except ProcessLookupError:
327
+ pass
328
+
329
+ @classmethod
330
+ def ensure_worker_running(cls):
331
+ """Guarantees that the background queue worker task is active and running."""
332
+ try:
333
+ loop = asyncio.get_running_loop()
334
+ if state.worker_task is None or state.worker_task.done():
335
+ state.worker_task = loop.create_task(cls.queue_worker())
336
+ try:
337
+ state.wake_event.set()
338
+ except AttributeError:
339
+ # Partially initialized state (unit tests): the worker
340
+ # loop polls anyway.
341
+ pass
342
+ except RuntimeError:
343
+ pass
344
+
345
+ @classmethod
346
+ def _concurrency_limit(cls) -> int:
347
+ """Return 0 for per-device concurrency, or the global task limit."""
348
+ return DeviceExecutionLock.resolve_env_concurrency()
349
+
350
+ @classmethod
351
+ async def queue_worker(cls):
352
+ """Persistent dispatcher scheduling pending tasks onto devices.
353
+
354
+ Scans the queue and launches each eligible task as an independent
355
+ coroutine. Admission is governed by _concurrency_limit(): 0 admits one
356
+ task per device so distinct devices execute concurrently, N>=1 admits
357
+ at most N tasks across all devices. Per-device FIFO ordering and
358
+ cross-process mutual exclusion remain enforced by DeviceExecutionLock
359
+ inside each worker process.
360
+ """
361
+ print(
362
+ f"[QueueWorker] Dispatcher initialized (concurrency limit: {cls._concurrency_limit()})."
363
+ )
364
+ try:
365
+ DeviceExecutionLock.cleanup_stale_locks()
366
+ except Exception as exc:
367
+ print(f"[QueueWorker] Initial stale lock cleanup notice: {exc}")
368
+
369
+ try:
370
+ while True:
371
+ try:
372
+ cls._dispatch_pending_tasks()
373
+ except Exception as exc:
374
+ print(f"[QueueWorker] Dispatch error: {exc}")
375
+ try:
376
+ await asyncio.wait_for(state.wake_event.wait(), timeout=0.3)
377
+ state.wake_event.clear()
378
+ except TimeoutError:
379
+ pass
380
+ except asyncio.CancelledError:
381
+ print("[QueueWorker] Dispatcher received cancellation signal.")
382
+ for run in list(state.active_runs.values()):
383
+ run_proc = run.get("process")
384
+ if run_proc is not None and run_proc.returncode is None:
385
+ await cls._terminate_worker_process(run_proc)
386
+ raise
387
+
388
+ @classmethod
389
+ def _dispatch_pending_tasks(cls) -> None:
390
+ """Launch every pending task admissible under the current concurrency limit."""
391
+ limit = cls._concurrency_limit()
392
+ state.prune_finished_runs()
393
+ if limit == 1 and state.is_running:
394
+ return
395
+
396
+ # Dispatched-but-not-yet-spawned runs are only visible as queue items in
397
+ # "running" state, so admission must count those too -- active_runs alone
398
+ # lags behind by the subprocess startup latency.
399
+ in_flight = [
400
+ i for i in state.queue_items if isinstance(i, dict) and i.get("status") == "running"
401
+ ]
402
+ capacity = None
403
+ if limit >= 1:
404
+ # Registered workers still have running queue rows until finalization.
405
+ active_pids = {
406
+ getattr(run.get("process"), "pid", None) for run in state.active_runs.values()
407
+ } - {None}
408
+ starting = sum(
409
+ str(item.get("session_id")) not in state.active_runs
410
+ and item.get("pid") not in active_pids
411
+ for item in in_flight
412
+ )
413
+ capacity = limit - len(state.active_runs) - starting
414
+ if capacity <= 0:
415
+ return
416
+ busy_devices = state.busy_device_ids | {
417
+ cls._task_target(i).lock_key for i in in_flight if i.get("device_serial")
418
+ }
419
+ dispatched_any = False
420
+ loop = asyncio.get_running_loop()
421
+ for item in list(state.queue_items):
422
+ if not (isinstance(item, dict) and item.get("status") == "pending"):
423
+ continue
424
+ sess_id = item.get("session_id")
425
+ if sess_id and str(sess_id) in state.cancelled_session_ids:
426
+ cls._remove_task(sess_id)
427
+ continue
428
+
429
+ device = item.get("device_serial")
430
+ target = cls._task_target(item)
431
+ if limit == 0:
432
+ # A task without a resolved device may bind to any serial, so it
433
+ # only launches on an otherwise idle scheduler; the device lock
434
+ # then allocates freely without contending against active runs.
435
+ if device is None and (state.active_runs or in_flight or dispatched_any):
436
+ continue
437
+ if device is not None and target.lock_key in busy_devices:
438
+ continue
439
+ elif limit > 1 and device is not None and target.lock_key in busy_devices:
440
+ # A second worker for this device would wait on its lock.
441
+ continue
442
+
443
+ item["status"] = "running"
444
+ dispatched_any = True
445
+ if device is not None:
446
+ busy_devices.add(target.lock_key)
447
+ run_task = loop.create_task(cls._execute_task_item(item))
448
+ # Hold a strong reference: asyncio keeps only weak refs to running
449
+ # tasks, and a collected run would strand its queue item forever.
450
+ cls._run_tasks.add(run_task)
451
+ run_task.add_done_callback(cls._run_tasks.discard)
452
+ if capacity is not None:
453
+ capacity -= 1
454
+ if capacity <= 0:
455
+ break
456
+
457
+ @classmethod
458
+ def _begin_task_run(
459
+ cls,
460
+ task_item: dict[str, Any],
461
+ run_key: str,
462
+ sess_id: Any,
463
+ goal: str,
464
+ profile: str,
465
+ ) -> None:
466
+ """Mark the task as running and announce the launch to subscribers."""
467
+ task_item["status"] = "running"
468
+ task_item["start_time"] = time.time()
469
+
470
+ # A fresh launch clears a stale stop request left over for this run
471
+ # key from a previous task. Manual stops are tracked per run in
472
+ # manually_stopped_run_ids so stopping one device's task never
473
+ # affects concurrent runs. Per-session stops are tracked in
474
+ # cancelled_session_ids and are unaffected.
475
+ state.manually_stopped_run_ids.discard(run_key)
476
+ state.current_goal = goal
477
+ state.current_profile = profile
478
+ state.active_session_id = sess_id
479
+
480
+ cls._broadcast_startup_progress(sess_id, "launching", "Starting the execution process")
481
+
482
+ # Broadcast session_started so all connected clients know the task has started
483
+ cls._broadcast_event(
484
+ "session_started",
485
+ {
486
+ "session_id": sess_id,
487
+ "initial_goal": goal,
488
+ "profile": profile,
489
+ "device_serial": task_item.get("device_serial"),
490
+ },
491
+ )
492
+
493
+ @classmethod
494
+ def _build_worker_invocation(
495
+ cls,
496
+ task_item: dict[str, Any],
497
+ run_key: str,
498
+ sess_id: Any,
499
+ goal: str,
500
+ profile: str,
501
+ target: AdbTarget,
502
+ ) -> tuple[list[str], dict[str, str]]:
503
+ """Assemble the worker subprocess command line and environment."""
504
+ expected_output = task_item.get("expected_output")
505
+ enable_outputter = task_item.get("enable_outputter")
506
+ verification_level = task_item.get("verification_level")
507
+ explorer_mode = task_item.get("explorer_mode")
508
+ locked_app = task_item.get("locked_app_package") or task_item.get("locked_app")
509
+ app_path = task_item.get("app_path")
510
+
511
+ test_name = f"web_{int(time.time())}_{run_key[:8]}"
512
+ env = os.environ.copy()
513
+ pythonpath_parts = [
514
+ str(WORKSPACE_ROOT),
515
+ str(WORKSPACE_ROOT / "apps" / "admin_console"),
516
+ str(WORKSPACE_ROOT / "apps" / "cloud_service"),
517
+ env.get("PYTHONPATH", ""),
518
+ ]
519
+ env["PYTHONPATH"] = os.pathsep.join([p for p in pythonpath_parts if p])
520
+ env["PYTHONUTF8"] = "1"
521
+ env["PYTHONUNBUFFERED"] = "1"
522
+ if state.ipc_port is not None:
523
+ env["ARTEMIS_IPC_PORT"] = str(state.ipc_port)
524
+ if sess_id:
525
+ env["ARTEMIS_SESSION_ID"] = str(sess_id)
526
+ env["ARTEMIS_TASK_INGRESS"] = str(task_item.get("ingress", "frontend"))
527
+ env["ARTEMIS_TASK_WORKER"] = "1"
528
+ target.endpoint.apply_to_environment(env)
529
+ env[DeviceExecutionLock.LOCK_SCOPE_ENV] = target.lock_scope
530
+ queue_ticket = task_item.get("queue_ticket")
531
+ if queue_ticket:
532
+ env[DeviceExecutionLock.QUEUE_TICKET_ENV] = str(queue_ticket)
533
+
534
+ cmd = [
535
+ sys.executable,
536
+ "-m",
537
+ "artemis.main",
538
+ goal,
539
+ "--profile",
540
+ profile,
541
+ "--test-name",
542
+ test_name,
543
+ ]
544
+ if sess_id:
545
+ cmd.extend(["--session-id", str(sess_id)])
546
+ if expected_output:
547
+ cmd.extend(["--output-description", str(expected_output)])
548
+ if enable_outputter is not None:
549
+ cmd.append("--enable-outputter" if enable_outputter else "--disable-outputter")
550
+ if verification_level:
551
+ cmd.extend(["--verification-level", str(verification_level)])
552
+ if explorer_mode:
553
+ cmd.extend(["--explorer-pro-mode", str(explorer_mode)])
554
+ if locked_app:
555
+ cmd.extend(["--locked-app", str(locked_app)])
556
+ if app_path:
557
+ cmd.extend(["--app-path", str(app_path)])
558
+ device_serial = task_item.get("device_serial")
559
+ if device_serial:
560
+ cmd.extend(["--device-serial", str(device_serial)])
561
+ env["ADB_DEVICE_SERIAL"] = str(device_serial)
562
+ return cmd, env
563
+
564
+ @classmethod
565
+ def _register_worker_run(
566
+ cls,
567
+ task_item: dict[str, Any],
568
+ run_key: str,
569
+ sess_id: Any,
570
+ goal: str,
571
+ profile: str,
572
+ target: AdbTarget,
573
+ proc: asyncio.subprocess.Process,
574
+ ) -> None:
575
+ """Record the spawned worker in shared state and hand it the device reservation."""
576
+ device_serial = task_item.get("device_serial")
577
+ state.current_process = proc
578
+ task_item["pid"] = proc.pid
579
+ state.active_runs[run_key] = {
580
+ "process": proc,
581
+ "device_id": str(device_serial) if device_serial else None,
582
+ "lock_key": target.lock_key if device_serial else None,
583
+ "adb_endpoint": target.endpoint.to_dict(),
584
+ "goal": goal,
585
+ "profile": profile,
586
+ }
587
+ if sess_id:
588
+ try:
589
+ # Preserve status updates written concurrently by the worker.
590
+ trace_store.update_trace_pid(str(sess_id), proc.pid)
591
+ except OSError as exc:
592
+ # Status probes fall back to DB PID bookkeeping, but note it.
593
+ print(
594
+ f"[QueueWorker] Could not record worker pid in status.json for {sess_id}: {exc}"
595
+ )
596
+ cls._broadcast_startup_progress(sess_id, "process_ready", "Execution process started")
597
+ ingress_type = str(task_item.get("ingress", "frontend"))
598
+ queue_ticket = task_item.get("queue_ticket")
599
+ if queue_ticket:
600
+ transferred = DeviceExecutionLock.transfer_reservation(
601
+ str(queue_ticket),
602
+ proc.pid,
603
+ description=f"{ingress_type} task: {goal[:120]}",
604
+ device_id=device_serial or "pending",
605
+ session_id=str(sess_id) if sess_id else None,
606
+ ingress=ingress_type,
607
+ lock_scope=target.lock_scope,
608
+ )
609
+ if not transferred:
610
+ print(
611
+ f"[QueueWorker] Could not transfer queue ticket {queue_ticket} to worker"
612
+ f" pid {proc.pid}; the worker will queue a fresh ticket itself."
613
+ )
614
+
615
+ @classmethod
616
+ def _start_output_forwarder(
617
+ cls, sess_id: Any, proc: asyncio.subprocess.Process
618
+ ) -> asyncio.Task[None] | None:
619
+ """Start forwarding the worker's output; returns the forwarder task, if any."""
620
+ # Forward the worker's combined output and tee it into the trace's
621
+ # stdout.log so the log paths advertised by the MCP API exist.
622
+ log_path = None
623
+ if sess_id:
624
+ try:
625
+ log_path = trace_store.get_trace_stdout_log_path(str(sess_id))
626
+ except Exception:
627
+ log_path = None
628
+ if isinstance(proc.stdout, asyncio.StreamReader):
629
+ return asyncio.create_task(cls._forward_worker_output(proc.stdout, log_path))
630
+ return None
631
+
632
+ @classmethod
633
+ async def _terminate_if_cancelled_during_launch(
634
+ cls, run_key: str, sess_id: Any, proc: asyncio.subprocess.Process
635
+ ) -> None:
636
+ """Terminate a worker whose task was cancelled while it was being launched."""
637
+ if sess_id and (
638
+ str(sess_id) in getattr(state, "cancelled_session_ids", set())
639
+ or run_key in state.manually_stopped_run_ids
640
+ ):
641
+ print(f"[QueueWorker] Task [{sess_id}] was cancelled during launch. Terminating.")
642
+ await cls._terminate_worker_process(proc)
643
+
644
+ @classmethod
645
+ async def _persist_terminal_session_status(
646
+ cls, sess_id: Any, returncode: int, manual_stop: bool
647
+ ) -> str:
648
+ """Resolve and persist the session's terminal status in the DB and trace store."""
649
+ current_status = session_repo.get_session_status(sess_id)
650
+ new_status, should_persist = cls._resolve_terminal_status(
651
+ current_status,
652
+ returncode,
653
+ manual_stop,
654
+ )
655
+ if should_persist:
656
+ if session_repo.update_session_status(sess_id, new_status, time.time()):
657
+ print(f"[QueueWorker] Updated session {sess_id} status to '{new_status}'")
658
+ else:
659
+ # The DB row is the fallback MCP pollers reconcile
660
+ # against when status.json is stale, so a failed DB
661
+ # write must not pass silently.
662
+ logger.error(
663
+ "[QueueWorker] Could not persist terminal DB status '%s' for session %s",
664
+ new_status,
665
+ sess_id,
666
+ )
667
+ else:
668
+ print(f"[QueueWorker] Preserved authoritative session {sess_id} status '{new_status}'")
669
+ # A stale status.json would leave MCP pollers seeing "running"
670
+ # until their next DB reconcile, so retry transient write
671
+ # failures before giving up.
672
+ for attempt in range(3):
673
+ try:
674
+ if trace_store.read_status(str(sess_id)):
675
+ canonical_mcp_status = (
676
+ "completed" if new_status in ("completed", "success") else new_status
677
+ )
678
+ trace_store.update_trace_status(
679
+ str(sess_id),
680
+ canonical_mcp_status,
681
+ )
682
+ break
683
+ except OSError as exc:
684
+ if attempt < 2:
685
+ await asyncio.sleep(0.5 * (attempt + 1))
686
+ continue
687
+ logger.error(
688
+ "[QueueWorker] Could not persist terminal MCP status "
689
+ "for %s after %d attempts: %s",
690
+ sess_id,
691
+ attempt + 1,
692
+ exc,
693
+ )
694
+ return new_status
695
+
696
+ @classmethod
697
+ async def _recover_or_fail_recording(cls, sess_id: Any) -> None:
698
+ """Finalize a recording whose worker died before it could, else mark it failed.
699
+
700
+ A worker that stops gracefully finalizes its own recording and this is
701
+ a no-op. A hard-killed (or crashed) worker leaves the raw scrcpy file it
702
+ registered at recording start; remuxing that file is all that is
703
+ needed to publish the full video.
704
+ """
705
+ rec_info = session_repo.get_video_recording_for_session(sess_id)
706
+ rec_status = (rec_info or {}).get("status")
707
+ if rec_status == "ready":
708
+ return
709
+
710
+ recovered_url = None
711
+ # 1. Direct recovery from the recording row written at recording start.
712
+ try:
713
+ local_video_path = (rec_info or {}).get("local_video_path")
714
+ if local_video_path:
715
+ start_time = (rec_info or {}).get("start_time")
716
+ if not start_time:
717
+ session_row = session_repo.get_session_by_id(sess_id)
718
+ start_time = dict(session_row).get("start_time") if session_row else None
719
+ final_path = await asyncio.to_thread(
720
+ media_service.recover_orphaned_recording,
721
+ local_video_path,
722
+ start_time,
723
+ )
724
+ if final_path:
725
+ session_repo.mark_recording_ready(sess_id, str(final_path))
726
+ recovered_url = media_service.path_to_video_url(Path(final_path))
727
+ except Exception as rec_err:
728
+ print(f"[QueueWorker] Error finalizing orphaned recording: {rec_err}")
729
+
730
+ # 2. Fallback: locate an already finalized file by folder / session naming.
731
+ if not recovered_url:
732
+ try:
733
+ video_rec_map = session_repo.get_video_recordings_map()
734
+ video_idx = await asyncio.to_thread(media_service.build_video_index)
735
+ fallback_url = await asyncio.to_thread(
736
+ media_service.resolve_video_url,
737
+ {"session_id": sess_id},
738
+ video_rec_map,
739
+ video_idx,
740
+ )
741
+ if fallback_url:
742
+ session_repo.mark_recording_ready(sess_id, fallback_url)
743
+ recovered_url = fallback_url
744
+ except Exception as rec_err:
745
+ print(f"[QueueWorker] Error attempting recording recovery: {rec_err}")
746
+
747
+ if recovered_url:
748
+ cls._broadcast_event(
749
+ "recording_ready",
750
+ {"session_id": sess_id, "video_url": recovered_url},
751
+ )
752
+ return
753
+
754
+ recording_error = "Task worker exited before recording finalization completed"
755
+ if session_repo.mark_recording_failed_if_pending(sess_id, recording_error):
756
+ cls._broadcast_event(
757
+ "recording_failed",
758
+ {"session_id": sess_id, "error": recording_error},
759
+ )
760
+
761
+ @classmethod
762
+ def _announce_session_end(
763
+ cls,
764
+ task_item: dict[str, Any],
765
+ sess_id: Any,
766
+ goal: str,
767
+ new_status: str,
768
+ manual_stop: bool,
769
+ ) -> None:
770
+ """Broadcast session_ended and dispatch the external completion notification."""
771
+ state.active_connections.pop(sess_id, None)
772
+ cls._broadcast_event(
773
+ "session_ended",
774
+ {
775
+ "session_id": sess_id,
776
+ "status": new_status,
777
+ "was_stopped_manually": manual_stop,
778
+ },
779
+ )
780
+
781
+ conversation_id = task_item.get("conversation_id") if task_item else None
782
+ if conversation_id or task_item.get("ingress") == "mcp":
783
+ try:
784
+ from mcp_server.notifiers import notify
785
+
786
+ notify(
787
+ conversation_id=conversation_id or "",
788
+ message=f"Artemis autonomous task '{goal}' finished with status '{new_status}'.\nTrace ID: {sess_id}",
789
+ title=f"Task {new_status.capitalize()}: {goal[:40]}",
790
+ event_type=new_status,
791
+ payload={
792
+ "trace_id": sess_id,
793
+ "session_id": sess_id,
794
+ "status": new_status,
795
+ "goal": goal,
796
+ },
797
+ )
798
+ except Exception as notif_err:
799
+ print(f"[QueueWorker] Notification dispatch notice: {notif_err}")
800
+
801
+ @classmethod
802
+ def _release_run_slot(
803
+ cls, sess_id: Any, run_key: str, proc: asyncio.subprocess.Process | None
804
+ ) -> None:
805
+ """Clean up the finished task and release this run's scheduling slot."""
806
+ if sess_id:
807
+ cls._remove_task(sess_id)
808
+ state.cancelled_session_ids.discard(str(sess_id))
809
+ state.cancelled_session_ids.discard(run_key)
810
+ state.manually_stopped_run_ids.discard(run_key)
811
+ try:
812
+ clear_cancel_request(
813
+ session_id=str(sess_id) if sess_id else None,
814
+ pid=getattr(proc, "pid", None),
815
+ )
816
+ except (OSError, TypeError, ValueError):
817
+ # Marker cleanup is best-effort: an unwritable temp dir or an odd
818
+ # pid value must not block releasing the run slot.
819
+ pass
820
+ state.active_runs.pop(run_key, None)
821
+ if proc is not None and state.current_process is proc:
822
+ state.current_process = None
823
+ if not state.active_runs:
824
+ state.active_session_id = None
825
+ state.current_goal = None
826
+ state.current_profile = None
827
+ # No runs left: any not-yet-consumed manual-stop markers are
828
+ # stale and must not leak into future runs.
829
+ state.manually_stopped_run_ids.clear()
830
+ state.wake_event.set()
831
+
832
+ @classmethod
833
+ async def _execute_task_item(cls, task_item: dict[str, Any]) -> None:
834
+ """Run one queued task to completion in its own worker subprocess."""
835
+ sess_id = task_item.get("session_id")
836
+ run_key = str(sess_id) if sess_id else uuid.uuid4().hex
837
+ goal = task_item.get("goal")
838
+ profile = task_item.get("profile", "flash")
839
+ proc: asyncio.subprocess.Process | None = None
840
+ output_task: asyncio.Task[None] | None = None
841
+ try:
842
+ if not isinstance(goal, str) or not goal.strip():
843
+ raise ValueError("Queued task must contain a non-empty string goal.")
844
+ cls._begin_task_run(task_item, run_key, sess_id, goal, profile)
845
+
846
+ target = cls._task_target(task_item)
847
+ cmd, env = cls._build_worker_invocation(
848
+ task_item, run_key, sess_id, goal, profile, target
849
+ )
850
+
851
+ device_serial = task_item.get("device_serial")
852
+ print(
853
+ f"[QueueWorker] Starting task [{sess_id}]: '{goal}' (profile: {profile}, device: {device_serial or 'auto'}, outputter: {bool(task_item.get('expected_output') or task_item.get('enable_outputter'))})"
854
+ )
855
+ proc = await asyncio.create_subprocess_exec(
856
+ *cmd,
857
+ cwd=str(WORKSPACE_ROOT),
858
+ env=env,
859
+ **cls._subprocess_creation_kwargs(),
860
+ )
861
+ cls._register_worker_run(task_item, run_key, sess_id, goal, profile, target, proc)
862
+ output_task = cls._start_output_forwarder(sess_id, proc)
863
+ await cls._terminate_if_cancelled_during_launch(run_key, sess_id, proc)
864
+
865
+ # 3. Await subprocess completion
866
+ returncode = await cls._wait_for_worker_process(proc)
867
+ print(f"[QueueWorker] Task [{sess_id}] exited with returncode {returncode}")
868
+
869
+ manual_stop = run_key in state.manually_stopped_run_ids or bool(
870
+ sess_id and str(sess_id) in state.cancelled_session_ids
871
+ )
872
+
873
+ # 4. Perform fallback database status update and notification
874
+ if sess_id:
875
+ new_status = await cls._persist_terminal_session_status(
876
+ sess_id, returncode, manual_stop
877
+ )
878
+ await cls._recover_or_fail_recording(sess_id)
879
+ cls._announce_session_end(task_item, sess_id, goal, new_status, manual_stop)
880
+
881
+ except asyncio.CancelledError:
882
+ print(f"[QueueWorker] Task [{sess_id}] received cancellation signal.")
883
+ if proc is not None and proc.returncode is None:
884
+ await cls._terminate_worker_process(proc)
885
+ raise
886
+ except Exception:
887
+ logger.exception(f"[QueueWorker] Unexpected error executing task [{sess_id}]")
888
+ # A spawn failure must also leave a terminal session status.
889
+ if sess_id:
890
+ try:
891
+ new_status = await cls._persist_terminal_session_status(
892
+ sess_id, returncode=1, manual_stop=False
893
+ )
894
+ cls._announce_session_end(task_item, sess_id, goal or "", new_status, False)
895
+ except (OSError, RuntimeError, ValueError):
896
+ logger.exception(
897
+ f"[QueueWorker] Could not persist failure status for [{sess_id}]"
898
+ )
899
+ finally:
900
+ await cls._finish_output_forwarder(output_task)
901
+ # 5. Clean up the finished task and release this run's scheduling slot
902
+ cls._release_run_slot(sess_id, run_key, proc)
903
+
904
+ @classmethod
905
+ def _find_duplicate_submission(
906
+ cls,
907
+ goals: list[str],
908
+ session_id: str | None,
909
+ device_serial: str | None,
910
+ endpoint: AdbEndpoint,
911
+ now: float,
912
+ ) -> dict[str, Any] | None:
913
+ """Return the short-circuit response for a duplicate submission, if any."""
914
+ # 1. Deduplication by session_id: if session_id is already running or queued, do not re-enqueue
915
+ if session_id and len(goals) == 1:
916
+ target_sid = str(session_id)
917
+ if state.active_session_id and str(state.active_session_id) == target_sid:
918
+ return {
919
+ "status": "started",
920
+ "tasks": [{"session_id": target_sid, "status": "running"}],
921
+ "enqueued_count": 0,
922
+ "total_queued": len(state.queue_tasks),
923
+ }
924
+ existing_item = next(
925
+ (
926
+ item
927
+ for item in state.queue_items
928
+ if isinstance(item, dict) and str(item.get("session_id")) == target_sid
929
+ ),
930
+ None,
931
+ )
932
+ if existing_item:
933
+ return {
934
+ "status": existing_item.get("status", "queued"),
935
+ "tasks": [existing_item],
936
+ "enqueued_count": 0,
937
+ "total_queued": len(state.queue_tasks),
938
+ }
939
+
940
+ # 2. Debounce duplicate rapid submissions (e.g. UI double-click or network retry within 1s)
941
+ if len(goals) == 1:
942
+ first_goal = goals[0]
943
+ recent_duplicate = next(
944
+ (
945
+ item
946
+ for item in reversed(state.queue_items)
947
+ if isinstance(item, dict)
948
+ and item.get("status") == "pending"
949
+ and item.get("goal") == first_goal
950
+ and (not device_serial or item.get("device_serial") == device_serial)
951
+ and item.get("adb_endpoint", {}).get("identity") == endpoint.identity
952
+ and (now - float(item.get("created_at", 0))) < 1.0
953
+ ),
954
+ None,
955
+ )
956
+ if recent_duplicate:
957
+ return {
958
+ "status": "queued",
959
+ "tasks": [recent_duplicate],
960
+ "enqueued_count": 0,
961
+ "total_queued": len(state.queue_tasks),
962
+ }
963
+ return None
964
+
965
+ @classmethod
966
+ async def _reject_unavailable_device(cls, device_serial: str | None) -> dict[str, Any] | None:
967
+ """Return the rejection response for an unattached explicit serial, if any."""
968
+ # Strict device binding: reject an explicitly requested serial that is not
969
+ # attached and authorized, instead of silently running on another device.
970
+ # The shared validator fails open on an indeterminate/empty enumeration so
971
+ # the task can proceed and fail downstream with a clear no-device error.
972
+ if device_serial:
973
+ try:
974
+ from artemis.runtime import device_pool
975
+
976
+ rejection = await device_pool.validate_explicit_serial_async(device_serial)
977
+ except Exception:
978
+ rejection = None
979
+ if rejection:
980
+ return {
981
+ "status": "rejected",
982
+ "error": rejection,
983
+ "tasks": [],
984
+ "enqueued_count": 0,
985
+ "total_queued": len(state.queue_tasks),
986
+ }
987
+ return None
988
+
989
+ @classmethod
990
+ def _create_queue_item(
991
+ cls,
992
+ goal: str,
993
+ index: int,
994
+ now: float,
995
+ endpoint: AdbEndpoint,
996
+ single_session_id: str | None,
997
+ profile: str,
998
+ expected_output: str | None,
999
+ enable_outputter: bool | None,
1000
+ locked_app_package: str | None,
1001
+ app_path: str | None,
1002
+ device_serial: str | None,
1003
+ ingress: str,
1004
+ conversation_id: str | None,
1005
+ verification_level: str | None = None,
1006
+ explorer_mode: str | None = None,
1007
+ ) -> dict[str, Any]:
1008
+ """Reserve a device slot and build one pending queue item for a goal."""
1009
+ sess_id = single_session_id if single_session_id else str(uuid.uuid4())
1010
+ # enqueue_tasks resolves the device before creating queue items.
1011
+ assigned_serial = device_serial
1012
+
1013
+ queue_ticket = DeviceExecutionLock.reserve(
1014
+ description=f"{ingress} task: {goal[:120]}",
1015
+ device_id=assigned_serial or "pending",
1016
+ session_id=sess_id,
1017
+ ingress=ingress,
1018
+ lock_scope=endpoint.identity,
1019
+ )
1020
+ return {
1021
+ "session_id": sess_id,
1022
+ "goal": goal,
1023
+ "profile": profile or "flash",
1024
+ "expected_output": expected_output,
1025
+ "enable_outputter": enable_outputter,
1026
+ "verification_level": verification_level,
1027
+ "explorer_mode": explorer_mode,
1028
+ "locked_app_package": locked_app_package,
1029
+ "app_path": app_path,
1030
+ "device_serial": assigned_serial,
1031
+ "adb_endpoint": endpoint.to_dict(),
1032
+ "ingress": ingress,
1033
+ "conversation_id": conversation_id,
1034
+ "status": "pending",
1035
+ "queue_ticket": queue_ticket,
1036
+ "created_at": now + index * 0.001,
1037
+ "start_time": now + index * 0.001,
1038
+ }
1039
+
1040
+ @classmethod
1041
+ async def enqueue_tasks(
1042
+ cls,
1043
+ goals: list[str],
1044
+ profile: str = "flash",
1045
+ expected_output: str | None = None,
1046
+ enable_outputter: bool | None = None,
1047
+ locked_app_package: str | None = None,
1048
+ app_path: str | None = None,
1049
+ device_serial: str | None = None,
1050
+ ingress: str = "frontend",
1051
+ session_id: str | None = None,
1052
+ conversation_id: str | None = None,
1053
+ verification_level: str | None = None,
1054
+ explorer_mode: str | None = None,
1055
+ ) -> dict[str, Any]:
1056
+ """Enqueues one or more goals and wakes up the background worker.
1057
+
1058
+ ``verification_level`` and ``explorer_mode`` are Pro-profile tuning knobs
1059
+ forwarded to the worker as ``--verification-level`` / ``--explorer-pro-mode``;
1060
+ they are normalised here so the queue item and the CLI see one spelling.
1061
+ """
1062
+ verification_level = (
1063
+ str(verification_level).strip().lower() or None if verification_level else None
1064
+ )
1065
+ explorer_mode = str(explorer_mode).strip().lower() or None if explorer_mode else None
1066
+ cls.ensure_worker_running()
1067
+
1068
+ enqueued_tasks = []
1069
+ now = time.time()
1070
+ endpoint = current_adb_endpoint()
1071
+
1072
+ duplicate_response = cls._find_duplicate_submission(
1073
+ goals, session_id, device_serial, endpoint, now
1074
+ )
1075
+ if duplicate_response is not None:
1076
+ return duplicate_response
1077
+
1078
+ rejection_response = await cls._reject_unavailable_device(device_serial)
1079
+ if rejection_response is not None:
1080
+ return rejection_response
1081
+
1082
+ single_session_id = session_id if (session_id and len(goals) == 1) else None
1083
+ if not device_serial:
1084
+ # Device enumeration may block on ADB.
1085
+ from artemis.runtime import device_pool
1086
+
1087
+ try:
1088
+ device_serial = await device_pool.select_device_async()
1089
+ except Exception:
1090
+ device_serial = None
1091
+ for i, goal in enumerate(goals):
1092
+ task_item = cls._create_queue_item(
1093
+ goal,
1094
+ i,
1095
+ now,
1096
+ endpoint,
1097
+ single_session_id,
1098
+ profile,
1099
+ expected_output,
1100
+ enable_outputter,
1101
+ locked_app_package,
1102
+ app_path,
1103
+ device_serial,
1104
+ ingress,
1105
+ conversation_id,
1106
+ verification_level=verification_level,
1107
+ explorer_mode=explorer_mode,
1108
+ )
1109
+ state.queue_items.append(task_item)
1110
+ enqueued_tasks.append(task_item)
1111
+ cls._broadcast_startup_progress(
1112
+ task_item["session_id"], "queued", "Task received and queued"
1113
+ )
1114
+
1115
+ # Wake worker immediately
1116
+ state.wake_event.set()
1117
+
1118
+ return {
1119
+ "status": "queued" if state.is_running else "started",
1120
+ "tasks": enqueued_tasks,
1121
+ "enqueued_count": len(goals),
1122
+ "total_queued": len(state.queue_tasks),
1123
+ }
1124
+
1125
+ @staticmethod
1126
+ def _clear_pause_file() -> None:
1127
+ """Remove a leftover pause marker after a stop request."""
1128
+ if PAUSE_FILE.exists():
1129
+ try:
1130
+ PAUSE_FILE.unlink()
1131
+ except OSError:
1132
+ # Best-effort cleanup of the pause marker; a leftover file
1133
+ # only pauses until the next resume request.
1134
+ pass
1135
+
1136
+ @classmethod
1137
+ def _terminate_all_device_owners(cls) -> None:
1138
+ """Terminate every device lock owner and persist each cancellation."""
1139
+ active_owners: dict[str, Any] = {}
1140
+ try:
1141
+ active_owners = DeviceExecutionLock.get_active_owners()
1142
+ except OSError as exc:
1143
+ # Unreadable lock dir: fall back to the single-owner probe below.
1144
+ print(f"[stop_tasks] Could not enumerate device owners: {exc}")
1145
+ fallback = DeviceExecutionLock.get_active_owner()
1146
+ if fallback and not active_owners:
1147
+ active_owners["default"] = fallback
1148
+
1149
+ for dev_owner in list(active_owners.values()):
1150
+ if dev_owner and DeviceExecutionLock.is_active_owner(dev_owner):
1151
+ cls._stop_worker_gracefully(
1152
+ dev_owner.pid,
1153
+ dev_owner.process_created_at,
1154
+ session_id=dev_owner.session_id,
1155
+ )
1156
+ DeviceExecutionLock.cleanup_stale_locks(dev_owner.device_id)
1157
+ sid = dev_owner.session_id
1158
+ if sid:
1159
+ session_repo.update_session_status(str(sid), "cancelled", time.time())
1160
+ try:
1161
+ is_mcp = bool(dev_owner and dev_owner.ingress == "mcp")
1162
+ if is_mcp or trace_store.read_status(str(sid)):
1163
+ trace_store.update_trace_status(
1164
+ str(sid),
1165
+ "cancelled",
1166
+ error="Task stopped from the Artemis frontend.",
1167
+ )
1168
+ except OSError as exc:
1169
+ print(
1170
+ f"[stop_tasks] Could not persist cancelled MCP status for {sid}: {exc}"
1171
+ )
1172
+ cls._broadcast_event(
1173
+ "session_ended",
1174
+ {
1175
+ "session_id": sid,
1176
+ "status": "cancelled",
1177
+ "was_stopped_manually": True,
1178
+ },
1179
+ )
1180
+
1181
+ @classmethod
1182
+ def _kill_all_local_runs(cls) -> None:
1183
+ """Mark every locally-managed run manually stopped and kill its process."""
1184
+ for run_key, run in list(state.active_runs.items()):
1185
+ # Mark each run individually: the global "stopped manually"
1186
+ # boolean was shared process-wide and polluted the terminal
1187
+ # status of unrelated concurrent runs.
1188
+ state.manually_stopped_run_ids.add(str(run_key))
1189
+ # Also track it per session so each finalizer resolves its
1190
+ # terminal status as cancelled.
1191
+ state.cancelled_session_ids.add(str(run_key))
1192
+ run_proc = run.get("process")
1193
+ if run_proc is not None and run_proc.returncode is None:
1194
+ if not cls._stop_proc_gracefully(run_proc, run.get("session_id") or run_key):
1195
+ try:
1196
+ run_proc.kill()
1197
+ except (ProcessLookupError, OSError):
1198
+ # Process already exited between the check and the kill.
1199
+ pass
1200
+ # active_runs entries are popped by each run's finalizer once the
1201
+ # process exit is observed; clearing them here would free the device
1202
+ # slots before the processes are actually gone.
1203
+ if state.current_process:
1204
+ if not cls._stop_proc_gracefully(state.current_process, state.active_session_id):
1205
+ try:
1206
+ state.current_process.kill()
1207
+ except (ProcessLookupError, OSError):
1208
+ # Process already exited between the check and the kill.
1209
+ pass
1210
+ state.current_process = None
1211
+
1212
+ @classmethod
1213
+ def _stop_all_tasks(cls) -> bool:
1214
+ """Terminate all active device owners and clear pending queue submissions."""
1215
+ # 1. Cancel local queue reservations
1216
+ for item in state.queue_items:
1217
+ if isinstance(item, dict) and item.get("status") != "running":
1218
+ DeviceExecutionLock.cancel_reservation(item.get("queue_ticket"))
1219
+ state.clear_queue()
1220
+
1221
+ # 2. Terminate all active owners across all devices
1222
+ cls._terminate_all_device_owners()
1223
+ cls._kill_all_local_runs()
1224
+
1225
+ state.active_connections.clear()
1226
+ state.active_session_id = None
1227
+ state.current_goal = None
1228
+ state.current_profile = None
1229
+
1230
+ cls._clear_pause_file()
1231
+
1232
+ cls.ensure_worker_running()
1233
+ state.wake_event.set()
1234
+ return True
1235
+
1236
+ @classmethod
1237
+ def _resolve_stop_owner(
1238
+ cls,
1239
+ active_owners: dict[str, Any],
1240
+ target_sid: str | None,
1241
+ target_device: str | None,
1242
+ ) -> Any:
1243
+ """Resolve the device lock owner targeted by this stop request, if any."""
1244
+ owner = None
1245
+ if target_sid:
1246
+ for dev_owner in active_owners.values():
1247
+ if dev_owner.session_id and str(dev_owner.session_id) == target_sid:
1248
+ owner = dev_owner
1249
+ break
1250
+ if owner is None and target_device:
1251
+ clean_target_dev = DeviceExecutionLock._normalize_device_id(target_device)
1252
+ for dev_key, dev_owner in active_owners.items():
1253
+ if dev_key == clean_target_dev or dev_owner.device_id == target_device:
1254
+ owner = dev_owner
1255
+ break
1256
+ if owner is None:
1257
+ # Covers scoped/legacy lock records that get_active_owners cannot
1258
+ # key by session or device. A stale owner for a different session
1259
+ # is never adopted: with no live owner, a session-targeted stop
1260
+ # falls through to the queue/session-repository fallbacks below.
1261
+ fallback_owner = DeviceExecutionLock.get_active_owner(target_device)
1262
+ if fallback_owner and (
1263
+ not target_sid
1264
+ or (fallback_owner.session_id and str(fallback_owner.session_id) == target_sid)
1265
+ ):
1266
+ owner = fallback_owner
1267
+ return owner
1268
+
1269
+ @classmethod
1270
+ def _resolve_local_run(
1271
+ cls, target_sid: str | None, target_device: str | None
1272
+ ) -> tuple[str | None, dict[str, Any] | None, Any]:
1273
+ """Resolve the locally-managed run for this target.
1274
+
1275
+ Concurrent scheduling keeps one subprocess per run in
1276
+ state.active_runs. Returns ``(local_run_key, local_run, local_proc)``.
1277
+ """
1278
+ local_run = None
1279
+ local_run_key: str | None = None
1280
+ if target_sid:
1281
+ local_run = state.active_runs.get(target_sid)
1282
+ if local_run is not None:
1283
+ local_run_key = target_sid
1284
+ elif target_device:
1285
+ local_run_key, local_run = next(
1286
+ (
1287
+ (key, run)
1288
+ for key, run in state.active_runs.items()
1289
+ if run.get("device_id") and str(run["device_id"]) == target_device
1290
+ ),
1291
+ (None, None),
1292
+ )
1293
+ elif len(state.active_runs) == 1:
1294
+ # Untargeted stop (legacy single-device gesture): with exactly one
1295
+ # live run the target is unambiguous.
1296
+ local_run_key, local_run = next(iter(state.active_runs.items()))
1297
+ local_proc = (local_run or {}).get("process")
1298
+ if local_proc is None and not target_sid and not target_device:
1299
+ # Untargeted legacy fallback only: the last-started process may
1300
+ # stand in when no per-run entry resolved. An explicitly targeted
1301
+ # stop that matched no run must never grab current_process -- it
1302
+ # mirrors the newest run, which can be an unrelated concurrent one.
1303
+ local_proc = state.current_process
1304
+ if local_run is None and local_proc is not None:
1305
+ # Legacy path: current_process without a resolved run entry. Find
1306
+ # the run that owns this process so a manual stop can be attributed
1307
+ # to it instead of to the whole scheduler.
1308
+ local_run_key = next(
1309
+ (
1310
+ str(key)
1311
+ for key, run in state.active_runs.items()
1312
+ if run.get("process") is local_proc
1313
+ ),
1314
+ None,
1315
+ )
1316
+ return local_run_key, local_run, local_proc
1317
+
1318
+ @classmethod
1319
+ def _find_target_queue_item(cls, target_sid: str | None) -> dict[str, Any] | None:
1320
+ """Find the running (preferred) or pending queue item for this target."""
1321
+ running_item = next(
1322
+ (
1323
+ item
1324
+ for item in state.queue_items
1325
+ if isinstance(item, dict)
1326
+ and item.get("status") == "running"
1327
+ and (not target_sid or str(item.get("session_id")) == target_sid)
1328
+ ),
1329
+ None,
1330
+ )
1331
+ return running_item or next(
1332
+ (
1333
+ item
1334
+ for item in state.queue_items
1335
+ if isinstance(item, dict)
1336
+ and item.get("status") == "pending"
1337
+ and (not target_sid or str(item.get("session_id")) == target_sid)
1338
+ ),
1339
+ None,
1340
+ )
1341
+
1342
+ @classmethod
1343
+ def _resolve_stopped_session_id(
1344
+ cls,
1345
+ owner: Any,
1346
+ target_sid: str | None,
1347
+ local_run_key: str | None,
1348
+ is_local_owner: bool,
1349
+ local_item: dict[str, Any] | None,
1350
+ ) -> Any:
1351
+ """Attribute the stop request to a session id, if one can be resolved."""
1352
+ # active_session_id mirrors the most recently launched run, so with
1353
+ # concurrent runs the resolved local run key (== its session id) must
1354
+ # take precedence to avoid attributing the stop to an unrelated run.
1355
+ return (
1356
+ owner.session_id
1357
+ if owner and owner.session_id
1358
+ else target_sid
1359
+ if target_sid
1360
+ else local_run_key
1361
+ if local_run_key is not None
1362
+ else state.active_session_id
1363
+ if is_local_owner
1364
+ else local_item.get("session_id")
1365
+ if local_item
1366
+ else None
1367
+ )
1368
+
1369
+ @classmethod
1370
+ def _terminate_stop_target(
1371
+ cls,
1372
+ owner: Any,
1373
+ owner_record_exists: bool,
1374
+ target_sid: str | None,
1375
+ local_run: dict[str, Any] | None,
1376
+ local_run_key: str | None,
1377
+ local_proc: Any,
1378
+ local_pid: Any,
1379
+ local_item: dict[str, Any] | None,
1380
+ is_local_owner: bool,
1381
+ ) -> tuple[bool, bool, bool] | None:
1382
+ """Terminate the resolved stop target.
1383
+
1384
+ Returns ``(stopped, is_local_owner, reservation_cancelled)``, or
1385
+ ``None`` when the caller must refuse the stop outright.
1386
+ """
1387
+ stopped = False
1388
+ reservation_cancelled = False
1389
+ if owner and DeviceExecutionLock.is_active_owner(owner):
1390
+ stopped, _deferred = cls._stop_worker_gracefully(
1391
+ owner.pid,
1392
+ owner.process_created_at,
1393
+ session_id=owner.session_id or target_sid,
1394
+ )
1395
+ DeviceExecutionLock.cleanup_stale_locks(owner.device_id)
1396
+ elif owner is None and owner_record_exists and not target_sid:
1397
+ # Never fall back to a frontend PID while another process has an
1398
+ # owner record that is still being published or cannot be parsed.
1399
+ return None
1400
+ elif (
1401
+ owner is None
1402
+ and local_proc is not None
1403
+ and (
1404
+ local_run is not None
1405
+ or not target_sid
1406
+ or str(state.active_session_id) == target_sid
1407
+ )
1408
+ ):
1409
+ # The locally-managed worker can be stopped during its short
1410
+ # initialization window before Agent acquires the device lease.
1411
+ if target_sid:
1412
+ state.cancelled_session_ids.add(target_sid)
1413
+ elif local_run_key is not None:
1414
+ state.manually_stopped_run_ids.add(str(local_run_key))
1415
+ deferred = False
1416
+ if local_pid:
1417
+ try:
1418
+ stopped, deferred = cls._stop_worker_gracefully(
1419
+ local_pid,
1420
+ session_id=target_sid or (local_run or {}).get("session_id"),
1421
+ )
1422
+ except Exception:
1423
+ stopped = False
1424
+ if not deferred:
1425
+ try:
1426
+ local_proc.kill()
1427
+ stopped = True
1428
+ except (ProcessLookupError, OSError):
1429
+ # Process already exited; the stop above may have got it.
1430
+ pass
1431
+ stopped = True
1432
+ is_local_owner = True
1433
+ elif (
1434
+ owner is None
1435
+ and local_item
1436
+ and (not target_sid or str(local_item.get("session_id")) == target_sid)
1437
+ ):
1438
+ # Cancel a frontend submission before its worker has started. This
1439
+ # does not touch pending reservations created by other ingresses.
1440
+ DeviceExecutionLock.cancel_reservation(local_item.get("queue_ticket"))
1441
+ reservation_cancelled = True
1442
+ stopped = True
1443
+ is_local_owner = True
1444
+ elif target_sid:
1445
+ # Fallback 1: check global device queue
1446
+ global_queued = DeviceExecutionLock.get_queued_tasks()
1447
+ for q_item in global_queued:
1448
+ if str(q_item.get("session_id")) == target_sid:
1449
+ # get_queued_tasks exposes the reservation as "token".
1450
+ stopped = DeviceExecutionLock.cancel_reservation(q_item.get("token"))
1451
+ break
1452
+ # Fallback 2: check session repository for a running session with a live worker PID
1453
+ if not stopped:
1454
+ row = session_repo.get_session_by_id(target_sid)
1455
+ if row and row.get("status") == "running":
1456
+ row_pid = row.get("pid")
1457
+ if row_pid and session_repo.process_is_alive(row_pid):
1458
+ try:
1459
+ cls._stop_worker_gracefully(int(row_pid), session_id=target_sid)
1460
+ except Exception as exc:
1461
+ # Report it: a surviving worker keeps the device busy.
1462
+ print(f"[stop_tasks] Could not terminate worker pid {row_pid}: {exc}")
1463
+ DeviceExecutionLock.cleanup_stale_locks()
1464
+ stopped = True
1465
+ return stopped, is_local_owner, reservation_cancelled
1466
+
1467
+ @classmethod
1468
+ def _finalize_targeted_stop(
1469
+ cls,
1470
+ stopped_session_id: Any,
1471
+ owner: Any,
1472
+ owner_pid: Any,
1473
+ is_local_owner: bool,
1474
+ local_run_key: str | None,
1475
+ local_proc: Any,
1476
+ ) -> None:
1477
+ """Propagate the stop into shared state, the DB, and event subscribers."""
1478
+ if is_local_owner:
1479
+ if stopped_session_id:
1480
+ # Per-session cancellation: the run's own finalizer resolves the
1481
+ # terminal status without affecting other concurrent runs.
1482
+ state.cancelled_session_ids.add(str(stopped_session_id))
1483
+ elif local_run_key is not None:
1484
+ state.manually_stopped_run_ids.add(str(local_run_key))
1485
+ if local_proc is not None and state.current_process is local_proc:
1486
+ state.current_process = None
1487
+
1488
+ for sid, conn_info in list(state.active_connections.items()):
1489
+ if (stopped_session_id and str(sid) == str(stopped_session_id)) or (
1490
+ owner_pid and conn_info.get("pid") == owner_pid
1491
+ ):
1492
+ state.active_connections.pop(sid, None)
1493
+
1494
+ if stopped_session_id:
1495
+ session_repo.update_session_status(str(stopped_session_id), "cancelled", time.time())
1496
+ try:
1497
+ is_mcp = bool(owner and owner.ingress == "mcp")
1498
+ if is_mcp or trace_store.read_status(str(stopped_session_id)):
1499
+ trace_store.update_trace_status(
1500
+ str(stopped_session_id),
1501
+ "cancelled",
1502
+ error="Task stopped from the Artemis frontend.",
1503
+ )
1504
+ except Exception as exc:
1505
+ print(f"Failed to update MCP cancellation status: {exc}")
1506
+ cls._broadcast_event(
1507
+ "session_ended",
1508
+ {
1509
+ "session_id": stopped_session_id,
1510
+ "status": "cancelled",
1511
+ "was_stopped_manually": True,
1512
+ },
1513
+ )
1514
+
1515
+ if state.active_session_id and (
1516
+ not stopped_session_id or str(state.active_session_id) == str(stopped_session_id)
1517
+ ):
1518
+ state.active_session_id = None
1519
+ state.current_goal = None
1520
+ state.current_profile = None
1521
+
1522
+ @classmethod
1523
+ def _remove_stopped_queue_item(
1524
+ cls, stopped_session_id: Any, reservation_cancelled: bool
1525
+ ) -> None:
1526
+ """Drop the stopped session's queue item and cancel its reservation."""
1527
+ if not stopped_session_id:
1528
+ return
1529
+ stopped_item = next(
1530
+ (
1531
+ item
1532
+ for item in state.queue_items
1533
+ if isinstance(item, dict) and str(item.get("session_id")) == str(stopped_session_id)
1534
+ ),
1535
+ None,
1536
+ )
1537
+ if stopped_item and not reservation_cancelled:
1538
+ DeviceExecutionLock.cancel_reservation(stopped_item.get("queue_ticket"))
1539
+ state.queue_items = [
1540
+ item
1541
+ for item in state.queue_items
1542
+ if not (
1543
+ isinstance(item, dict) and str(item.get("session_id")) == str(stopped_session_id)
1544
+ )
1545
+ ]
1546
+
1547
+ @classmethod
1548
+ def _stop_targeted_task(cls, target_sid: str | None, target_device: str | None) -> bool:
1549
+ """Stop a specific task (or default single-device active task)."""
1550
+ active_owners = {}
1551
+ try:
1552
+ active_owners = DeviceExecutionLock.get_active_owners()
1553
+ except OSError as exc:
1554
+ # Unreadable lock dir: the local-process fallbacks below still apply.
1555
+ print(f"[stop_tasks] Could not enumerate device owners: {exc}")
1556
+
1557
+ owner = cls._resolve_stop_owner(active_owners, target_sid, target_device)
1558
+
1559
+ owner_record_exists = DeviceExecutionLock.has_owner_record(target_device)
1560
+ owner_pid = owner.pid if owner else None
1561
+
1562
+ local_run_key, local_run, local_proc = cls._resolve_local_run(target_sid, target_device)
1563
+ local_pid = getattr(local_proc, "pid", None)
1564
+ is_local_owner = bool(owner_pid and local_pid and owner_pid == local_pid)
1565
+
1566
+ local_item = cls._find_target_queue_item(target_sid)
1567
+ stopped_session_id = cls._resolve_stopped_session_id(
1568
+ owner, target_sid, local_run_key, is_local_owner, local_item
1569
+ )
1570
+
1571
+ outcome = cls._terminate_stop_target(
1572
+ owner,
1573
+ owner_record_exists,
1574
+ target_sid,
1575
+ local_run,
1576
+ local_run_key,
1577
+ local_proc,
1578
+ local_pid,
1579
+ local_item,
1580
+ is_local_owner,
1581
+ )
1582
+ if outcome is None:
1583
+ return False
1584
+ stopped, is_local_owner, reservation_cancelled = outcome
1585
+
1586
+ if not stopped and not target_sid:
1587
+ return False
1588
+
1589
+ cls._finalize_targeted_stop(
1590
+ stopped_session_id,
1591
+ owner,
1592
+ owner_pid,
1593
+ is_local_owner,
1594
+ local_run_key,
1595
+ local_proc,
1596
+ )
1597
+ cls._remove_stopped_queue_item(stopped_session_id, reservation_cancelled)
1598
+
1599
+ cls._clear_pause_file()
1600
+
1601
+ cls.ensure_worker_running()
1602
+ state.wake_event.set()
1603
+ return True
1604
+
1605
+ @classmethod
1606
+ def stop_tasks(
1607
+ cls,
1608
+ clear_all: bool = False,
1609
+ session_id: str | None = None,
1610
+ device_id: str | None = None,
1611
+ ) -> bool:
1612
+ """Stop the active task controlling a mobile device or all tasks.
1613
+
1614
+ The active lease is shared by frontend, MCP, CLI, SDK, and other UI
1615
+ processes across all connected devices.
1616
+ - If ``session_id`` or ``device_id`` is specified, only the corresponding
1617
+ running or queued task is cancelled.
1618
+ - If ``clear_all`` is requested, all active device owners are terminated
1619
+ and pending queue submissions are cleared.
1620
+ - If no specific task is specified and ``clear_all`` is False, stops the
1621
+ currently active task in single-device mode for backward compatibility.
1622
+ """
1623
+ target_sid = str(session_id).strip() if session_id else None
1624
+ target_device = str(device_id).strip() if device_id else None
1625
+
1626
+ if clear_all:
1627
+ return cls._stop_all_tasks()
1628
+
1629
+ return cls._stop_targeted_task(target_sid, target_device)
1630
+
1631
+ @classmethod
1632
+ def resume_task(cls) -> bool:
1633
+ if PAUSE_FILE.exists():
1634
+ PAUSE_FILE.unlink()
1635
+ return True
1636
+ return False
1637
+
1638
+ @classmethod
1639
+ def recover_orphaned_recordings_on_launch(cls) -> int:
1640
+ """Finalize recordings left behind by workers that died with the daemon.
1641
+
1642
+ The per-run finalizer handles workers that exit while the daemon is up;
1643
+ this sweep covers everything else (daemon crash, machine reboot, tasks
1644
+ cancelled before this recovery path existed). Returns the number of
1645
+ recordings published.
1646
+ """
1647
+ recovered = 0
1648
+ try:
1649
+ pending = session_repo.get_unfinalized_video_recordings()
1650
+ except Exception as exc:
1651
+ print(f"[ServerStartup] Could not enumerate unfinalized recordings: {exc}")
1652
+ return 0
1653
+ for row in pending:
1654
+ sess_id = row.get("session_id")
1655
+ try:
1656
+ final_path = media_service.recover_orphaned_recording(
1657
+ row.get("local_video_path"),
1658
+ row.get("start_time") or row.get("session_start_time"),
1659
+ )
1660
+ except Exception as exc:
1661
+ print(f"[ServerStartup] Recording recovery failed for {sess_id}: {exc}")
1662
+ continue
1663
+ if not final_path:
1664
+ continue
1665
+ if session_repo.mark_recording_ready(str(sess_id), str(final_path)):
1666
+ recovered += 1
1667
+ print(f"[ServerStartup] Recovered recording for session {sess_id}: {final_path}")
1668
+ if recovered:
1669
+ print(f"[ServerStartup] Recovered {recovered} orphaned recording(s).")
1670
+ return recovered
1671
+
1672
+ @staticmethod
1673
+ def archive_older_replays_on_launch():
1674
+ """Archives all existing step replay output folders on server launch."""
1675
+ if TEST_OUTPUTS_DIR.exists():
1676
+ archive_dir = TEST_DATA_DIR / "older"
1677
+ for item in TEST_OUTPUTS_DIR.iterdir():
1678
+ if item.is_dir():
1679
+ archive_dir.mkdir(parents=True, exist_ok=True)
1680
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
1681
+ target_name = f"{item.name}_{timestamp}"
1682
+ target_path = archive_dir / target_name
1683
+
1684
+ counter = 1
1685
+ while target_path.exists():
1686
+ target_name = f"{item.name}_{timestamp}_{counter}"
1687
+ target_path = archive_dir / target_name
1688
+ counter += 1
1689
+
1690
+ print(
1691
+ f"Archiving older replay record on server launch: "
1692
+ f"{item.name} -> {target_path}"
1693
+ )
1694
+ try:
1695
+ shutil.move(str(item), str(target_path))
1696
+ except Exception as e:
1697
+ print(
1698
+ f"Warning: Failed to archive {item.name} during server launch: {e}",
1699
+ file=sys.stderr,
1700
+ )
1701
+
1702
+ @staticmethod
1703
+ def verify_chunks_exist_on_launch(replay_manager):
1704
+ """Verifies that chunked directories exist for all sessions in the database."""
1705
+ print("Verifying session chunks on launch...")
1706
+ try:
1707
+ sessions = session_repo.get_all_sessions()
1708
+ session_ids = [s.get("session_id") for s in sessions if s.get("session_id")]
1709
+ print(f"Found {len(session_ids)} sessions in database to verify.")
1710
+ for session_id in session_ids:
1711
+ replay_manager._ensure_session_chunked(str(session_id))
1712
+ except Exception as e:
1713
+ print(
1714
+ f"Warning: Failed to verify chunks during server launch: {e}",
1715
+ file=sys.stderr,
1716
+ )
1717
+
1718
+
1719
+ task_queue_service = TaskQueueService()