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