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:
1 parent
fac4ccb760
commit
be6e8c2abd
79 files changed
+2263
-277
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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":
|
||||
|
||||
Reference in new issue
Block a user