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
@@ -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):
|
||||
|
||||
@@ -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
|
||||
):
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user