android-midscene-automation 0.2.3 → 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.
- package/CHANGELOG.md +11 -1
- package/README.md +63 -1
- package/artemis-main/.dockerignore +15 -0
- package/artemis-main/.env.example +34 -0
- package/artemis-main/CONTRIBUTING.md +35 -0
- package/artemis-main/Dockerfile +68 -0
- package/artemis-main/LICENSE +202 -0
- package/artemis-main/Makefile +115 -0
- package/artemis-main/README.md +322 -0
- package/artemis-main/README_CN.md +320 -0
- package/artemis-main/apps/__init__.py +15 -0
- package/artemis-main/apps/admin_console/__init__.py +19 -0
- package/artemis-main/apps/admin_console/core/__init__.py +13 -0
- package/artemis-main/apps/admin_console/core/config.py +43 -0
- package/artemis-main/apps/admin_console/core/security.py +178 -0
- package/artemis-main/apps/admin_console/core/state.py +243 -0
- package/artemis-main/apps/admin_console/database/__init__.py +13 -0
- package/artemis-main/apps/admin_console/database/connection.py +60 -0
- package/artemis-main/apps/admin_console/database/repositories/__init__.py +13 -0
- package/artemis-main/apps/admin_console/database/repositories/session_repository.py +564 -0
- package/artemis-main/apps/admin_console/database/repositories/step_repository.py +512 -0
- package/artemis-main/apps/admin_console/database/repositories/trace_repository.py +133 -0
- package/artemis-main/apps/admin_console/index.html +3787 -0
- package/artemis-main/apps/admin_console/replay_manager.py +2585 -0
- package/artemis-main/apps/admin_console/routers/__init__.py +13 -0
- package/artemis-main/apps/admin_console/routers/media.py +198 -0
- package/artemis-main/apps/admin_console/routers/replay.py +91 -0
- package/artemis-main/apps/admin_console/routers/sessions.py +239 -0
- package/artemis-main/apps/admin_console/routers/steps.py +109 -0
- package/artemis-main/apps/admin_console/routers/stream.py +56 -0
- package/artemis-main/apps/admin_console/routers/system.py +761 -0
- package/artemis-main/apps/admin_console/routers/tasks.py +517 -0
- package/artemis-main/apps/admin_console/schemas/__init__.py +13 -0
- package/artemis-main/apps/admin_console/schemas/task_schema.py +47 -0
- package/artemis-main/apps/admin_console/server.py +508 -0
- package/artemis-main/apps/admin_console/services/__init__.py +13 -0
- package/artemis-main/apps/admin_console/services/device_stream_service.py +137 -0
- package/artemis-main/apps/admin_console/services/ipc_service.py +262 -0
- package/artemis-main/apps/admin_console/services/media_service.py +623 -0
- package/artemis-main/apps/admin_console/services/model_service.py +148 -0
- package/artemis-main/apps/admin_console/services/task_preset_catalog.py +514 -0
- package/artemis-main/apps/admin_console/services/task_queue_service.py +1719 -0
- package/artemis-main/apps/admin_console/services/worker_process_io.py +144 -0
- package/artemis-main/apps/showcase_ui/README.md +31 -0
- package/artemis-main/apps/showcase_ui/angular.json +132 -0
- package/artemis-main/apps/showcase_ui/package-lock.json +9472 -0
- package/artemis-main/apps/showcase_ui/package.json +39 -0
- package/artemis-main/apps/showcase_ui/proxy.conf.json +23 -0
- package/artemis-main/apps/showcase_ui/public/favicon.ico +0 -0
- package/artemis-main/apps/showcase_ui/public/logo.png +0 -0
- package/artemis-main/apps/showcase_ui/public/logo.svg +56 -0
- package/artemis-main/apps/showcase_ui/src/app/app.component.html +24 -0
- package/artemis-main/apps/showcase_ui/src/app/app.component.scss +49 -0
- package/artemis-main/apps/showcase_ui/src/app/app.component.spec.ts +42 -0
- package/artemis-main/apps/showcase_ui/src/app/app.component.ts +32 -0
- package/artemis-main/apps/showcase_ui/src/app/app.config.ts +29 -0
- package/artemis-main/apps/showcase_ui/src/app/app.routes.ts +27 -0
- package/artemis-main/apps/showcase_ui/src/app/components/agent-stream/agent-stream-startup.spec.ts +108 -0
- package/artemis-main/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.html +1436 -0
- package/artemis-main/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.scss +3973 -0
- package/artemis-main/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.ts +2235 -0
- package/artemis-main/apps/showcase_ui/src/app/components/chat-interface/chat-interface.component.html +380 -0
- package/artemis-main/apps/showcase_ui/src/app/components/chat-interface/chat-interface.component.scss +1171 -0
- package/artemis-main/apps/showcase_ui/src/app/components/chat-interface/chat-interface.component.ts +270 -0
- package/artemis-main/apps/showcase_ui/src/app/components/floating-video-player/floating-video-player.component.html +433 -0
- package/artemis-main/apps/showcase_ui/src/app/components/floating-video-player/floating-video-player.component.scss +987 -0
- package/artemis-main/apps/showcase_ui/src/app/components/floating-video-player/floating-video-player.component.ts +632 -0
- package/artemis-main/apps/showcase_ui/src/app/components/legacy-agent-stream/legacy-agent-stream.component.html +130 -0
- package/artemis-main/apps/showcase_ui/src/app/components/legacy-agent-stream/legacy-agent-stream.component.scss +406 -0
- package/artemis-main/apps/showcase_ui/src/app/components/legacy-agent-stream/legacy-agent-stream.component.ts +74 -0
- package/artemis-main/apps/showcase_ui/src/app/components/nav-switcher/nav-switcher.component.scss +115 -0
- package/artemis-main/apps/showcase_ui/src/app/components/nav-switcher/nav-switcher.component.ts +52 -0
- package/artemis-main/apps/showcase_ui/src/app/core/data/smart-tasks.data.ts +334 -0
- package/artemis-main/apps/showcase_ui/src/app/core/i18n/language.service.spec.ts +55 -0
- package/artemis-main/apps/showcase_ui/src/app/core/i18n/language.service.ts +61 -0
- package/artemis-main/apps/showcase_ui/src/app/core/i18n/translate.pipe.ts +11 -0
- package/artemis-main/apps/showcase_ui/src/app/core/i18n/zh-cn.ts +651 -0
- package/artemis-main/apps/showcase_ui/src/app/core/models/markdown.model.ts +43 -0
- package/artemis-main/apps/showcase_ui/src/app/core/models/pro-tuning.model.spec.ts +47 -0
- package/artemis-main/apps/showcase_ui/src/app/core/models/pro-tuning.model.ts +165 -0
- package/artemis-main/apps/showcase_ui/src/app/core/models/session.model.ts +85 -0
- package/artemis-main/apps/showcase_ui/src/app/core/models/stream.model.ts +246 -0
- package/artemis-main/apps/showcase_ui/src/app/core/models/system.model.ts +118 -0
- package/artemis-main/apps/showcase_ui/src/app/core/services/task-recommendation.service.ts +111 -0
- package/artemis-main/apps/showcase_ui/src/app/pages/home/home.component.html +2130 -0
- package/artemis-main/apps/showcase_ui/src/app/pages/home/home.component.scss +5204 -0
- package/artemis-main/apps/showcase_ui/src/app/pages/home/home.component.ts +1189 -0
- package/artemis-main/apps/showcase_ui/src/app/pages/legacy-workspace/legacy-workspace.component.html +39 -0
- package/artemis-main/apps/showcase_ui/src/app/pages/legacy-workspace/legacy-workspace.component.scss +151 -0
- package/artemis-main/apps/showcase_ui/src/app/pages/legacy-workspace/legacy-workspace.component.ts +58 -0
- package/artemis-main/apps/showcase_ui/src/app/pages/workspace/workspace.component.html +201 -0
- package/artemis-main/apps/showcase_ui/src/app/pages/workspace/workspace.component.scss +886 -0
- package/artemis-main/apps/showcase_ui/src/app/pages/workspace/workspace.component.ts +324 -0
- package/artemis-main/apps/showcase_ui/src/app/services/agent.service.spec.ts +418 -0
- package/artemis-main/apps/showcase_ui/src/app/services/agent.service.ts +2012 -0
- package/artemis-main/apps/showcase_ui/src/app/services/system.service.spec.ts +173 -0
- package/artemis-main/apps/showcase_ui/src/app/services/system.service.ts +620 -0
- package/artemis-main/apps/showcase_ui/src/app/utils/action-formatter.util.spec.ts +513 -0
- package/artemis-main/apps/showcase_ui/src/app/utils/action-formatter.util.ts +1015 -0
- package/artemis-main/apps/showcase_ui/src/app/utils/image-overlay.util.spec.ts +85 -0
- package/artemis-main/apps/showcase_ui/src/app/utils/image-overlay.util.ts +662 -0
- package/artemis-main/apps/showcase_ui/src/app/utils/markdown-parser.util.spec.ts +70 -0
- package/artemis-main/apps/showcase_ui/src/app/utils/markdown-parser.util.ts +383 -0
- package/artemis-main/apps/showcase_ui/src/app/utils/recording-timeline.util.spec.ts +107 -0
- package/artemis-main/apps/showcase_ui/src/app/utils/recording-timeline.util.ts +123 -0
- package/artemis-main/apps/showcase_ui/src/app/utils/run-info.util.spec.ts +92 -0
- package/artemis-main/apps/showcase_ui/src/app/utils/run-info.util.ts +116 -0
- package/artemis-main/apps/showcase_ui/src/app/utils/stream-aggregator.util.spec.ts +640 -0
- package/artemis-main/apps/showcase_ui/src/app/utils/stream-aggregator.util.ts +1021 -0
- package/artemis-main/apps/showcase_ui/src/app/utils/tool-formatter.util.spec.ts +375 -0
- package/artemis-main/apps/showcase_ui/src/app/utils/tool-formatter.util.ts +1419 -0
- package/artemis-main/apps/showcase_ui/src/index.html +37 -0
- package/artemis-main/apps/showcase_ui/src/main.ts +22 -0
- package/artemis-main/apps/showcase_ui/src/styles.scss +60 -0
- package/artemis-main/apps/showcase_ui/tsconfig.app.json +23 -0
- package/artemis-main/apps/showcase_ui/tsconfig.json +27 -0
- package/artemis-main/apps/showcase_ui/tsconfig.spec.json +23 -0
- package/artemis-main/artemis/__init__.py +44 -0
- package/artemis-main/artemis/__main__.py +18 -0
- package/artemis-main/artemis/_legacy_imports.py +94 -0
- package/artemis-main/artemis/_version.py +30 -0
- package/artemis-main/artemis/agents/__init__.py +17 -0
- package/artemis-main/artemis/agents/checker/checker.json +9 -0
- package/artemis-main/artemis/agents/checker/checker.py +859 -0
- package/artemis-main/artemis/agents/diagnoser/diagnoser.json +23 -0
- package/artemis-main/artemis/agents/diagnoser/diagnoser.py +608 -0
- package/artemis-main/artemis/agents/explorer/constants.py +52 -0
- package/artemis-main/artemis/agents/explorer/explorer.json +57 -0
- package/artemis-main/artemis/agents/explorer/explorer.py +473 -0
- package/artemis-main/artemis/agents/explorer/geometry.py +73 -0
- package/artemis-main/artemis/agents/explorer/native_runner.py +878 -0
- package/artemis-main/artemis/agents/explorer/perception_tools.py +632 -0
- package/artemis-main/artemis/agents/explorer/run_setup.py +403 -0
- package/artemis-main/artemis/agents/explorer/screen_index.py +266 -0
- package/artemis-main/artemis/agents/explorer/tiers.py +110 -0
- package/artemis-main/artemis/agents/explorer/tool_declarations.py +394 -0
- package/artemis-main/artemis/agents/explorer/universal_runner.py +317 -0
- package/artemis-main/artemis/agents/flash/context_compressor.py +452 -0
- package/artemis-main/artemis/agents/flash/flash_runner.md +51 -0
- package/artemis-main/artemis/agents/flash/flash_summarizer.md +53 -0
- package/artemis-main/artemis/agents/flash/flash_summarizer_single.md +54 -0
- package/artemis-main/artemis/agents/flash/runner.py +1167 -0
- package/artemis-main/artemis/agents/flash/summarizer.py +511 -0
- package/artemis-main/artemis/agents/history_analyzer/history_analyzer.md +8 -0
- package/artemis-main/artemis/agents/history_analyzer/history_analyzer.py +182 -0
- package/artemis-main/artemis/agents/image_processor/image_processor.md +74 -0
- package/artemis-main/artemis/agents/image_processor/image_processor.py +322 -0
- package/artemis-main/artemis/agents/log_analyzer/log_analyzer.md +15 -0
- package/artemis-main/artemis/agents/log_analyzer/log_analyzer.py +403 -0
- package/artemis-main/artemis/agents/log_analyzer/log_reader_sub_agent.md +8 -0
- package/artemis-main/artemis/agents/log_analyzer/output_analyzer.md +11 -0
- package/artemis-main/artemis/agents/log_analyzer/output_analyzer.py +163 -0
- package/artemis-main/artemis/agents/object_detector/object_detector.json +9 -0
- package/artemis-main/artemis/agents/object_detector/object_detector.py +211 -0
- package/artemis-main/artemis/agents/operator/operator.json +3 -0
- package/artemis-main/artemis/agents/operator/operator.py +1394 -0
- package/artemis-main/artemis/agents/operator/prompts.py +1064 -0
- package/artemis-main/artemis/agents/outputter/outputter.md +36 -0
- package/artemis-main/artemis/agents/outputter/outputter.py +552 -0
- package/artemis-main/artemis/agents/planner/planner.json +32 -0
- package/artemis-main/artemis/agents/planner/planner.py +436 -0
- package/artemis-main/artemis/agents/prompt_assembly.py +79 -0
- package/artemis-main/artemis/agents/summarizer/summarizer.py +190 -0
- package/artemis-main/artemis/agents/validator/action_execution.py +98 -0
- package/artemis-main/artemis/agents/validator/categories.py +33 -0
- package/artemis-main/artemis/agents/validator/execution_loop.py +488 -0
- package/artemis-main/artemis/agents/validator/incidents.py +120 -0
- package/artemis-main/artemis/agents/validator/pixel_safety_net.md +15 -0
- package/artemis-main/artemis/agents/validator/precondition_pixel.py +320 -0
- package/artemis-main/artemis/agents/validator/precondition_xml.py +672 -0
- package/artemis-main/artemis/agents/validator/tool_declarations.py +332 -0
- package/artemis-main/artemis/agents/validator/validator.py +188 -0
- package/artemis-main/artemis/agents/video_analyzer/__init__.py +15 -0
- package/artemis-main/artemis/agents/video_analyzer/audio_native.py +543 -0
- package/artemis-main/artemis/agents/video_analyzer/blackboard.py +821 -0
- package/artemis-main/artemis/agents/video_analyzer/chunk_agentic.py +455 -0
- package/artemis-main/artemis/agents/video_analyzer/chunk_conversation.py +491 -0
- package/artemis-main/artemis/agents/video_analyzer/chunk_native.py +437 -0
- package/artemis-main/artemis/agents/video_analyzer/conflict_resolution.py +137 -0
- package/artemis-main/artemis/agents/video_analyzer/gemini_files.py +162 -0
- package/artemis-main/artemis/agents/video_analyzer/native_coordinator.py +541 -0
- package/artemis-main/artemis/agents/video_analyzer/reliability.py +128 -0
- package/artemis-main/artemis/agents/video_analyzer/universal_engine.py +487 -0
- package/artemis-main/artemis/agents/video_analyzer/universal_tools.py +323 -0
- package/artemis-main/artemis/agents/video_analyzer/video_analyzer.md +51 -0
- package/artemis-main/artemis/agents/video_analyzer/video_analyzer.py +875 -0
- package/artemis-main/artemis/agents/video_analyzer/video_sub_agent.md +15 -0
- package/artemis-main/artemis/agents/video_analyzer/video_sub_agent_audio.md +12 -0
- package/artemis-main/artemis/clients/accessibility_client.py +438 -0
- package/artemis-main/artemis/clients/screen_client_factory.py +403 -0
- package/artemis-main/artemis/config/__init__.py +309 -0
- package/artemis-main/artemis/config/agent.py +1098 -0
- package/artemis-main/artemis/config/constants.py +151 -0
- package/artemis-main/artemis/config/core/__init__.py +24 -0
- package/artemis-main/artemis/config/core/engine.py +118 -0
- package/artemis-main/artemis/config/core/interpolator.py +78 -0
- package/artemis-main/artemis/config/llm.py +267 -0
- package/artemis-main/artemis/config/paths.py +244 -0
- package/artemis-main/artemis/config/runtime.py +197 -0
- package/artemis-main/artemis/config/settings.py +315 -0
- package/artemis-main/artemis/constants.py +47 -0
- package/artemis-main/artemis/context.py +289 -0
- package/artemis-main/artemis/controllers/__init__.py +41 -0
- package/artemis-main/artemis/controllers/controller_factory.py +41 -0
- package/artemis-main/artemis/controllers/device_controller.py +29 -0
- package/artemis-main/artemis/controllers/types.py +37 -0
- package/artemis-main/artemis/controllers/unified_controller.py +649 -0
- package/artemis-main/artemis/core/__init__.py +21 -0
- package/artemis-main/artemis/core/diagnostics/__init__.py +90 -0
- package/artemis-main/artemis/core/diagnostics/adb_keys.py +270 -0
- package/artemis-main/artemis/core/diagnostics/adb_server_connection.py +330 -0
- package/artemis-main/artemis/core/diagnostics/device_smoke.py +364 -0
- package/artemis-main/artemis/core/diagnostics/emulator_manager.py +501 -0
- package/artemis-main/artemis/core/diagnostics/engine.py +438 -0
- package/artemis-main/artemis/core/diagnostics/hierarchy_parity.py +216 -0
- package/artemis-main/artemis/core/diagnostics/probes/adb_probe.py +887 -0
- package/artemis-main/artemis/core/diagnostics/probes/base.py +45 -0
- package/artemis-main/artemis/core/diagnostics/probes/credentials_probe.py +296 -0
- package/artemis-main/artemis/core/diagnostics/probes/host_probe.py +427 -0
- package/artemis-main/artemis/core/diagnostics/probes/runtime_probe.py +169 -0
- package/artemis-main/artemis/core/diagnostics/probes/toolchain_probe.py +139 -0
- package/artemis-main/artemis/core/diagnostics/readiness.py +83 -0
- package/artemis-main/artemis/core/diagnostics/schema.py +118 -0
- package/artemis-main/artemis/core/tool_declaration.py +43 -0
- package/artemis-main/artemis/core/tool_failure.py +56 -0
- package/artemis-main/artemis/data_engine/context_vars.py +24 -0
- package/artemis-main/artemis/data_engine/engine.py +1615 -0
- package/artemis-main/artemis/data_engine/history_reader.py +142 -0
- package/artemis-main/artemis/data_engine/models.py +131 -0
- package/artemis-main/artemis/data_engine/storage.py +1245 -0
- package/artemis-main/artemis/data_engine/trace.py +727 -0
- package/artemis-main/artemis/drivers/__init__.py +48 -0
- package/artemis-main/artemis/drivers/android/adb_driver.py +507 -0
- package/artemis-main/artemis/drivers/android/hierarchy.py +64 -0
- package/artemis-main/artemis/drivers/android/input_ime.py +120 -0
- package/artemis-main/artemis/drivers/android/recorder.py +57 -0
- package/artemis-main/artemis/drivers/base.py +290 -0
- package/artemis-main/artemis/drivers/cloud/cloud_driver.py +117 -0
- package/artemis-main/artemis/drivers/factory.py +80 -0
- package/artemis-main/artemis/drivers/mock/mock_driver.py +198 -0
- package/artemis-main/artemis/drivers/types.py +67 -0
- package/artemis-main/artemis/graph/checkpoints.py +1243 -0
- package/artemis-main/artemis/graph/graph.py +1055 -0
- package/artemis-main/artemis/graph/perception.py +234 -0
- package/artemis-main/artemis/graph/state.py +151 -0
- package/artemis-main/artemis/graph/visibility.py +238 -0
- package/artemis-main/artemis/interfaces/cli/__init__.py +19 -0
- package/artemis-main/artemis/interfaces/cli/commands/__init__.py +15 -0
- package/artemis-main/artemis/interfaces/cli/commands/batch.py +278 -0
- package/artemis-main/artemis/interfaces/cli/commands/doctor.py +468 -0
- package/artemis-main/artemis/interfaces/cli/commands/helper.py +226 -0
- package/artemis-main/artemis/interfaces/cli/commands/init.py +193 -0
- package/artemis-main/artemis/interfaces/cli/commands/mcp.py +862 -0
- package/artemis-main/artemis/interfaces/cli/commands/run.py +463 -0
- package/artemis-main/artemis/interfaces/cli/commands/server.py +151 -0
- package/artemis-main/artemis/interfaces/cli/commands/server_lifecycle.py +328 -0
- package/artemis-main/artemis/interfaces/cli/commands/trace.py +111 -0
- package/artemis-main/artemis/interfaces/cli/commands/ui.py +404 -0
- package/artemis-main/artemis/interfaces/cli/main.py +109 -0
- package/artemis-main/artemis/interfaces/sdk/__init__.py +31 -0
- package/artemis-main/artemis/interfaces/sdk/client.py +19 -0
- package/artemis-main/artemis/interfaces/sdk/task.py +72 -0
- package/artemis-main/artemis/llm/__init__.py +25 -0
- package/artemis-main/artemis/llm/fake_model.py +179 -0
- package/artemis-main/artemis/llm/google/__init__.py +57 -0
- package/artemis-main/artemis/llm/google/provider.py +185 -0
- package/artemis-main/artemis/llm/google/usage.py +148 -0
- package/artemis-main/artemis/llm/parser.py +32 -0
- package/artemis-main/artemis/llm/reliability.py +267 -0
- package/artemis-main/artemis/llm/router.py +389 -0
- package/artemis-main/artemis/llm/schemas.py +36 -0
- package/artemis-main/artemis/llm/structured.py +196 -0
- package/artemis-main/artemis/main.py +58 -0
- package/artemis-main/artemis/mcp/__init__.py +20 -0
- package/artemis-main/artemis/mcp/action_executor.py +760 -0
- package/artemis-main/artemis/mcp/action_manifest.py +320 -0
- package/artemis-main/artemis/mcp/action_names.py +172 -0
- package/artemis-main/artemis/mcp/action_server.py +202 -0
- package/artemis-main/artemis/mcp/action_session.py +245 -0
- package/artemis-main/artemis/mcp/action_specs.py +868 -0
- package/artemis-main/artemis/mcp/action_types.py +86 -0
- package/artemis-main/artemis/mcp/actuators/__init__.py +21 -0
- package/artemis-main/artemis/mcp/actuators/adb.py +414 -0
- package/artemis-main/artemis/mcp/actuators/base.py +113 -0
- package/artemis-main/artemis/mcp/actuators/mock.py +70 -0
- package/artemis-main/artemis/mcp/adb_server.py +490 -0
- package/artemis-main/artemis/mcp/observation.py +133 -0
- package/artemis-main/artemis/mcp/xml_search_server.py +297 -0
- package/artemis-main/artemis/memory/__init__.py +90 -0
- package/artemis-main/artemis/memory/chunking.py +1843 -0
- package/artemis-main/artemis/memory/context_policy.py +226 -0
- package/artemis-main/artemis/memory/step_capsule.md +73 -0
- package/artemis-main/artemis/memory/step_memory.py +281 -0
- package/artemis-main/artemis/memory/transcript.py +798 -0
- package/artemis-main/artemis/platform/__init__.py +61 -0
- package/artemis-main/artemis/platform/base.py +104 -0
- package/artemis-main/artemis/platform/darwin.py +184 -0
- package/artemis-main/artemis/platform/linux.py +207 -0
- package/artemis-main/artemis/platform/windows.py +193 -0
- package/artemis-main/artemis/resources/__init__.py +46 -0
- package/artemis-main/artemis/runtime/__init__.py +108 -0
- package/artemis-main/artemis/runtime/adb_endpoint.py +173 -0
- package/artemis-main/artemis/runtime/awake_lease.py +283 -0
- package/artemis-main/artemis/runtime/awake_service.py +343 -0
- package/artemis-main/artemis/runtime/cancel_requests.py +254 -0
- package/artemis-main/artemis/runtime/daemon_client.py +422 -0
- package/artemis-main/artemis/runtime/device_lock.py +1013 -0
- package/artemis-main/artemis/runtime/device_pool.py +478 -0
- package/artemis-main/artemis/runtime/helper_manager.py +1030 -0
- package/artemis-main/artemis/runtime/process_probe.py +98 -0
- package/artemis-main/artemis/runtime/server_lifecycle.py +435 -0
- package/artemis-main/artemis/runtime/supervisor.py +121 -0
- package/artemis-main/artemis/runtime/trace_store.py +375 -0
- package/artemis-main/artemis/sdk/__init__.py +26 -0
- package/artemis-main/artemis/sdk/agent.py +485 -0
- package/artemis-main/artemis/sdk/builders/__init__.py +29 -0
- package/artemis-main/artemis/sdk/builders/agent_config_builder.py +474 -0
- package/artemis-main/artemis/sdk/constants.py +15 -0
- package/artemis-main/artemis/sdk/run_outcome.py +58 -0
- package/artemis-main/artemis/sdk/types/__init__.py +61 -0
- package/artemis-main/artemis/sdk/types/agent.py +89 -0
- package/artemis-main/artemis/sdk/utils.py +28 -0
- package/artemis-main/artemis/services/llm.py +1040 -0
- package/artemis-main/artemis/services/token_meter.py +267 -0
- package/artemis-main/artemis/telemetry/__init__.py +35 -0
- package/artemis-main/artemis/telemetry/models.py +64 -0
- package/artemis-main/artemis/telemetry/replay.py +34 -0
- package/artemis-main/artemis/telemetry/storage/json_storage.py +47 -0
- package/artemis-main/artemis/telemetry/tracer.py +85 -0
- package/artemis-main/artemis/toolchain/__init__.py +52 -0
- package/artemis-main/artemis/toolchain/descriptors.py +92 -0
- package/artemis-main/artemis/toolchain/resolver.py +185 -0
- package/artemis-main/artemis/tools/base.py +232 -0
- package/artemis-main/artemis/tools/command_tool.py +1240 -0
- package/artemis-main/artemis/tools/committee_prompts.json +5 -0
- package/artemis-main/artemis/tools/committee_tool.py +422 -0
- package/artemis-main/artemis/tools/diagnoser_submit_answer_tool.py +87 -0
- package/artemis-main/artemis/tools/diagnostic_tool.py +120 -0
- package/artemis-main/artemis/tools/explorer_tool.py +581 -0
- package/artemis-main/artemis/tools/history/__init__.py +398 -0
- package/artemis-main/artemis/tools/history/replay.py +120 -0
- package/artemis-main/artemis/tools/history/screenshot.py +139 -0
- package/artemis-main/artemis/tools/history/search.py +563 -0
- package/artemis-main/artemis/tools/image_processor_tool.py +124 -0
- package/artemis-main/artemis/tools/index.py +47 -0
- package/artemis-main/artemis/tools/log_tool.py +108 -0
- package/artemis-main/artemis/tools/mobile/launch_app.py +132 -0
- package/artemis-main/artemis/tools/mobile/log_utils.py +138 -0
- package/artemis-main/artemis/tools/mobile/ocr.py +202 -0
- package/artemis-main/artemis/tools/mobile/read_hierarchy.py +108 -0
- package/artemis-main/artemis/tools/mobile/read_logs.py +116 -0
- package/artemis-main/artemis/tools/mobile/search_logs.py +260 -0
- package/artemis-main/artemis/tools/object_detection_tool.py +244 -0
- package/artemis-main/artemis/tools/scratchpad.py +819 -0
- package/artemis-main/artemis/tools/system/adb_shell.py +31 -0
- package/artemis-main/artemis/tools/tool_wrapper.py +270 -0
- package/artemis-main/artemis/tools/video_tool.py +261 -0
- package/artemis-main/artemis/tools/wait_tool.py +97 -0
- package/artemis-main/artemis/utils/app_launch_utils.py +207 -0
- package/artemis-main/artemis/utils/coordinates.py +606 -0
- package/artemis-main/artemis/utils/credentials_validator.py +201 -0
- package/artemis-main/artemis/utils/cv_canvas.py +189 -0
- package/artemis-main/artemis/utils/cython_compat.py +45 -0
- package/artemis-main/artemis/utils/element_hit_test.py +80 -0
- package/artemis-main/artemis/utils/image_diff.py +335 -0
- package/artemis-main/artemis/utils/image_hash.py +80 -0
- package/artemis-main/artemis/utils/notes.py +470 -0
- package/artemis-main/artemis/utils/ocr_api.py +105 -0
- package/artemis-main/artemis/utils/ocr_xml_fusion.py +318 -0
- package/artemis-main/artemis/utils/plan_grammar.py +513 -0
- package/artemis-main/artemis/utils/python_executor.py +213 -0
- package/artemis-main/artemis/utils/startup_progress.py +60 -0
- package/artemis-main/artemis/utils/task_tree.py +1174 -0
- package/artemis-main/artemis/utils/text.py +37 -0
- package/artemis-main/artemis/utils/ui_filter.py +651 -0
- package/artemis-main/artemis/utils/video.py +685 -0
- package/artemis-main/artemis/utils/visualization.py +802 -0
- package/artemis-main/config/artemis.example.jsonc +12 -0
- package/artemis-main/mcp_server/README.md +111 -0
- package/artemis-main/mcp_server/__init__.py +21 -0
- package/artemis-main/mcp_server/__main__.py +20 -0
- package/artemis-main/mcp_server/background/__init__.py +15 -0
- package/artemis-main/mcp_server/background/task_runner.py +453 -0
- package/artemis-main/mcp_server/base.py +49 -0
- package/artemis-main/mcp_server/notifiers/__init__.py +74 -0
- package/artemis-main/mcp_server/notifiers/agentapi.py +289 -0
- package/artemis-main/mcp_server/notifiers/base.py +45 -0
- package/artemis-main/mcp_server/notifiers/composite.py +87 -0
- package/artemis-main/mcp_server/notifiers/desktop.py +108 -0
- package/artemis-main/mcp_server/notifiers/file.py +69 -0
- package/artemis-main/mcp_server/notifiers/script.py +90 -0
- package/artemis-main/mcp_server/notifiers/webhook.py +87 -0
- package/artemis-main/mcp_server/rules.md +68 -0
- package/artemis-main/mcp_server/server.py +106 -0
- package/artemis-main/mcp_server/tools/__init__.py +29 -0
- package/artemis-main/mcp_server/tools/device_state.py +107 -0
- package/artemis-main/mcp_server/tools/diagnose.py +1249 -0
- package/artemis-main/mcp_server/tools/inspect_trace.py +394 -0
- package/artemis-main/mcp_server/tools/task_manager.py +627 -0
- package/artemis-main/mcp_server/tools/task_runner.py +548 -0
- package/artemis-main/mcp_server/utils/__init__.py +15 -0
- package/artemis-main/mcp_server/utils/device_utils.py +180 -0
- package/artemis-main/mcp_server/utils/env_utils.py +65 -0
- package/artemis-main/packages/artemis-accessibility-helper/ArtemisAccessibilityHelper.apk +0 -0
- package/artemis-main/packages/artemis-accessibility-helper/app/build.gradle.kts +35 -0
- package/artemis-main/packages/artemis-accessibility-helper/app/src/main/AndroidManifest.xml +49 -0
- package/artemis-main/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/A11yNode.java +274 -0
- package/artemis-main/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/ArtemisAccessibilityService.java +227 -0
- package/artemis-main/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/CommandServer.java +463 -0
- package/artemis-main/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/DisplayUtils.java +111 -0
- package/artemis-main/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/GestureController.java +232 -0
- package/artemis-main/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/HierarchyDumper.java +832 -0
- package/artemis-main/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/TokenReceiver.java +36 -0
- package/artemis-main/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/TokenStore.java +38 -0
- package/artemis-main/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/XmlUtils.java +102 -0
- package/artemis-main/packages/artemis-accessibility-helper/app/src/main/res/values/strings.xml +4 -0
- package/artemis-main/packages/artemis-accessibility-helper/app/src/main/res/xml/accessibility_service_config.xml +10 -0
- package/artemis-main/packages/artemis-accessibility-helper/build.gradle.kts +3 -0
- package/artemis-main/packages/artemis-accessibility-helper/build_apk.sh +215 -0
- package/artemis-main/packages/artemis-accessibility-helper/debug.keystore +0 -0
- package/artemis-main/packages/artemis-accessibility-helper/gradle.properties +2 -0
- package/artemis-main/packages/artemis-accessibility-helper/helper_manifest.json +7 -0
- package/artemis-main/packages/artemis-accessibility-helper/settings.gradle.kts +17 -0
- package/artemis-main/packages/artemis-client/DESIGN.md +89 -0
- package/artemis-main/packages/artemis-client/README.md +112 -0
- package/artemis-main/packages/artemis-client/examples/basic.py +30 -0
- package/artemis-main/packages/artemis-client/pyproject.toml +36 -0
- package/artemis-main/packages/artemis-client/src/artemis_client/__init__.py +45 -0
- package/artemis-main/packages/artemis-client/src/artemis_client/client.py +401 -0
- package/artemis-main/packages/artemis-client/src/artemis_client/errors.py +66 -0
- package/artemis-main/packages/artemis-client/src/artemis_client/models.py +212 -0
- package/artemis-main/packages/artemis-client/src/artemis_client/py.typed +1 -0
- package/artemis-main/packages/artemis-client/src/artemis_client/transport.py +134 -0
- package/artemis-main/packages/artemis-client/tests/test_client.py +289 -0
- package/artemis-main/packages/artemis-client/tests/test_transport.py +85 -0
- package/artemis-main/pyproject.toml +259 -0
- package/artemis-main/pyright-core.json +17 -0
- package/artemis-main/scripts/check_dependency_sources.py +68 -0
- package/artemis-main/scripts/install_deps.ps1 +520 -0
- package/artemis-main/scripts/install_deps.sh +533 -0
- package/artemis-main/scripts/quality_ratchet.py +97 -0
- package/artemis-main/scripts/real_device_video_acceptance.py +483 -0
- package/artemis-main/scripts/run_edge_case_part.py +327 -0
- package/artemis-main/scripts/run_ui.py +242 -0
- package/artemis-main/scripts/start.ps1 +500 -0
- package/artemis-main/scripts/test_all_edge_cases.py +269 -0
- package/artemis-main/scripts/verify_concurrency_and_devices.py +385 -0
- package/artemis-main/setup.py +164 -0
- package/artemis-main/start.bat +35 -0
- package/artemis-main/start.sh +423 -0
- package/artemis-main/third_party/mobile_use/LICENSE +190 -0
- package/artemis-main/third_party/mobile_use/METADATA +27 -0
- package/artemis-main/third_party/mobile_use/NOTICE +12 -0
- package/artemis-main/third_party/mobile_use/__init__.py +19 -0
- package/artemis-main/third_party/mobile_use/agents/__init__.py +13 -0
- package/artemis-main/third_party/mobile_use/agents/hopper/__init__.py +13 -0
- package/artemis-main/third_party/mobile_use/agents/hopper/hopper.md +22 -0
- package/artemis-main/third_party/mobile_use/agents/hopper/hopper.py +80 -0
- package/artemis-main/third_party/mobile_use/clients/__init__.py +13 -0
- package/artemis-main/third_party/mobile_use/clients/ui_automator_client.py +482 -0
- package/artemis-main/third_party/mobile_use/config/__init__.py +13 -0
- package/artemis-main/third_party/mobile_use/config/llm.py +183 -0
- package/artemis-main/third_party/mobile_use/config/output.py +184 -0
- package/artemis-main/third_party/mobile_use/context.py +119 -0
- package/artemis-main/third_party/mobile_use/controllers/__init__.py +13 -0
- package/artemis-main/third_party/mobile_use/controllers/platform_specific_commands_controller.py +151 -0
- package/artemis-main/third_party/mobile_use/controllers/types.py +92 -0
- package/artemis-main/third_party/mobile_use/controllers/unified_controller.py +183 -0
- package/artemis-main/third_party/mobile_use/graph.py +54 -0
- package/artemis-main/third_party/mobile_use/llm_service.py +193 -0
- package/artemis-main/third_party/mobile_use/main.py +130 -0
- package/artemis-main/third_party/mobile_use/sdk/__init__.py +13 -0
- package/artemis-main/third_party/mobile_use/sdk/agent.py +1060 -0
- package/artemis-main/third_party/mobile_use/sdk/builders/__init__.py +13 -0
- package/artemis-main/third_party/mobile_use/sdk/builders/agent_config_builder.py +225 -0
- package/artemis-main/third_party/mobile_use/sdk/builders/index.py +33 -0
- package/artemis-main/third_party/mobile_use/sdk/builders/task_request_builder.py +243 -0
- package/artemis-main/third_party/mobile_use/sdk/types/__init__.py +13 -0
- package/artemis-main/third_party/mobile_use/sdk/types/agent.py +98 -0
- package/artemis-main/third_party/mobile_use/sdk/types/exceptions.py +109 -0
- package/artemis-main/third_party/mobile_use/sdk/types/task.py +234 -0
- package/artemis-main/third_party/mobile_use/testing.py +59 -0
- package/artemis-main/third_party/mobile_use/tools/__init__.py +13 -0
- package/artemis-main/third_party/mobile_use/tools/index.py +44 -0
- package/artemis-main/third_party/mobile_use/tools/mobile/__init__.py +13 -0
- package/artemis-main/third_party/mobile_use/tools/mobile/launch_app.py +89 -0
- package/artemis-main/third_party/mobile_use/tools/tool_wrapper.py +52 -0
- package/artemis-main/third_party/mobile_use/utils/__init__.py +13 -0
- package/artemis-main/third_party/mobile_use/utils/app_launch_utils.py +217 -0
- package/artemis-main/third_party/mobile_use/utils/cli_helpers.py +60 -0
- package/artemis-main/third_party/mobile_use/utils/decorators.py +158 -0
- package/artemis-main/third_party/mobile_use/utils/file.py +31 -0
- package/artemis-main/third_party/mobile_use/utils/logger.py +216 -0
- package/artemis-main/third_party/mobile_use/utils/media.py +213 -0
- package/artemis-main/third_party/mobile_use/utils/shell_utils.py +39 -0
- package/artemis-main/third_party/mobile_use/utils/video.py +398 -0
- package/artemis-main/uv.lock +4908 -0
- package/bin/android-midscene-automation.js +1 -5
- package/bin/artemis-config.mjs +15 -0
- package/bin/artemis-embedded.py +56 -0
- package/bin/start-artemis.mjs +30 -0
- package/bin/start-dev.mjs +112 -0
- package/bin/test_artemis_embedded.py +44 -0
- package/package.json +10 -8
- package/server/artemis.ts +34 -0
- package/server/http-api.ts +13 -1
- package/src/App.vue +72 -23
- package/src/api.ts +5 -0
- package/src/appium-recorder/AppiumPage.vue +1 -1
- package/src/appium-recorder/components/ComponentInspector.vue +1 -1
- package/src/appium-recorder/components/FlowNodeSearch.vue +3 -3
- package/src/appium-recorder/components/ImageCheckDialog.vue +3 -3
- package/src/appium-recorder/components/RecorderWorkspace.vue +4 -4
- package/src/appium-recorder/flow-appearance.ts +4 -4
- package/src/components/AppSidebar.vue +8 -7
- package/src/components/MidsceneMaintenanceNotice.vue +17 -0
- package/src/components/analytics/AnalysisCharts.vue +4 -4
- package/src/components/analytics/ReportAnalysis.vue +1 -1
- package/src/components/generator/TestCaseFileUpload.vue +1 -1
- package/src/components/help/HelpCenter.vue +56 -37
- package/src/pages/AiGeneratorPage.vue +1 -1
- package/src/pages/ArtemisPage.vue +72 -0
- package/src/pages/ConfigPage.vue +18 -19
- package/src/style.css +369 -186
- 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
|