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

+10
View File
@@ -23,6 +23,10 @@ normalization, and result models. Device discovery, task scheduling, concurrency
control, model selection, execution, logs, videos, and artifact generation all
belong to the server.
Artemis 2.2.0 executes one task at a time across computer and Android targets.
Legacy concurrency options remain accepted for compatibility, but cannot enable
parallel execution. The client does not implement a separate scheduler.
The distribution is named `artemis-client`, while the Python import is
`artemis_client`. This prevents it from shadowing the full runtime's top-level
`artemis` package. The full runtime can depend on this package later and
@@ -39,6 +43,12 @@ session database, the client reconstructs its state from `queue` and
Experimental parameters live in `options`. Older servers ignore unknown fields,
while newer servers can consume them without requiring an immediate SDK release.
On the current host, `options` maps to the shared `runtime_overrides` TaskOptions
object rather than an unknown top-level `options` field. The first-class
`execution_target` argument is serialized there too. `computer` omits saved
Android serials; the host owns plugin selection, desktop observation, resource
locks and execution. The client never discovers Android for a PC-only submission.
### Guided exploration in 2.0
`ArtemisClient.exploration()` uses the existing `/api/explorations` API shared
+23
View File
@@ -7,6 +7,9 @@ ADB, device-driver, agent, LLM, or image-processing code.
Runtime dependencies: **zero**. HTTP, JSON, and asynchronous adapters use only
the Python standard library.
Artemis 2.2.0 queues computer and Android tasks for serial execution. Legacy
concurrency options cannot enable parallel execution on that server version.
## Installation
Install from a local checkout:
@@ -44,6 +47,26 @@ and bearer-token authentication and exposes only task-related APIs.
## Usage
PC-only tasks do not need Android or an emulator online. Enable and authorize a
computer observation plugin on the host first, then use the same target as the
web launcher's “仅 PC” checkbox:
```python
handle = await client.submit(
"Open the desktop browser and inspect the requested page",
profile="flash", # "pro" is also supported
execution_target="computer",
)
```
`run()` accepts the same argument. For task-like objects, use
`Task(goal="Check desktop", execution_target="computer")` from the full runtime
and pass it to `client.run_task()`. A saved Android device on the client is ignored
for PC-only tasks. Per-task `options` are sent as the server's `runtime_overrides`,
for example `options={"execution_target":"computer","plugins":[{"id":"cua-driver","profile":"default"}]}`.
Plugin IDs must exist on the host; omitting `plugins` selects enabled authorized
defaults for the target. Mixed tasks still require Android.
For interactive case design, use `client.exploration` with the same actions as the
web workspace and `mobile_exploration`. Use a stable request ID when retrying a write:
@@ -8,7 +8,7 @@
"""Public API for the lightweight Artemis remote client."""
from artemis_client.client import ArtemisClient
from artemis_client.client import ArtemisClient, ExecutionTarget
from artemis_client.errors import (
ApiError,
ArtemisClientError,
@@ -33,6 +33,7 @@ __all__ = [
"Capabilities",
"ConflictError",
"Device",
"ExecutionTarget",
"JsonTransport",
"NetworkError",
"NotFoundError",
@@ -39,6 +39,8 @@ VERIFICATION_LEVELS: tuple[str, ...] = ("off", "final", "checkpoints", "strict")
#: Pro-profile Explorer perception versions accepted by ``/api/run`` (``explorer_mode``).
ExplorerMode = Literal["flash", "pro", "ultra"]
EXPLORER_MODES: tuple[str, ...] = ("flash", "pro", "ultra")
ExecutionTarget = Literal["android", "computer", "mixed"]
EXECUTION_TARGETS: tuple[str, ...] = ("android", "computer", "mixed")
def _normalize_choice(value: str | None, name: str, choices: tuple[str, ...]) -> str | None:
@@ -82,7 +84,7 @@ class ArtemisClient:
device_id: str | None = None,
device_serial: str | None = None,
default_profile: Literal["flash", "pro"] = "flash",
concurrency_mode: str = "per_device",
concurrency_mode: str = "global",
max_concurrency: int | None = None,
standalone: bool = False,
) -> None:
@@ -187,6 +189,7 @@ class ArtemisClient:
task_id: str | None = None,
verification_level: VerificationLevel | None = None,
explorer_mode: ExplorerMode | None = None,
execution_target: ExecutionTarget | None = None,
options: Mapping[str, Any] | None = None,
) -> TaskHandle:
"""Submit one task and return immediately after scheduler admission.
@@ -197,7 +200,9 @@ class ArtemisClient:
``strict``: how much the Checker audits a Pro run) and
``explorer_mode`` (``flash`` | ``pro`` | ``ultra``: the Pro Operator's
perception depth) are Pro-only tuning knobs; the Flash profile ignores
them. Experimental, forward-compatible fields belong in ``options``.
them. ``execution_target="computer"`` runs only on the host PC without
Android discovery. Per-task ``options`` use the host's TaskOptions schema
and are sent as ``runtime_overrides`` (including plugin selections).
"""
normalized_goal = goal.strip()
if not normalized_goal:
@@ -206,6 +211,18 @@ class ArtemisClient:
verification_level, "verification_level", VERIFICATION_LEVELS
)
resolved_mode = _normalize_choice(explorer_mode, "explorer_mode", EXPLORER_MODES)
runtime_overrides = dict(options) if options is not None else {}
resolved_target = _normalize_choice(
execution_target
if execution_target is not None
else runtime_overrides.get("execution_target"),
"execution_target",
EXECUTION_TARGETS,
)
if resolved_target is not None:
runtime_overrides["execution_target"] = resolved_target
if resolved_target == "computer":
runtime_overrides.pop("device_serial", None)
if task_id is None:
resolved_task_id = str(uuid.uuid4())
else:
@@ -214,7 +231,9 @@ class ArtemisClient:
except (AttributeError, TypeError, ValueError) as exc:
raise ValueError("task_id must be a valid UUID string") from exc
resolved_profile = profile or self.default_profile
resolved_device = device_serial or self.device_serial
resolved_device = (
None if resolved_target == "computer" else device_serial or self.device_serial
)
payload: dict[str, Any] = {
"goal": normalized_goal,
"profile": resolved_profile,
@@ -230,7 +249,9 @@ class ArtemisClient:
"conversation_id": conversation_id,
"verification_level": resolved_level,
"explorer_mode": resolved_mode,
"options": dict(options) if options is not None else None,
"runtime_overrides": runtime_overrides
if options is not None or runtime_overrides
else None,
}
payload.update({key: value for key, value in optional_values.items() if value is not None})
@@ -307,6 +328,7 @@ class ArtemisClient:
task_id: str | None = None,
verification_level: VerificationLevel | None = None,
explorer_mode: ExplorerMode | None = None,
execution_target: ExecutionTarget | None = None,
options: Mapping[str, Any] | None = None,
timeout: float = 1800.0,
poll_interval: float | None = None,
@@ -324,6 +346,7 @@ class ArtemisClient:
task_id=task_id,
verification_level=verification_level,
explorer_mode=explorer_mode,
execution_target=execution_target,
options=options,
)
return await self.wait_for_task(
@@ -342,6 +365,7 @@ class ArtemisClient:
"device_serial": getattr(task, "device_serial", None)
or getattr(task, "device_id", None),
"locked_app_package": getattr(task, "locked_package", None),
"execution_target": getattr(task, "execution_target", None),
}
values.update(overrides)
return await self.run(goal, **values)
+45 -1
View File
@@ -18,6 +18,7 @@ from artemis_client import (
NotFoundError,
ProtocolError,
TaskRejectedError,
TaskResult,
TaskTimeoutError,
)
@@ -105,7 +106,50 @@ class ArtemisClientTests(unittest.IsolatedAsyncioTestCase):
assert body is not None
self.assertEqual(body["session_id"], task_id)
self.assertEqual(body["ingress"], "python_sdk")
self.assertEqual(body["options"], {"record_video": True})
self.assertEqual(body["runtime_overrides"], {"record_video": True})
async def test_pc_submission_ignores_saved_phone_and_uses_shared_options(self) -> None:
self.client.device_serial = "offline-phone"
options = {"device_serial": "old-phone", "plugins": [{"id": "cua", "profile": "default"}]}
self.transport.add(
"POST", "/api/run", {"tasks": [{"session_id": "pc", "status": "pending"}]}
)
await self.client.submit("Check desktop", execution_target="computer", options=options)
body = self.transport.calls[-1][2]
assert body is not None
self.assertNotIn("device_serial", body)
self.assertEqual(
body["runtime_overrides"],
{"execution_target": "computer", "plugins": options["plugins"]},
)
self.assertEqual(options["device_serial"], "old-phone")
async def test_pc_target_in_options_and_task_object_are_forwarded(self) -> None:
self.transport.add(
"POST", "/api/run", {"tasks": [{"session_id": "pc", "status": "pending"}]}
)
await self.client.submit("Check desktop", options={"execution_target": "computer"})
self.assertEqual(
self.transport.calls[-1][2]["runtime_overrides"], {"execution_target": "computer"}
)
from unittest.mock import AsyncMock
from types import SimpleNamespace
self.client.wait_for_task = AsyncMock(
return_value=TaskResult(task_id="pc", status="completed")
)
self.transport.add(
"POST", "/api/run", {"tasks": [{"session_id": "pc", "status": "pending"}]}
)
await self.client.run_task(SimpleNamespace(goal="Check PC", execution_target="computer"))
self.assertEqual(
self.transport.calls[-1][2]["runtime_overrides"], {"execution_target": "computer"}
)
async def test_invalid_execution_target_is_rejected_without_submission(self) -> None:
with self.assertRaisesRegex(ValueError, "execution_target"):
await self.client.submit("Check desktop", options={"execution_target": "phone"})
self.assertEqual(self.transport.calls, [])
async def test_submit_forwards_pro_tuning_knobs_normalised(self) -> None:
task_id = "00000000-0000-4000-8000-000000000321"