feat: release 2.2.0 with PC-only tests and execution reliability fixes

Unify task scheduling and queue state, fix Android input and observation recovery, preserve screenshot paths, and match final review criteria accurately. Add collapsible copyable results and publish the complete 2.1.0 upgrade notes. Validation: 3189 backend tests, 326 frontend tests, 24 client tests, production build and quality gates.
This commit is contained in:
chenmingxuan committed 2026-10-27 01:13:41 +08:00
1 parent fac4ccb760
commit be6e8c2abd
79 files changed
+2263 -277

No files matched your search

+25 -31
View File
@@ -89,9 +89,8 @@ class DeviceLockOwner:
class DeviceExecutionLock:
"""A PID-aware FIFO lease supporting per-device mutexes and global concurrency limits.
Artemis enforces a single active task per mobile device at any time.
Different devices can execute tasks concurrently unless a global concurrency
limit (e.g. concurrency_mode="global" or ARTEMIS_MAX_CONCURRENT_TASKS=1) is configured.
Artemis 2.2 serializes tasks across Android and computer targets. Legacy
concurrency options remain accepted but cannot enable parallel execution.
Atomic file creation provides exclusion per device serial. PID creation time
prevents stale owners or queue tickets from blocking execution after a process
@@ -102,6 +101,7 @@ class DeviceExecutionLock:
_DEFAULT_POLL_INTERVAL_SECONDS = 0.1
QUEUE_TICKET_ENV = "ARTEMIS_DEVICE_QUEUE_TICKET"
LOCK_SCOPE_ENV = "ARTEMIS_ADB_ENDPOINT_ID"
EXECUTION_RESOURCE = "artemis:execution"
@staticmethod
def _normalize_device_id(device_id: str | None) -> str:
@@ -160,7 +160,12 @@ class DeviceExecutionLock:
self._queue_path: Path | None = None
self._acquired = False
self.resource_ids = tuple(
sorted(set(r.strip().casefold() for r in resource_ids if r.strip()))
sorted(
{
self.EXECUTION_RESOURCE,
*(r.strip().casefold() for r in resource_ids if r.strip()),
}
)
)
self._resource_paths: list[Path] = []
@@ -613,7 +618,15 @@ class DeviceExecutionLock:
path = self.path.parent / f"artemis-device-resource-{digest}.lock"
# Same resource identity applies across plugins and ADB server endpoints.
owner = self._get_active_owner_for_path(path)
if owner is not None and owner.token == self.token:
if owner is not None and (
owner.token == self.token
or (
resource == self.EXECUTION_RESOURCE
and owner.pid == os.getpid()
and self.session_id
and owner.session_id == self.session_id
)
):
continue
try:
fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
@@ -675,37 +688,17 @@ class DeviceExecutionLock:
@property
def effective_concurrency(self) -> int:
"""Resolve effective concurrency limit:
0: per-device concurrency (multiple devices run concurrently, FIFO per device)
1: global concurrency (strict single-task serialization across all devices)
>1: global concurrency capped at N tasks across all devices
"""
if self.concurrency_mode is not None:
mode = self.concurrency_mode
if mode in ("global", "serial", "1"):
return 1
if mode in ("per_device", "device", "parallel", "0"):
return 0
if self.max_concurrency is not None:
return self.max_concurrency
"""All task entrypoints share the current single-task execution policy."""
return self.resolve_env_concurrency()
@classmethod
def resolve_env_concurrency(cls) -> int:
"""Resolve the concurrency limit from the environment.
"""Compatibility entrypoint shared with the scheduler: execution is serial.
Shared with the admin scheduler. Returns 0 for per-device concurrency,
or a positive global limit.
Legacy environment/SDK concurrency values cannot opt out in 2.2.0.
The shared execution resource also makes admission atomic across processes.
"""
env_mode = os.environ.get("ARTEMIS_CONCURRENCY_MODE", "").strip().lower()
if env_mode in ("global", "serial", "1"):
return 1
if env_mode in ("per_device", "device", "parallel", "0"):
return 0
try:
return int(os.environ.get("ARTEMIS_MAX_CONCURRENT_TASKS", 0))
except (ValueError, TypeError):
return 0
return 1
def acquire(
self,
@@ -768,6 +761,7 @@ class DeviceExecutionLock:
# 2. Determine FIFO eligibility for this specific device
all_wait_files = sorted(self.queue_dir.glob("*.wait"))
is_reentrant = False
if effective_concurrency == 1:
# In strict serial mode, global FIFO applies; re-entrant parent processes are always eligible
active_owners = self.get_active_owners()
@@ -794,7 +788,7 @@ class DeviceExecutionLock:
)
is_eligible = bool(device_queue and device_queue[0] == self._queue_path)
if is_eligible and self.resource_ids:
if is_eligible and self.resource_ids and not is_reentrant:
for earlier in all_wait_files:
if earlier == self._queue_path:
break
+51
View File
@@ -0,0 +1,51 @@
"""One observation deadline shared with synchronous screen-client worker threads."""
from contextvars import ContextVar
from dataclasses import dataclass, field
from threading import Event
from time import monotonic
class ObservationExpired(TimeoutError):
"""The shared observation deadline has elapsed."""
class ObservationCancelled(RuntimeError):
"""The owning driver has ended this observation."""
@dataclass
class ObservationBudget:
deadline: float
cancelled: Event = field(default_factory=Event)
phase: str = "获取屏幕"
last_failure: str | None = None
def remaining(self) -> float:
if self.cancelled.is_set():
raise ObservationCancelled("设备观测已取消,禁止继续重连")
remaining = self.deadline - monotonic()
if remaining <= 0:
raise ObservationExpired(f"设备观测预算耗尽:{self.detail}")
return remaining
@property
def detail(self) -> str:
return self.phase + (f";最近错误:{self.last_failure}" if self.last_failure else "")
current_observation: ContextVar[ObservationBudget | None] = ContextVar(
"screen_observation_budget", default=None
)
def check_observation(phase: str | None = None, failure: BaseException | None = None) -> None:
budget = current_observation.get()
if budget is not None:
if failure is not None and not isinstance(
failure, (ObservationExpired, ObservationCancelled)
):
budget.last_failure = f"{type(failure).__name__}: {failure}"
budget.remaining()
if phase is not None:
budget.phase = phase
+6
View File
@@ -100,6 +100,12 @@ def write_control_state(session_id: str, status: str, *, reset: bool = False, **
"timing": timing,
},
)
if status == "running" and (control_dir(session_id) / "status.json").is_file():
from artemis.runtime import trace_store
# The worker reaches this state only after acquiring its execution lease.
# Admission stays pending until then; never revive a cancelled/finished task.
trace_store.update_trace_status(session_id, "running", expected_status="pending")
def request_pause(session_id: str, *, reason: str | None = None) -> None:
+7
View File
@@ -288,12 +288,16 @@ def update_trace_status(
error: str | None = None,
result: Any | None = None,
device_serial: str | None = None,
*,
expected_status: str | None = None,
) -> dict[str, Any] | None:
"""Updates specific fields of the status.json for a given trace_id.
The read-modify-write cycle holds a cross-process lock so concurrent
updates from the daemon, MCP tools, and worker processes do not lose each
other's fields.
When expected_status is supplied, apply the update only if it still matches
inside the lock; a late worker transition must not revive a terminal task.
"""
path = get_status_path(trace_id)
with _status_lock(path):
@@ -306,6 +310,9 @@ def update_trace_status(
)
return None
if expected_status is not None and data.get("status") != expected_status:
return data
# "success" is a legacy alias for the canonical "completed" terminal
# status and must never be persisted into status.json.
if status == "success":