android-midscene-automation 0.2.4 → 1.1.0

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 +7 -0
  2. package/README.md +35 -0
  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,1013 @@
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
+ """Cross-process FIFO execution queue and per-device mutex for Artemis devices."""
16
+
17
+ from __future__ import annotations
18
+
19
+ from dataclasses import dataclass
20
+ from datetime import UTC, datetime
21
+ import json
22
+ import os
23
+ from pathlib import Path
24
+ import re
25
+ import time
26
+ from typing import Any
27
+ import uuid
28
+
29
+ from artemis.config.paths import get_temp_dir
30
+ from artemis.runtime.process_probe import pid_is_alive
31
+
32
+
33
+ class DeviceBusyError(RuntimeError):
34
+ """Raised when another Artemis process already owns a device."""
35
+
36
+
37
+ class ConcurrencyMode:
38
+ """Supported concurrency strategies for Artemis task execution."""
39
+
40
+ GLOBAL = "global" # Strict serial mode: 1 task globally across all devices
41
+ PER_DEVICE = "per_device" # 1 task per device: concurrent across different devices
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class DeviceLockOwner:
46
+ pid: int
47
+ process_created_at: float
48
+ token: str
49
+ device_id: str
50
+ description: str
51
+ acquired_at: str
52
+ session_id: str | None = None
53
+ ingress: str | None = None
54
+ lock_scope: str | None = None
55
+
56
+ @classmethod
57
+ def from_dict(cls, value: dict) -> DeviceLockOwner:
58
+ return cls(
59
+ pid=int(value["pid"]),
60
+ process_created_at=float(value["process_created_at"]),
61
+ token=str(value["token"]),
62
+ device_id=str(value["device_id"]),
63
+ description=str(value.get("description", "unknown task")),
64
+ acquired_at=str(value.get("acquired_at", "unknown time")),
65
+ session_id=(str(value["session_id"]) if value.get("session_id") is not None else None),
66
+ ingress=(str(value["ingress"]) if value.get("ingress") is not None else None),
67
+ lock_scope=(str(value["lock_scope"]) if value.get("lock_scope") is not None else None),
68
+ )
69
+
70
+
71
+ class DeviceExecutionLock:
72
+ """A PID-aware FIFO lease supporting per-device mutexes and global concurrency limits.
73
+
74
+ Artemis enforces a single active task per mobile device at any time.
75
+ Different devices can execute tasks concurrently unless a global concurrency
76
+ limit (e.g. concurrency_mode="global" or ARTEMIS_MAX_CONCURRENT_TASKS=1) is configured.
77
+
78
+ Atomic file creation provides exclusion per device serial. PID creation time
79
+ prevents stale owners or queue tickets from blocking execution after a process
80
+ exits or after the operating system reuses the same PID.
81
+ """
82
+
83
+ _MALFORMED_LOCK_GRACE_SECONDS = 5.0
84
+ _DEFAULT_POLL_INTERVAL_SECONDS = 0.1
85
+ QUEUE_TICKET_ENV = "ARTEMIS_DEVICE_QUEUE_TICKET"
86
+ LOCK_SCOPE_ENV = "ARTEMIS_ADB_ENDPOINT_ID"
87
+
88
+ @staticmethod
89
+ def _normalize_device_id(device_id: str | None) -> str:
90
+ """Normalize device serial/identifier to a filesystem-safe string."""
91
+ if not device_id or str(device_id).strip() in ("", "default-device", "default"):
92
+ return "default"
93
+ raw = str(device_id).strip()
94
+ if raw.lower() in ("pending", "any"):
95
+ return raw.lower()
96
+ clean = re.sub(r"[^a-zA-Z0-9_-]", "_", raw)
97
+ return clean or "default"
98
+
99
+ @classmethod
100
+ def _normalize_lock_id(
101
+ cls,
102
+ device_id: str | None,
103
+ lock_scope: str | None = None,
104
+ ) -> str:
105
+ clean_device = cls._normalize_device_id(device_id)
106
+ clean_scope = cls._normalize_device_id(lock_scope) if lock_scope else None
107
+ return f"{clean_scope}__{clean_device}" if clean_scope else clean_device
108
+
109
+ def __init__(
110
+ self,
111
+ device_id: str = "default",
112
+ description: str = "Artemis task",
113
+ queue_ticket: str | None = None,
114
+ max_concurrency: int | None = None,
115
+ concurrency_mode: str | None = None,
116
+ session_id: str | None = None,
117
+ ingress: str | None = None,
118
+ lock_scope: str | None = None,
119
+ ):
120
+ self.device_id = device_id or "default"
121
+ self.lock_scope = lock_scope or os.getenv(self.LOCK_SCOPE_ENV) or None
122
+ self.clean_device_id = self._normalize_lock_id(self.device_id, self.lock_scope)
123
+ self.description = description
124
+ self.max_concurrency = max_concurrency
125
+ self.concurrency_mode = (
126
+ str(concurrency_mode).strip().lower() if concurrency_mode is not None else None
127
+ )
128
+ self.token = uuid.uuid4().hex
129
+
130
+ lock_dir = get_temp_dir("device-locks")
131
+ # Per-device lock file
132
+ self.path = lock_dir / f"artemis-device-{self.clean_device_id}.lock"
133
+ # Shared FIFO wait queue directory across all processes
134
+ self.queue_dir = lock_dir / "artemis-global-device.queue"
135
+ self._initial_queue_ticket = queue_ticket or os.environ.pop(self.QUEUE_TICKET_ENV, None)
136
+ self.queue_ticket = self._initial_queue_ticket
137
+ self.session_id = (
138
+ session_id or os.getenv("ARTEMIS_SESSION_ID") or os.getenv("ARTEMIS_CLOUD_SESSION_ID")
139
+ )
140
+ self.ingress = ingress or os.getenv("ARTEMIS_TASK_INGRESS") or "sdk"
141
+ self._queue_path: Path | None = None
142
+ self._acquired = False
143
+
144
+ @classmethod
145
+ def _device_lock_path(
146
+ cls,
147
+ device_id: str | None,
148
+ lock_scope: str | None = None,
149
+ ) -> Path:
150
+ clean_id = cls._normalize_lock_id(
151
+ device_id,
152
+ lock_scope or os.getenv(cls.LOCK_SCOPE_ENV) or None,
153
+ )
154
+ return get_temp_dir("device-locks") / f"artemis-device-{clean_id}.lock"
155
+
156
+ @classmethod
157
+ def _global_lock_path(cls) -> Path:
158
+ # Kept for backward compatibility and fallback detection
159
+ return get_temp_dir("device-locks") / "artemis-global-device.lock"
160
+
161
+ @staticmethod
162
+ def _current_process_created_at() -> float:
163
+ try:
164
+ import psutil
165
+
166
+ return float(psutil.Process(os.getpid()).create_time())
167
+ except Exception:
168
+ return 0.0
169
+
170
+ @staticmethod
171
+ def _safe_unlink(path: Path, max_retries: int = 3, delay: float = 0.02) -> bool:
172
+ """Safely unlink a file with retries on Windows PermissionError / sharing violations."""
173
+ for i in range(max_retries):
174
+ try:
175
+ path.unlink(missing_ok=True)
176
+ return True
177
+ except OSError:
178
+ if i < max_retries - 1:
179
+ time.sleep(delay)
180
+ return False
181
+
182
+ @staticmethod
183
+ def _safe_replace(src: Path, dst: Path, max_retries: int = 3, delay: float = 0.02) -> bool:
184
+ """Safely replace a file with retries on Windows PermissionError / sharing violations."""
185
+ for i in range(max_retries):
186
+ try:
187
+ os.replace(src, dst)
188
+ return True
189
+ except OSError:
190
+ if i < max_retries - 1:
191
+ time.sleep(delay)
192
+ return False
193
+
194
+ @staticmethod
195
+ def _owner_is_alive(owner: DeviceLockOwner) -> bool:
196
+ if owner.pid <= 0:
197
+ return False
198
+ if owner.process_created_at <= 0:
199
+ # Legacy owner payloads without a create time cannot use PID-reuse
200
+ # protection; require an Artemis-looking process before trusting
201
+ # the record (a recycled PID must not keep a lock alive forever).
202
+ try:
203
+ import psutil
204
+
205
+ process = psutil.Process(owner.pid)
206
+ if not process.is_running():
207
+ return False
208
+ name = process.name().lower()
209
+ cmdline = " ".join(process.cmdline()).lower()
210
+ return any(k in name or k in cmdline for k in ("python", "artemis", "pytest"))
211
+ except Exception:
212
+ return False
213
+ return pid_is_alive(owner.pid, owner.process_created_at)
214
+
215
+ @staticmethod
216
+ def _read_owner(path: Path) -> DeviceLockOwner | None:
217
+ try:
218
+ payload = json.loads(path.read_text(encoding="utf-8"))
219
+ return DeviceLockOwner.from_dict(payload)
220
+ except (OSError, ValueError, KeyError, TypeError, json.JSONDecodeError):
221
+ return None
222
+
223
+ def _remove_stale_lock(self) -> bool:
224
+ owner = self._read_owner(self.path)
225
+ if owner is not None:
226
+ if self._owner_is_alive(owner):
227
+ return False
228
+ else:
229
+ try:
230
+ if time.time() - self.path.stat().st_mtime < self._MALFORMED_LOCK_GRACE_SECONDS:
231
+ return False
232
+ except OSError:
233
+ return True
234
+
235
+ return self._safe_unlink(self.path)
236
+
237
+ @classmethod
238
+ def _owner_payload(
239
+ cls,
240
+ token: str,
241
+ device_id: str,
242
+ description: str,
243
+ *,
244
+ session_id: str | None = None,
245
+ ingress: str | None = None,
246
+ lock_scope: str | None = None,
247
+ ) -> dict:
248
+ payload = {
249
+ "pid": os.getpid(),
250
+ "process_created_at": cls._current_process_created_at(),
251
+ "token": token,
252
+ "device_id": device_id,
253
+ "description": description,
254
+ "acquired_at": datetime.now(UTC).isoformat(),
255
+ }
256
+ if session_id:
257
+ payload["session_id"] = str(session_id)
258
+ if ingress:
259
+ payload["ingress"] = str(ingress)
260
+ if lock_scope:
261
+ payload["lock_scope"] = str(lock_scope)
262
+ return payload
263
+
264
+ @classmethod
265
+ def _atomic_write_json(cls, path: Path, payload: dict) -> bool:
266
+ """Replace a ticket atomically so queue scans cannot read partial JSON."""
267
+ temp_path = path.with_name(f"{path.name}.{uuid.uuid4().hex}.tmp")
268
+ try:
269
+ temp_path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
270
+ if not cls._safe_replace(temp_path, path):
271
+ return False
272
+ except OSError:
273
+ return False
274
+ finally:
275
+ cls._safe_unlink(temp_path)
276
+ return True
277
+
278
+ @classmethod
279
+ def _ticket_targets_device(
280
+ cls,
281
+ ticket_owner: DeviceLockOwner,
282
+ *,
283
+ target_lock_id: str,
284
+ target_scope: str | None,
285
+ include_pending: bool,
286
+ ) -> bool:
287
+ """Return whether a queue ticket belongs to the given device's queue.
288
+
289
+ A concrete ticket matches only its own device. A ticket whose device id
290
+ is still ``pending``/``any`` is a submission placeholder that no worker
291
+ has claimed yet; with ``include_pending=True`` it matches any device
292
+ with a compatible lock scope (used by listings, and by
293
+ ``_build_device_queue`` which then narrows the match down to a single
294
+ parked device), while ``include_pending=False`` excludes it.
295
+ """
296
+ ticket_dev = cls._normalize_device_id(ticket_owner.device_id)
297
+ ticket_lock_id = cls._normalize_lock_id(
298
+ ticket_owner.device_id,
299
+ ticket_owner.lock_scope,
300
+ )
301
+ if ticket_lock_id == target_lock_id:
302
+ return True
303
+ if not include_pending:
304
+ return False
305
+ same_scope = (
306
+ not ticket_owner.lock_scope
307
+ or not target_scope
308
+ or ticket_owner.lock_scope == target_scope
309
+ )
310
+ return ticket_dev in ("pending", "any") and same_scope
311
+
312
+ @classmethod
313
+ def _build_device_queue(
314
+ cls,
315
+ wait_files: list[Path],
316
+ *,
317
+ target_lock_id: str,
318
+ target_scope: str | None,
319
+ ) -> list[Path]:
320
+ """Return the FIFO wait-ticket queue that gates ``target_lock_id``.
321
+
322
+ Concrete tickets belong to exactly their own device's queue. An
323
+ unclaimed ``pending``/``any`` ticket is *parked* on at most one device:
324
+ pending tickets, taken in FIFO order, occupy the idle scope-compatible
325
+ candidate devices in sorted lock-id order. Historically such a ticket
326
+ was counted into every device's queue, so a single unclaimed
327
+ head-of-queue ticket blocked all devices at once. Parking preserves
328
+ submission order on the one device the ticket is presumed to claim
329
+ while leaving every other idle device schedulable.
330
+
331
+ The pending submission itself cannot starve: its ticket file keeps its
332
+ original timestamp, so once a worker claims a concrete device the
333
+ ticket sorts ahead of every younger ticket for that device.
334
+ """
335
+ entries: list[tuple[Path, str | None]] = []
336
+ known_lock_ids: set[str] = set()
337
+ for wait_path in wait_files:
338
+ owner = cls._read_owner(wait_path)
339
+ if owner is None:
340
+ continue
341
+ if cls._normalize_device_id(owner.device_id) in ("pending", "any"):
342
+ if cls._ticket_targets_device(
343
+ owner,
344
+ target_lock_id=target_lock_id,
345
+ target_scope=target_scope,
346
+ include_pending=True,
347
+ ):
348
+ entries.append((wait_path, None))
349
+ continue
350
+ same_scope = (
351
+ not owner.lock_scope or not target_scope or owner.lock_scope == target_scope
352
+ )
353
+ if not same_scope:
354
+ continue
355
+ lock_id = cls._normalize_lock_id(owner.device_id, owner.lock_scope)
356
+ known_lock_ids.add(lock_id)
357
+ entries.append((wait_path, lock_id))
358
+
359
+ lock_dir = get_temp_dir("device-locks")
360
+ idle_candidates = sorted(
361
+ lock_id
362
+ for lock_id in known_lock_ids
363
+ if not (lock_dir / f"artemis-device-{lock_id}.lock").exists()
364
+ )
365
+ parked: dict[Path, str] = {}
366
+ next_candidate = 0
367
+ for wait_path, lock_id in entries:
368
+ if lock_id is None and next_candidate < len(idle_candidates):
369
+ parked[wait_path] = idle_candidates[next_candidate]
370
+ next_candidate += 1
371
+
372
+ return [
373
+ wait_path
374
+ for wait_path, lock_id in entries
375
+ if lock_id == target_lock_id or parked.get(wait_path) == target_lock_id
376
+ ]
377
+
378
+ @classmethod
379
+ def reserve(
380
+ cls,
381
+ description: str = "Artemis task",
382
+ device_id: str = "pending",
383
+ *,
384
+ session_id: str | None = None,
385
+ ingress: str | None = None,
386
+ lock_scope: str | None = None,
387
+ ) -> str:
388
+ """Reserve a FIFO position in the global queue before a worker process is spawned."""
389
+ token = uuid.uuid4().hex
390
+ queue_dir = get_temp_dir("device-locks") / "artemis-global-device.queue"
391
+ queue_dir.mkdir(parents=True, exist_ok=True)
392
+ payload = cls._owner_payload(
393
+ token,
394
+ device_id,
395
+ description,
396
+ session_id=session_id,
397
+ ingress=ingress,
398
+ lock_scope=lock_scope or os.getenv(cls.LOCK_SCOPE_ENV) or None,
399
+ )
400
+ for _ in range(8):
401
+ path = queue_dir / f"{time.time_ns():020d}-{token}.wait"
402
+ try:
403
+ fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
404
+ except FileExistsError:
405
+ continue
406
+ else:
407
+ try:
408
+ os.write(fd, json.dumps(payload, ensure_ascii=False).encode("utf-8"))
409
+ finally:
410
+ os.close(fd)
411
+ return token
412
+ raise DeviceBusyError("Could not reserve a position in the Artemis device queue.")
413
+
414
+ @classmethod
415
+ def cancel_reservation(cls, token: str | None) -> bool:
416
+ """Remove a pending queue ticket. Active execution leases are untouched."""
417
+ if not token:
418
+ return False
419
+ queue_dir = get_temp_dir("device-locks") / "artemis-global-device.queue"
420
+ removed = False
421
+ if queue_dir.exists():
422
+ for path in queue_dir.glob(f"*-{token}.wait"):
423
+ try:
424
+ path.unlink(missing_ok=True)
425
+ removed = True
426
+ except OSError:
427
+ pass
428
+ return removed
429
+
430
+ @classmethod
431
+ def transfer_reservation(
432
+ cls,
433
+ token: str,
434
+ pid: int,
435
+ *,
436
+ description: str = "Artemis task",
437
+ device_id: str = "pending",
438
+ session_id: str | None = None,
439
+ ingress: str | None = None,
440
+ lock_scope: str | None = None,
441
+ ) -> bool:
442
+ """Transfer a submission ticket to its worker process."""
443
+ queue_dir = get_temp_dir("device-locks") / "artemis-global-device.queue"
444
+ if not queue_dir.exists():
445
+ return False
446
+ matches = list(queue_dir.glob(f"*-{token}.wait"))
447
+ path = min(matches, default=None)
448
+ if path is None:
449
+ return False
450
+ try:
451
+ import psutil
452
+
453
+ process_created_at = float(psutil.Process(pid).create_time())
454
+ except Exception:
455
+ process_created_at = 0.0
456
+ payload = {
457
+ "pid": pid,
458
+ "process_created_at": process_created_at,
459
+ "token": token,
460
+ "device_id": device_id,
461
+ "description": description,
462
+ "acquired_at": datetime.now(UTC).isoformat(),
463
+ }
464
+ if session_id:
465
+ payload["session_id"] = str(session_id)
466
+ if ingress:
467
+ payload["ingress"] = str(ingress)
468
+ effective_scope = lock_scope or os.getenv(cls.LOCK_SCOPE_ENV) or None
469
+ if effective_scope:
470
+ payload["lock_scope"] = str(effective_scope)
471
+ return cls._atomic_write_json(path, payload)
472
+
473
+ def _find_reserved_ticket(self) -> Path | None:
474
+ if not self.queue_ticket:
475
+ return None
476
+ matches = list(self.queue_dir.glob(f"*-{self.queue_ticket}.wait"))
477
+ return min(matches, default=None)
478
+
479
+ def _claim_or_create_queue_ticket(self) -> Path:
480
+ self.queue_dir.mkdir(parents=True, exist_ok=True)
481
+ if self.queue_ticket:
482
+ path = self._find_reserved_ticket()
483
+ if path is None:
484
+ raise DeviceBusyError(
485
+ "The Artemis queue reservation was cancelled or expired before execution."
486
+ )
487
+ payload = self._owner_payload(
488
+ self.queue_ticket,
489
+ self.device_id,
490
+ self.description,
491
+ session_id=self.session_id,
492
+ ingress=self.ingress,
493
+ lock_scope=self.lock_scope,
494
+ )
495
+ if not self._atomic_write_json(path, payload):
496
+ raise DeviceBusyError(
497
+ "The Artemis queue reservation could not be claimed (ticket rewrite failed)."
498
+ )
499
+ return path
500
+
501
+ self.queue_ticket = self.reserve(
502
+ self.description,
503
+ self.device_id,
504
+ session_id=self.session_id,
505
+ ingress=self.ingress,
506
+ lock_scope=self.lock_scope,
507
+ )
508
+ path = self._find_reserved_ticket()
509
+ if path is None:
510
+ raise DeviceBusyError("The Artemis device queue ticket could not be created.")
511
+ return path
512
+
513
+ def _remove_stale_queue_entries(self) -> int:
514
+ removed = 0
515
+ if not self.queue_dir.exists():
516
+ return removed
517
+ for path in self.queue_dir.glob("*.wait"):
518
+ if self._queue_path is not None and path == self._queue_path:
519
+ continue
520
+ owner = self._read_owner(path)
521
+ if owner is not None and self._owner_is_alive(owner):
522
+ continue
523
+ if owner is None:
524
+ try:
525
+ if time.time() - path.stat().st_mtime < self._MALFORMED_LOCK_GRACE_SECONDS:
526
+ continue
527
+ except OSError:
528
+ continue
529
+ try:
530
+ path.unlink(missing_ok=True)
531
+ removed += 1
532
+ except OSError:
533
+ pass
534
+ return removed
535
+
536
+ def _try_acquire_owner_lock(self) -> bool:
537
+ owner_payload = self._owner_payload(
538
+ self.token,
539
+ self.device_id,
540
+ self.description,
541
+ session_id=self.session_id,
542
+ ingress=self.ingress,
543
+ lock_scope=self.lock_scope,
544
+ )
545
+ try:
546
+ fd = os.open(self.path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
547
+ except FileExistsError:
548
+ current_owner = self._read_owner(self.path)
549
+ if (
550
+ current_owner is not None
551
+ and current_owner.pid == os.getpid()
552
+ and (
553
+ current_owner.token == self.token
554
+ or (self.session_id and current_owner.session_id == self.session_id)
555
+ )
556
+ ):
557
+ self._acquired = True
558
+ return True
559
+ self._remove_stale_lock()
560
+ return False
561
+ try:
562
+ os.write(fd, json.dumps(owner_payload, ensure_ascii=False).encode("utf-8"))
563
+ finally:
564
+ os.close(fd)
565
+ self._acquired = True
566
+ return True
567
+
568
+ @property
569
+ def effective_concurrency(self) -> int:
570
+ """Resolve effective concurrency limit:
571
+ 0: per-device concurrency (multiple devices run concurrently, FIFO per device)
572
+ 1: global concurrency (strict single-task serialization across all devices)
573
+ >1: global concurrency capped at N tasks across all devices
574
+ """
575
+ if self.concurrency_mode is not None:
576
+ mode = self.concurrency_mode
577
+ if mode in ("global", "serial", "1"):
578
+ return 1
579
+ if mode in ("per_device", "device", "parallel", "0"):
580
+ return 0
581
+ if self.max_concurrency is not None:
582
+ return self.max_concurrency
583
+ return self.resolve_env_concurrency()
584
+
585
+ @classmethod
586
+ def resolve_env_concurrency(cls) -> int:
587
+ """Resolve the concurrency limit from the environment.
588
+
589
+ Shared with the admin scheduler. Returns 0 for per-device concurrency,
590
+ or a positive global limit.
591
+ """
592
+ env_mode = os.environ.get("ARTEMIS_CONCURRENCY_MODE", "").strip().lower()
593
+ if env_mode in ("global", "serial", "1"):
594
+ return 1
595
+ if env_mode in ("per_device", "device", "parallel", "0"):
596
+ return 0
597
+ try:
598
+ return int(os.environ.get("ARTEMIS_MAX_CONCURRENT_TASKS", 0))
599
+ except (ValueError, TypeError):
600
+ return 0
601
+
602
+ def acquire(
603
+ self,
604
+ *,
605
+ blocking: bool = True,
606
+ timeout: float | None = None,
607
+ poll_interval: float = _DEFAULT_POLL_INTERVAL_SECONDS,
608
+ cancel_event=None,
609
+ ) -> None:
610
+ """Wait in FIFO order and acquire exclusive access to the target Artemis device."""
611
+ self._queue_path = self._claim_or_create_queue_ticket()
612
+ started_at = time.monotonic()
613
+ effective_concurrency = self.effective_concurrency
614
+ try:
615
+ while True:
616
+ if cancel_event is not None and cancel_event.is_set():
617
+ raise DeviceBusyError("Waiting for the Artemis device queue was cancelled.")
618
+ self._remove_stale_queue_entries()
619
+
620
+ if not self._queue_path.exists():
621
+ raise DeviceBusyError("The Artemis queue reservation was cancelled.")
622
+
623
+ # 1. Check global concurrency limit if configured (> 0)
624
+ if effective_concurrency > 0:
625
+ active_owners = self.get_active_owners()
626
+ active_other_owners = [
627
+ o
628
+ for o in active_owners.values()
629
+ if not (
630
+ o.pid == os.getpid()
631
+ and (
632
+ (self.session_id and o.session_id == self.session_id)
633
+ or o.token == self.token
634
+ )
635
+ )
636
+ ]
637
+ if len(active_other_owners) >= effective_concurrency:
638
+ if not blocking:
639
+ owner_desc = (
640
+ active_other_owners[0].description
641
+ if active_other_owners
642
+ else "active task"
643
+ )
644
+ raise DeviceBusyError(
645
+ f"Global task concurrency limit ({effective_concurrency}) reached ({owner_desc})."
646
+ )
647
+ if timeout is not None and time.monotonic() - started_at >= timeout:
648
+ raise DeviceBusyError(
649
+ f"Timed out waiting for a concurrency slot after {timeout:.1f}s."
650
+ )
651
+ time.sleep(max(0.01, poll_interval))
652
+ continue
653
+
654
+ # 2. Determine FIFO eligibility for this specific device
655
+ all_wait_files = sorted(self.queue_dir.glob("*.wait"))
656
+ if effective_concurrency == 1:
657
+ # In strict serial mode, global FIFO applies; re-entrant parent processes are always eligible
658
+ active_owners = self.get_active_owners()
659
+ is_reentrant = any(
660
+ o.pid == os.getpid()
661
+ and (
662
+ (self.session_id and o.session_id == self.session_id)
663
+ or o.token == self.token
664
+ )
665
+ for o in active_owners.values()
666
+ )
667
+ is_eligible = is_reentrant or bool(
668
+ all_wait_files and all_wait_files[0] == self._queue_path
669
+ )
670
+ else:
671
+ # In multi-device parallel mode, per-device FIFO applies.
672
+ # Unclaimed pending/any tickets are parked on at most one
673
+ # idle device instead of gating the head of every device's
674
+ # queue (see _build_device_queue).
675
+ device_queue = self._build_device_queue(
676
+ all_wait_files,
677
+ target_lock_id=self.clean_device_id,
678
+ target_scope=self.lock_scope,
679
+ )
680
+ is_eligible = bool(device_queue and device_queue[0] == self._queue_path)
681
+
682
+ if is_eligible and self._try_acquire_owner_lock():
683
+ self._safe_unlink(self._queue_path)
684
+ self._queue_path = None
685
+ return
686
+
687
+ if not blocking:
688
+ owner = self._read_owner(self.path)
689
+ owner_text = (
690
+ f"PID {owner.pid} ({owner.description}, since {owner.acquired_at})"
691
+ if owner is not None
692
+ else "an earlier queued task"
693
+ )
694
+ raise DeviceBusyError(
695
+ f"Device '{self.device_id}' is already controlled by {owner_text}."
696
+ )
697
+ if timeout is not None and time.monotonic() - started_at >= timeout:
698
+ raise DeviceBusyError(
699
+ f"Timed out waiting for the Artemis device queue after {timeout:.1f}s."
700
+ )
701
+ time.sleep(max(0.01, poll_interval))
702
+ except BaseException:
703
+ if self._queue_path is not None:
704
+ self._safe_unlink(self._queue_path)
705
+ self._queue_path = None
706
+ if not self._initial_queue_ticket:
707
+ self.queue_ticket = None
708
+ raise
709
+
710
+ def release(self) -> None:
711
+ if not self._acquired:
712
+ return
713
+ try:
714
+ owner = self._read_owner(self.path)
715
+ # A matching session id cannot release another process's lease.
716
+ if (
717
+ owner is not None
718
+ and owner.pid == os.getpid()
719
+ and (
720
+ owner.token == self.token
721
+ or (self.session_id and owner.session_id == self.session_id)
722
+ )
723
+ ):
724
+ self._safe_unlink(self.path)
725
+ finally:
726
+ self._acquired = False
727
+
728
+ @classmethod
729
+ def _get_active_owner_for_path(cls, path: Path) -> DeviceLockOwner | None:
730
+ owner = None
731
+ for attempt in range(3):
732
+ owner = cls._read_owner(path)
733
+ if owner is not None or not path.exists():
734
+ break
735
+ if attempt < 2:
736
+ time.sleep(0.01)
737
+ if owner is None:
738
+ try:
739
+ if (
740
+ path.exists()
741
+ and time.time() - path.stat().st_mtime >= cls._MALFORMED_LOCK_GRACE_SECONDS
742
+ ):
743
+ cls._safe_unlink(path)
744
+ except OSError:
745
+ pass
746
+ return None
747
+ if cls._owner_is_alive(owner):
748
+ return owner
749
+ cls._safe_unlink(path)
750
+ return None
751
+
752
+ @classmethod
753
+ def get_active_owners(cls) -> dict[str, DeviceLockOwner]:
754
+ """Return a mapping of clean_device_id -> live DeviceLockOwner across all devices."""
755
+ lock_dir = get_temp_dir("device-locks")
756
+ if not lock_dir.exists():
757
+ return {}
758
+ owners: list[DeviceLockOwner] = []
759
+ seen_tokens: set[str] = set()
760
+
761
+ for lock_path in sorted(lock_dir.glob("artemis-device-*.lock")):
762
+ owner = cls._get_active_owner_for_path(lock_path)
763
+ if owner is not None and owner.token not in seen_tokens:
764
+ owners.append(owner)
765
+ seen_tokens.add(owner.token)
766
+
767
+ # Also check legacy global lock if still present
768
+ global_lock = lock_dir / "artemis-global-device.lock"
769
+ if global_lock.exists():
770
+ owner = cls._get_active_owner_for_path(global_lock)
771
+ if owner is not None and owner.token not in seen_tokens:
772
+ owners.append(owner)
773
+ seen_tokens.add(owner.token)
774
+
775
+ active_map: dict[str, DeviceLockOwner] = {}
776
+ owners_by_device: dict[str, list[DeviceLockOwner]] = {}
777
+ for owner in owners:
778
+ key = cls._normalize_device_id(owner.device_id)
779
+ owners_by_device.setdefault(key, []).append(owner)
780
+ for device_key, device_owners in owners_by_device.items():
781
+ if len(device_owners) == 1:
782
+ active_map[device_key] = device_owners[0]
783
+ continue
784
+ for owner in device_owners:
785
+ scoped_key = cls._normalize_lock_id(owner.device_id, owner.lock_scope)
786
+ active_map[scoped_key] = owner
787
+ return active_map
788
+
789
+ @classmethod
790
+ def get_active_owner(
791
+ cls,
792
+ device_id: str | None = None,
793
+ lock_scope: str | None = None,
794
+ ) -> DeviceLockOwner | None:
795
+ """Return the live process owning the lock for a specific device, or any device if None."""
796
+ lock_dir = get_temp_dir("device-locks")
797
+ if device_id is not None:
798
+ effective_scope = lock_scope or os.getenv(cls.LOCK_SCOPE_ENV) or None
799
+ clean_id = cls._normalize_lock_id(device_id, effective_scope)
800
+ dev_path = lock_dir / f"artemis-device-{clean_id}.lock"
801
+ owner = cls._get_active_owner_for_path(dev_path)
802
+ if owner is not None:
803
+ return owner
804
+ if effective_scope:
805
+ legacy_path = lock_dir / (
806
+ f"artemis-device-{cls._normalize_device_id(device_id)}.lock"
807
+ )
808
+ owner = cls._get_active_owner_for_path(legacy_path)
809
+ if owner is not None:
810
+ return owner
811
+ # Check legacy global lock as fallback
812
+ global_path = lock_dir / "artemis-global-device.lock"
813
+ if global_path.exists():
814
+ return cls._get_active_owner_for_path(global_path)
815
+ return None
816
+
817
+ # Return any active owner for single-device caller backward compatibility
818
+ active_owners = cls.get_active_owners()
819
+ for owner in active_owners.values():
820
+ return owner
821
+ return None
822
+
823
+ @classmethod
824
+ def get_queued_tasks(cls, device_id: str | None = None) -> list[dict[str, Any]]:
825
+ """Return all live pending queue tickets across all devices in FIFO order."""
826
+ lock_dir = get_temp_dir("device-locks")
827
+ queue_dir = lock_dir / "artemis-global-device.queue"
828
+ if not queue_dir.exists():
829
+ return []
830
+
831
+ target_scope = os.getenv(cls.LOCK_SCOPE_ENV) or None
832
+ clean_target = cls._normalize_lock_id(device_id, target_scope) if device_id else None
833
+ active_tokens = {o.token for o in cls.get_active_owners().values()}
834
+ queued: list[dict[str, Any]] = []
835
+
836
+ for path in sorted(queue_dir.glob("*.wait")):
837
+ owner = cls._read_owner(path)
838
+ if owner is None:
839
+ continue
840
+ # If the owner process died, ignore
841
+ if not cls._owner_is_alive(owner):
842
+ continue
843
+ # If this token already acquired the lock, it is actively running, not pending
844
+ if owner.token in active_tokens:
845
+ continue
846
+
847
+ if clean_target is not None and not cls._ticket_targets_device(
848
+ owner,
849
+ target_lock_id=clean_target,
850
+ target_scope=target_scope,
851
+ include_pending=True,
852
+ ):
853
+ continue
854
+
855
+ try:
856
+ created_at = path.stat().st_mtime
857
+ except OSError:
858
+ created_at = time.time()
859
+
860
+ queued.append(
861
+ {
862
+ "session_id": owner.session_id or f"queued-{owner.token[:8]}",
863
+ "goal": owner.description,
864
+ "device_id": owner.device_id,
865
+ "device_serial": owner.device_id,
866
+ "adb_endpoint_id": owner.lock_scope,
867
+ "pid": owner.pid,
868
+ "token": owner.token,
869
+ "ingress": owner.ingress or "unknown",
870
+ "status": "pending",
871
+ "created_at": created_at,
872
+ "start_time": created_at,
873
+ }
874
+ )
875
+ return queued
876
+
877
+ @classmethod
878
+ def has_owner_record(cls, device_id: str | None = None) -> bool:
879
+ """Return whether an owner record exists, including a record being written."""
880
+ lock_dir = get_temp_dir("device-locks")
881
+ if not lock_dir.exists():
882
+ return False
883
+ if device_id is not None:
884
+ clean_id = cls._normalize_lock_id(
885
+ device_id,
886
+ os.getenv(cls.LOCK_SCOPE_ENV) or None,
887
+ )
888
+ return (lock_dir / f"artemis-device-{clean_id}.lock").exists() or (
889
+ lock_dir / "artemis-global-device.lock"
890
+ ).exists()
891
+ return (
892
+ bool(list(lock_dir.glob("artemis-device-*.lock")))
893
+ or (lock_dir / "artemis-global-device.lock").exists()
894
+ )
895
+
896
+ @classmethod
897
+ def is_active_owner(cls, expected: DeviceLockOwner, device_id: str | None = None) -> bool:
898
+ """Revalidate a previously read owner before performing a destructive action."""
899
+ target_dev = device_id or expected.device_id
900
+ lock_dir = get_temp_dir("device-locks")
901
+ clean_id = cls._normalize_lock_id(target_dev, expected.lock_scope)
902
+ path = lock_dir / f"artemis-device-{clean_id}.lock"
903
+ if not path.exists():
904
+ path = lock_dir / "artemis-global-device.lock"
905
+ current = cls._read_owner(path)
906
+ return bool(
907
+ current
908
+ and current.token == expected.token
909
+ and current.pid == expected.pid
910
+ and abs(current.process_created_at - expected.process_created_at) < 1.0
911
+ and cls._owner_is_alive(current)
912
+ )
913
+
914
+ @classmethod
915
+ def _find_lock_by_pid(cls, pid: int) -> Path | None:
916
+ lock_dir = get_temp_dir("device-locks")
917
+ if not lock_dir.exists():
918
+ return None
919
+ for path in sorted(lock_dir.glob("artemis-device-*.lock")):
920
+ owner = cls._read_owner(path)
921
+ if owner is not None and owner.pid == pid:
922
+ return path
923
+ global_path = lock_dir / "artemis-global-device.lock"
924
+ if global_path.exists():
925
+ owner = cls._read_owner(global_path)
926
+ if owner is not None and owner.pid == pid:
927
+ return global_path
928
+ return None
929
+
930
+ @classmethod
931
+ def annotate_active_owner(
932
+ cls,
933
+ *,
934
+ session_id: str,
935
+ ingress: str | None = None,
936
+ device_id: str | None = None,
937
+ ) -> bool:
938
+ """Attach session metadata after tracing starts inside the lease owner."""
939
+ lock_dir = get_temp_dir("device-locks")
940
+ if device_id is not None:
941
+ clean_id = cls._normalize_device_id(device_id)
942
+ path = lock_dir / f"artemis-device-{clean_id}.lock"
943
+ if not path.exists():
944
+ path = lock_dir / "artemis-global-device.lock"
945
+ else:
946
+ path = cls._find_lock_by_pid(os.getpid())
947
+ if path is None:
948
+ path = lock_dir / "artemis-global-device.lock"
949
+
950
+ owner = cls._read_owner(path)
951
+ if owner is None or owner.pid != os.getpid() or not cls._owner_is_alive(owner):
952
+ return False
953
+ payload = {
954
+ "pid": owner.pid,
955
+ "process_created_at": owner.process_created_at,
956
+ "token": owner.token,
957
+ "device_id": owner.device_id,
958
+ "description": owner.description,
959
+ "acquired_at": owner.acquired_at,
960
+ "session_id": str(session_id),
961
+ "ingress": ingress or owner.ingress or "sdk",
962
+ }
963
+ return cls._atomic_write_json(path, payload)
964
+
965
+ @classmethod
966
+ def cleanup_stale_locks(cls, device_id: str | None = None) -> int:
967
+ """Remove execution leases and queue tickets whose owners are gone."""
968
+ removed = 0
969
+ lock_dir = get_temp_dir("device-locks")
970
+ if not lock_dir.exists():
971
+ return removed
972
+
973
+ patterns = (
974
+ [f"artemis-device-{cls._normalize_device_id(device_id)}.lock"]
975
+ if device_id
976
+ else ["artemis-device-*.lock", "artemis-global-device.lock", "*.lock"]
977
+ )
978
+ seen_paths: set[Path] = set()
979
+ for pat in patterns:
980
+ for path in lock_dir.glob(pat):
981
+ if path in seen_paths:
982
+ continue
983
+ seen_paths.add(path)
984
+ owner = cls._read_owner(path)
985
+ if owner is not None and cls._owner_is_alive(owner):
986
+ continue
987
+ if owner is None:
988
+ try:
989
+ if time.time() - path.stat().st_mtime < cls._MALFORMED_LOCK_GRACE_SECONDS:
990
+ continue
991
+ except OSError:
992
+ continue
993
+ if cls._safe_unlink(path):
994
+ removed += 1
995
+
996
+ for q_dir in lock_dir.glob("*.queue"):
997
+ if q_dir.is_dir():
998
+ for path in q_dir.glob("*.wait"):
999
+ owner = cls._read_owner(path)
1000
+ if owner is not None and cls._owner_is_alive(owner):
1001
+ continue
1002
+ if owner is None:
1003
+ try:
1004
+ if (
1005
+ time.time() - path.stat().st_mtime
1006
+ < cls._MALFORMED_LOCK_GRACE_SECONDS
1007
+ ):
1008
+ continue
1009
+ except OSError:
1010
+ continue
1011
+ if cls._safe_unlink(path):
1012
+ removed += 1
1013
+ return removed