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

+57 -39
View File
@@ -80,18 +80,14 @@ def test_same_serial_on_different_adb_endpoints_has_independent_locks():
)
first.acquire()
second.acquire()
try:
assert first.path != second.path
owners = DeviceExecutionLock.get_active_owners()
assert len(owners) == 2
assert {owner.lock_scope for owner in owners.values()} == {
"tcp:127.0.0.1:5037",
"tcp:127.0.0.1:5038",
}
with pytest.raises(DeviceBusyError, match="concurrency limit"):
second.acquire(blocking=False)
finally:
second.release()
first.release()
second.acquire(blocking=False)
second.release()
def test_device_lock_release_does_not_remove_replacement_owner():
@@ -185,28 +181,19 @@ def test_submission_reservations_preserve_order_before_workers_start():
thread.join(timeout=2.0)
def test_pending_reservation_at_queue_head_does_not_block_other_devices():
"""An unclaimed pending submission must not gate every device's queue head.
Regression: a reservation whose device is still "pending" used to be
counted into every device's FIFO queue, so one unclaimed head-of-queue
ticket blocked all devices at once. It must now park on at most one idle
candidate device (the first in sorted order) and leave the others
schedulable.
"""
def test_pending_reservation_at_queue_head_preserves_global_submission_order():
"""Unresolved earlier submissions retain their place across target devices."""
pending_ticket = DeviceExecutionLock.reserve("unclaimed submission")
# device-alpha is a known idle device (a queued submission targets it), so
# the pending ticket parks there; device-beta must stay schedulable.
alpha_ticket = DeviceExecutionLock.reserve("task for device alpha", device_id="device-alpha")
try:
beta = DeviceExecutionLock("device-beta", "task for device beta")
# Must acquire immediately even though the older pending ticket is at
# the head of the global queue directory.
beta.acquire(blocking=False)
beta.release()
with pytest.raises(DeviceBusyError, match="earlier queued task"):
beta.acquire(blocking=False)
finally:
DeviceExecutionLock.cancel_reservation(pending_ticket)
DeviceExecutionLock.cancel_reservation(alpha_ticket)
beta.acquire(blocking=False)
beta.release()
def test_pending_reservation_is_served_with_original_fifo_position_after_claim():
@@ -326,27 +313,58 @@ def test_active_owner_is_discoverable_and_can_be_annotated(monkeypatch):
owner_lock.release()
def test_multi_device_locks_can_run_concurrently():
lock_a = DeviceExecutionLock("emulator-5554", "task on device A")
lock_b = DeviceExecutionLock("pixel-9-test", "task on device B")
@pytest.mark.parametrize("targets", [("emulator-5554", "computer"), ("computer", "pixel-9-test")])
@pytest.mark.parametrize("legacy_mode", ["per_device", "parallel", "global"])
def test_all_targets_run_serially_even_with_legacy_parallel_options(
monkeypatch, targets, legacy_mode
):
monkeypatch.setenv("ARTEMIS_CONCURRENCY_MODE", legacy_mode)
monkeypatch.setenv("ARTEMIS_MAX_CONCURRENT_TASKS", "8")
lock_a = DeviceExecutionLock(
targets[0], "task A", concurrency_mode=legacy_mode, max_concurrency=0
)
lock_b = DeviceExecutionLock(
targets[1], "task B", concurrency_mode=legacy_mode, max_concurrency=8
)
lock_a.acquire()
try:
# Device B should be acquired without blocking since it's a different device
lock_b.acquire(blocking=False)
try:
active_owners = DeviceExecutionLock.get_active_owners()
assert "emulator-5554" in active_owners
assert "pixel-9-test" in active_owners
# A second task on device A should be blocked
lock_a_second = DeviceExecutionLock("emulator-5554", "second task on device A")
with pytest.raises(DeviceBusyError, match="task on device A"):
lock_a_second.acquire(blocking=False)
finally:
lock_b.release()
with pytest.raises(DeviceBusyError, match="task A"):
lock_b.acquire(blocking=False)
finally:
lock_a.release()
lock_b.acquire(blocking=False)
lock_b.release()
def test_shared_execution_lease_prevents_racing_admission():
"""Both schedulers can see an idle snapshot; only one may create a lease."""
first = DeviceExecutionLock("computer", "PC")
second = DeviceExecutionLock("android", "Android")
assert first._try_acquire_owner_lock()
try:
assert not second._try_acquire_owner_lock()
assert not second.path.exists()
finally:
first.release()
assert second._try_acquire_owner_lock()
second.release()
def test_same_session_nested_lease_does_not_wait_behind_other_tasks():
parent = DeviceExecutionLock("android", "parent", session_id="same-session")
nested = DeviceExecutionLock("computer", "nested", session_id="same-session")
parent.acquire()
earlier = DeviceExecutionLock.reserve("another task", device_id="computer")
try:
nested.acquire(blocking=False)
nested.release()
# Borrowing the shared resource must not delete the parent's lease.
challenger = DeviceExecutionLock("another-device", "challenger")
assert not challenger._try_acquire_owner_lock()
finally:
DeviceExecutionLock.cancel_reservation(earlier)
parent.release()
def test_global_concurrency_serial_mode_blocks_other_devices(monkeypatch):
+35
View File
@@ -14,6 +14,41 @@ import pytest
import artemis.runtime.task_control as task_control
def test_worker_admission_and_lease_acquisition_are_distinct_mcp_states(tmp_path, monkeypatch):
from artemis.runtime import trace_store
from mcp_server.tools import task_manager
monkeypatch.setattr(task_control, "TRACES_PATH", tmp_path)
monkeypatch.setattr(trace_store, "TRACES_DIR", str(tmp_path))
monkeypatch.setattr(task_manager, "_find_data_engine_db", lambda: None)
for sid in ("worker", "waiting"):
trace_store.init_trace(sid, "read-only test", "Pro")
trace_store.update_trace_status(sid, "pending")
task_control.write_control_state("worker", "queued", reset=True)
assert trace_store.read_status("worker")["status"] == "pending"
task_control.TaskControl("worker", object())._state("running")
for sid, expected in (("worker", "running"), ("waiting", "pending")):
stored = trace_store.read_status(sid)
assert stored["status"] == expected
assert task_manager._reconcile_task_state(sid, stored)[0] == expected
@pytest.mark.parametrize("status", ["completed", "failed", "cancelled", "interrupted"])
def test_late_worker_start_preserves_terminal_trace(tmp_path, monkeypatch, status):
from artemis.runtime import trace_store
monkeypatch.setattr(task_control, "TRACES_PATH", tmp_path)
monkeypatch.setattr(trace_store, "TRACES_DIR", str(tmp_path))
trace_store.init_trace("finished", "read-only test", "Pro")
before = trace_store.update_trace_status("finished", status, result={"kept": True})
task_control.TaskControl("finished", object())._state("running")
assert trace_store.read_status("finished") == before
def test_transition_timing_keeps_repeated_states_and_separates_pause_and_resource_wait(
tmp_path, monkeypatch
):
+26
View File
@@ -89,6 +89,32 @@ def test_update_trace_status_normalizes_success_alias(temp_trace_env):
assert persisted["status"] == "completed"
def test_conditional_status_update_checks_state_after_acquiring_lock(temp_trace_env, monkeypatch):
from contextlib import contextmanager
trace_store.init_trace("late-worker", "Test task", "Pro")
trace_store.update_trace_status("late-worker", "pending")
lock = trace_store._status_lock
terminal = {}
@contextmanager
def cancelled_before_lock(path):
with lock(path):
data = trace_store.read_status("late-worker")
data.update(status="cancelled", result="user stopped", end_time=123)
trace_store.write_status("late-worker", data)
terminal.update(data)
yield
monkeypatch.setattr(trace_store, "_status_lock", cancelled_before_lock)
result = trace_store.update_trace_status(
"late-worker", "running", expected_status="pending", result="must not overwrite"
)
assert result == terminal
assert trace_store.read_status("late-worker") == terminal
def test_read_nonexistent_status(temp_trace_env):
non_existent = str(uuid.uuid4())
assert trace_store.read_status(non_existent) is None