feat: add plugin runtime and cross-device task orchestration

This commit is contained in:
chenmingxuan committed 2026-10-14 20:21:44 +08:00
1 parent 3ee4a5f7a8
commit 6663233ab8
82 files changed
+4147 -124

No files matched your search

@@ -13,6 +13,7 @@
# limitations under the License.
import asyncio
import json
from datetime import datetime
import logging
import os
@@ -275,6 +276,11 @@ class TaskQueueService:
]
for item in removed_items:
DeviceExecutionLock.cancel_reservation(item.get("queue_ticket"))
from artemis.plugins.store import PluginStore
# A stop request may already have removed this row. The worker finalizer
# must still release the server-owned version reservation after exit.
PluginStore().release(str(session_id))
state.queue_items = [
t
for t in state.queue_items
@@ -417,6 +423,9 @@ class TaskQueueService:
busy_devices = state.busy_device_ids | {
cls._task_target(i).lock_key for i in in_flight if i.get("device_serial")
}
busy_resources = {r for item in in_flight for r in item.get("plugin_resources", [])}
for owner in DeviceExecutionLock.get_active_owners().values():
busy_resources.update(owner.resource_ids)
dispatched_any = False
loop = asyncio.get_running_loop()
for item in list(state.queue_items):
@@ -429,6 +438,9 @@ class TaskQueueService:
device = item.get("device_serial")
target = cls._task_target(item)
resources = set(item.get("plugin_resources", []))
if resources & busy_resources:
continue
if limit == 0:
# A task without a resolved device may bind to any serial, so it
# only launches on an otherwise idle scheduler; the device lock
@@ -446,6 +458,7 @@ class TaskQueueService:
continue
item["status"] = "running"
busy_resources.update(resources)
dispatched_any = True
if device is not None:
busy_devices.add(target.lock_key)
@@ -593,6 +606,11 @@ class TaskQueueService:
else str(overrides[option])
)
target.endpoint.apply_to_environment(env)
if "plugins" in overrides:
env["ARTEMIS_TASK_PLUGINS"] = json.dumps(overrides["plugins"])
else:
env.pop("ARTEMIS_TASK_PLUGINS", None)
env["ARTEMIS_EXECUTION_TARGET"] = overrides.get("execution_target") or "android"
env[DeviceExecutionLock.LOCK_SCOPE_ENV] = target.lock_scope
queue_ticket = task_item.get("queue_ticket")
if queue_ticket:
@@ -623,7 +641,7 @@ class TaskQueueService:
if app_path:
cmd.extend(["--app-path", str(app_path)])
device_serial = task_item.get("device_serial")
if device_serial:
if device_serial and env["ARTEMIS_EXECUTION_TARGET"] != "computer":
cmd.extend(["--device-serial", str(device_serial)])
env["ADB_DEVICE_SERIAL"] = str(device_serial)
from apps.admin_console.services.automation_store import store
@@ -1237,11 +1255,40 @@ class TaskQueueService:
if duplicate_response is not None:
return duplicate_response
rejection_response = await cls._reject_unavailable_device(device_serial)
execution_target = (runtime_overrides or {}).get("execution_target") or "android"
from pydantic import TypeAdapter
from artemis.plugins.models import PluginSelection
from artemis.plugins.store import PluginStore
choices = (runtime_overrides or {}).get("plugins")
try:
pinned = PluginStore().pin(
TypeAdapter(list[PluginSelection]).validate_python(choices)
if choices is not None
else None,
execution_target,
)
except ValueError:
return {
"status": "error",
"message": "插件未就绪、未授权或不支持所选任务目标,请检查插件配置",
}
runtime_overrides = {
**(runtime_overrides or {}),
"execution_target": execution_target,
"plugins": [p.model_dump() for p in pinned],
}
rejection_response = (
await cls._reject_unavailable_device(device_serial)
if execution_target != "computer"
else None
)
if rejection_response is not None:
return rejection_response
single_session_id = session_id if (session_id and len(goals) == 1) else None
if execution_target == "computer":
device_serial = "computer:local"
if not device_serial:
# Device enumeration may block on ADB.
from artemis.runtime import device_pool
@@ -1270,6 +1317,19 @@ class TaskQueueService:
)
if runtime_overrides:
task_item["runtime_overrides"] = runtime_overrides
if pinned:
try:
task_item["plugin_resources"] = PluginStore().reserve(
task_item["session_id"], pinned, execution_target
)
except (ValueError, OSError):
DeviceExecutionLock.cancel_reservation(task_item.get("queue_ticket"))
for previous in enqueued_tasks:
cls._remove_task(previous["session_id"])
return {
"status": "error",
"message": "插件配置在入队前发生变化,请检查后重新提交",
}
if display_name:
task_item["display_name"] = display_name
state.queue_items.append(task_item)
@@ -1380,6 +1440,7 @@ class TaskQueueService:
"""Terminate all active device owners and clear pending queue submissions."""
from apps.admin_console.services.automation_store import store
from apps.admin_console.services.automation_service import on_terminal, spawn
from artemis.plugins.store import PluginStore
for batch in store.records("batch"):
if batch["status"] == "running":
@@ -1389,6 +1450,7 @@ class TaskQueueService:
for item in state.queue_items:
if isinstance(item, dict) and item.get("status") != "running":
DeviceExecutionLock.cancel_reservation(item.get("queue_ticket"))
PluginStore().release(str(item.get("session_id")))
if store.job(str(item.get("session_id"))):
spawn(on_terminal(item, "cancelled"))
state.clear_queue()
@@ -1712,7 +1774,9 @@ class TaskQueueService:
if stopped_item and stopped_item.get("status") != "running":
from apps.admin_console.services.automation_store import store
from apps.admin_console.services.automation_service import on_terminal, spawn
from artemis.plugins.store import PluginStore
PluginStore().release(str(stopped_session_id))
if store.job(str(stopped_session_id)):
spawn(on_terminal(stopped_item, "cancelled"))
if stopped_item and not reservation_cancelled: