feat: enable remote SDK and resilient device orchestration

This commit is contained in:
somew1nd committed 2026-08-30 00:41:07 -07:00
1 parent 3cc711df51
commit ddfa420945
110 files changed
+14450 -12954

No files matched your search

+1
View File
@@ -16,6 +16,7 @@
import asyncio
import json
import os
from pathlib import Path
from typing import Annotated
+3 -3
View File
@@ -367,9 +367,9 @@ def run_command(
def on_status(sess_info):
st = sess_info.get("status")
if st == "queued":
console.print(f"[yellow]⏳ Task queued in scheduler, waiting for device...[/yellow]")
console.print("[yellow]⏳ Task queued in scheduler, waiting for device...[/yellow]")
elif st == "running":
console.print(f"[green]▶ Task executing on mobile device...[/green]")
console.print("[green]▶ Task executing on mobile device...[/green]")
try:
final_res = wait_for_daemon_task(
@@ -380,7 +380,7 @@ def run_command(
)
final_st = final_res.get("status")
if final_st in ("completed", "success"):
console.print(f"\n[bold green]✅ Task completed successfully![/bold green]")
console.print("\n[bold green]✅ Task completed successfully![/bold green]")
return
else:
err = final_res.get("error") or final_res.get("explanation") or ""
@@ -17,6 +17,7 @@
from __future__ import annotations
import datetime
import time
from typing import Annotated
import webbrowser
@@ -25,8 +26,8 @@ from rich.panel import Panel
from rich.table import Table
import typer
from artemis.interfaces.cli.commands.ui import ui_command
from artemis.runtime.daemon_client import spawn_daemon
from artemis.interfaces.cli.commands.ui import ensure_showcase_built, ui_command
from artemis.runtime.daemon_client import daemon_log_path, is_daemon_running, spawn_daemon
from artemis.runtime.server_lifecycle import (
find_server_pids,
get_server_status,
@@ -55,8 +56,15 @@ def _format_uptime(seconds: float | None) -> str:
def restart_command(
host: Annotated[
str,
typer.Option("--host", "-H", help="Bind host address for the UI server."),
] = "0.0.0.0",
typer.Option(
"--host",
"-H",
help=(
"Bind host address for the UI server. Defaults to loopback; for remote "
"access prefer a Tailscale/SSH tunnel over a wide bind."
),
),
] = "127.0.0.1",
port: Annotated[
int,
typer.Option("--port", "-p", help="Port to run the unified UI server on."),
@@ -86,14 +94,23 @@ def restart_command(
daemon: Annotated[
bool,
typer.Option(
"--daemon",
"-d",
help="Run the new server as a detached background process instead of foreground.",
"--daemon/--foreground",
"-d/-F",
help=(
"Run the new server as a detached background daemon (default) or attached "
"to the current terminal. The daemon is launched via 'python -m' so the "
"console-script executable is never pinned by a long-lived server "
"(which would block 'uv sync' reinstalls on Windows)."
),
),
] = False,
] = True,
) -> None:
"""Restart the Artemis Web UI server, terminating any existing instance on the port."""
console.print()
if reload and daemon:
# uvicorn auto-reload requires an attached terminal session.
console.print(" [dim]ℹ --reload requested: switching to foreground mode.[/dim]")
daemon = False
console.print(
Panel(
f"[bold cyan]🔄 Artemis Server Restart[/bold cyan]\n"
@@ -111,7 +128,7 @@ def restart_command(
else:
console.print(f" [dim]ℹ No active Artemis server found on port {port}.[/dim]")
success, msg, stopped_pids = stop_server(port=port, force=force, timeout=4.0)
success, msg, stopped_pids = stop_server(port=port, force=force, timeout=12.0)
if stopped_pids:
console.print(f" [green]✓ {msg}[/green]\n")
elif not success:
@@ -122,23 +139,54 @@ def restart_command(
# 2. Launch fresh server instance
local_url = f"http://localhost:{port}"
if daemon:
console.print(f" [cyan]🚀 Starting Artemis in detached background daemon mode...[/cyan]")
ensure_showcase_built(console)
console.print(" [cyan]🚀 Starting Artemis in detached background daemon mode...[/cyan]")
proc = spawn_daemon(host=host, port=port)
if proc:
if not proc:
console.print(" [red]❌ Failed to spawn background daemon.[/red]\n")
raise typer.Exit(code=1)
# Wait until the daemon answers HTTP before declaring success (cold
# start includes DB/trace initialization and can take tens of seconds).
ready = False
deadline = time.monotonic() + 60.0
while time.monotonic() < deadline:
if is_daemon_running(host="127.0.0.1", port=port, timeout=0.5):
ready = True
break
if proc.poll() is not None:
# Parent exited: either it crashed, or it handed the socket off
# to a child worker. Give the worker a short grace period.
grace = time.monotonic() + 5.0
while time.monotonic() < grace:
if is_daemon_running(host="127.0.0.1", port=port, timeout=0.5):
ready = True
break
time.sleep(0.3)
break
time.sleep(0.3)
log_path = daemon_log_path(port)
if ready:
console.print(
f" [bold green]✓ Artemis server running in background (PID: {proc.pid})[/bold green]\n"
f" 🌐 [cyan]{local_url}[/cyan]\n"
f" 🛠️ [cyan]{local_url}/admin[/cyan]\n"
f" 📄 [dim]Logs: {log_path}[/dim]\n"
f" [dim]💡 Stop with [bold]artemis stop[/bold]; run in-terminal with [bold]artemis restart --foreground[/bold].[/dim]\n"
)
if open_browser:
webbrowser.open(local_url)
else:
console.print(" [red]❌ Failed to spawn background daemon.[/red]\n")
console.print(
f" [red]❌ Background daemon did not become ready on port {port}.[/red]\n"
f" 📄 [dim]Check logs: {log_path}[/dim]\n"
)
raise typer.Exit(code=1)
return
# Default foreground mode
console.print(f" [cyan]🚀 Launching unified Artemis Showcase UI in current terminal...[/cyan]\n")
# Foreground mode (--foreground / --reload)
console.print(" [cyan]🚀 Launching unified Artemis Showcase UI in current terminal...[/cyan]\n")
ui_command(host=host, port=port, open_browser=open_browser, reload=reload)
@@ -165,7 +213,7 @@ def stop_command(
else:
console.print(f" [dim]Scanning port {port}...[/dim]")
success, msg, stopped_pids = stop_server(port=port, force=force, timeout=4.0)
success, msg, stopped_pids = stop_server(port=port, force=force, timeout=12.0)
if stopped_pids:
console.print(
Panel(
+59 -19
View File
@@ -29,6 +29,33 @@ from rich.panel import Panel
import typer
def running_via_console_script_shim() -> bool:
"""Return True when this CLI was launched through a console-script ``.exe`` shim.
On Windows, ``uv``/pip install entry points as trampoline executables
(``Scripts/artemis.exe``) that stay resident for the lifetime of the CLI
process and hold a lock on their own file. A long-running foreground server
started through the shim therefore blocks ``uv sync`` from reinstalling the
package ("os error 32") until the server exits. Launching via
``python -m artemis`` avoids the shim entirely.
"""
if sys.platform != "win32":
return False
argv0 = Path(sys.argv[0] or "")
return argv0.suffix.lower() == ".exe" and argv0.name.lower() != "python.exe"
def warn_if_console_script_shim(console: Console) -> None:
"""Print a hint when a foreground server run will pin the console-script exe."""
if running_via_console_script_shim():
console.print(
" [dim]⚠ Running in foreground via the [bold]artemis.exe[/bold] launcher: package "
"syncs (uv sync / uv run) cannot replace it while this server is running.\n"
" Prefer [bold]artemis restart[/bold] (background daemon) or launch with "
"[bold]uv run python -m artemis ui[/bold] to avoid this.[/dim]\n"
)
def _resolve_npm_executable(platform_name: str | None = None) -> str | None:
"""Return a directly executable npm path for the current platform."""
platform_name = platform_name or sys.platform
@@ -57,6 +84,25 @@ def _showcase_build_required(showcase_dir: Path) -> bool:
return any(path.exists() and path.stat().st_mtime > build_time for path in input_paths)
def ensure_showcase_built(console: Console) -> None:
"""Rebuild the Angular Showcase UI when its sources are newer than the dist build."""
from artemis.config.paths import ROOT_DIR
showcase_dir = ROOT_DIR / "apps" / "showcase_ui"
if not _showcase_build_required(showcase_dir):
return
npm_executable = _resolve_npm_executable()
if not npm_executable:
return
console.print(" [yellow]🎨 Showcase UI sources changed. Compiling Angular Showcase UI...[/yellow]")
try:
subprocess.run([npm_executable, "install", "--silent"], cwd=showcase_dir, check=True)
subprocess.run([npm_executable, "run", "build"], cwd=showcase_dir, check=True)
console.print(" [green]✓ Showcase UI built successfully.[/green]\n")
except Exception as e:
console.print(f" [red]⚠ Failed to auto-build Showcase UI: {e}[/red]\n")
def _poll_and_open_browser(url: str, stop_event: threading.Event, timeout: float = 15.0) -> None:
"""Poll the UI server until it responds, then launch the user's default browser."""
start = time.time()
@@ -75,8 +121,15 @@ def _poll_and_open_browser(url: str, stop_event: threading.Event, timeout: float
def ui_command(
host: Annotated[
str,
typer.Option("--host", "-h", help="Bind host address for the UI server."),
] = "0.0.0.0",
typer.Option(
"--host",
"-h",
help=(
"Bind host address for the UI server. Defaults to loopback; for remote "
"access prefer a Tailscale/SSH tunnel over a wide bind."
),
),
] = "127.0.0.1",
port: Annotated[
int,
typer.Option("--port", "-p", help="Port to run the unified UI server on."),
@@ -116,7 +169,7 @@ def ui_command(
if restart:
console.print(f" [yellow]🔄 Restart requested. Recycling port {port}...[/yellow]")
stop_server(port=port, timeout=4.0)
stop_server(port=port, timeout=12.0)
elif is_port_in_use(port):
pids = find_server_pids(port)
pid_str = f" (PID: {', '.join(map(str, pids))})" if pids else ""
@@ -132,7 +185,7 @@ def ui_command(
choice = "1"
if choice == "2":
console.print(f"\n [yellow]🔄 Terminating previous Artemis instance on port {port}...[/yellow]")
stop_server(port=port, timeout=4.0)
stop_server(port=port, timeout=12.0)
elif choice == "3":
return
else:
@@ -178,21 +231,8 @@ def ui_command(
except Exception:
pass
showcase_dir = ROOT_DIR / "apps" / "showcase_ui"
if _showcase_build_required(showcase_dir):
npm_executable = _resolve_npm_executable()
if npm_executable:
console.print(
" [yellow]🎨 Showcase UI sources changed. Compiling Angular Showcase UI...[/yellow]"
)
try:
subprocess.run(
[npm_executable, "install", "--silent"], cwd=showcase_dir, check=True
)
subprocess.run([npm_executable, "run", "build"], cwd=showcase_dir, check=True)
console.print(" [green]✓ Showcase UI built successfully.[/green]\n")
except Exception as e:
console.print(f" [red]⚠ Failed to auto-build Showcase UI: {e}[/red]\n")
ensure_showcase_built(console)
warn_if_console_script_shim(console)
stop_event = threading.Event()
if open_browser:
+5 -1
View File
@@ -15,13 +15,17 @@
"""Python SDK for ARTEMIS."""
from artemis.interfaces.sdk.client import ArtemisClient, ConcurrencyMode
from artemis.interfaces.sdk.task import StreamEvent, StreamEventType, Task, TaskResult
from artemis.interfaces.sdk.task import StreamEvent, StreamEventType, Task
from artemis_client import Capabilities, Device, TaskHandle, TaskResult
__all__ = [
"ArtemisClient",
"Capabilities",
"ConcurrencyMode",
"Device",
"StreamEvent",
"StreamEventType",
"Task",
"TaskHandle",
"TaskResult",
]
+8 -433
View File
@@ -5,440 +5,15 @@
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import asyncio
import os
import uuid
from typing import Any, Literal
from collections.abc import AsyncGenerator, Callable
from artemis.engine.pipeline import Pipeline
from artemis.interfaces.sdk.task import StreamEvent, StreamEventType, Task, TaskResult
from artemis.runtime import (
ConcurrencyMode,
DeviceBusyError,
DeviceExecutionLock,
DeviceStatus,
device_pool,
ensure_daemon_running,
submit_task_to_daemon,
wait_for_daemon_task,
)
from artemis.utils.logger import get_logger
"""Compatibility import for the standalone remote Artemis client.
logger = get_logger(__name__)
The implementation lives in the zero-dependency ``artemis-client`` package.
Keeping this module lets existing full-runtime installations migrate without
maintaining a second client execution path.
"""
from artemis.runtime import ConcurrencyMode
from artemis_client import ArtemisClient
class ArtemisClient:
"""The official high-level developer entrypoint for interacting with ARTEMIS."""
def __init__(
self,
device_id: str | None = None,
device_serial: str | None = None,
default_profile: Literal["flash", "pro"] = "pro",
concurrency_mode: Literal["global", "per_device"] | str = "per_device",
max_concurrency: int | None = None,
standalone: bool = False,
):
self.standalone = standalone or os.environ.get("ARTEMIS_STANDALONE", "").lower() in ("1", "true", "yes")
if not self.standalone:
try:
ensure_daemon_running(timeout=1.5)
except Exception:
pass
target_dev = device_serial or device_id
if target_dev is None or target_dev == "default-device":
self._device_id = device_pool.select_device() or "default-device"
else:
self._device_id = target_dev
self.default_profile = default_profile
self._concurrency_mode = str(concurrency_mode).strip().lower()
self._max_concurrency = max_concurrency
@property
def device_id(self) -> str:
return self._device_id
@device_id.setter
def device_id(self, val: str) -> None:
self._device_id = val
@property
def device_serial(self) -> str:
"""Alias for device_id."""
return self._device_id
@device_serial.setter
def device_serial(self, val: str) -> None:
self._device_id = val
def set_device(self, device_serial: str) -> "ArtemisClient":
"""Set target device serial and return client for chaining."""
self._device_id = device_serial
return self
@property
def concurrency_mode(self) -> str:
"""Concurrency mode: 'global' (1 across all devices) or 'per_device' (1 per device)."""
return self._concurrency_mode
@concurrency_mode.setter
def concurrency_mode(self, mode: Literal["global", "per_device"] | str) -> None:
self._concurrency_mode = str(mode).strip().lower()
@property
def max_concurrency(self) -> int | None:
return self._max_concurrency
@max_concurrency.setter
def max_concurrency(self, val: int | None) -> None:
self._max_concurrency = val
def set_concurrency_mode(self, mode: Literal["global", "per_device"] | str) -> "ArtemisClient":
"""Set concurrency mode ('global' or 'per_device') and return client for chaining."""
self.concurrency_mode = mode
return self
def list_devices(self) -> list[DeviceStatus]:
"""List all connected Android devices with their lock state and availability."""
return device_pool.list_devices()
async def list_devices_async(self) -> list[DeviceStatus]:
"""Asynchronously list all connected Android devices with their lock state."""
return await device_pool.list_devices_async()
def get_idle_devices(self) -> list[DeviceStatus]:
"""Return connected devices currently ready and idle (not holding a task lock)."""
return device_pool.get_idle_devices()
async def run(
self,
goal: str,
profile: Literal["flash", "pro"] | None = None,
device_id: str | None = None,
device_serial: str | None = None,
concurrency_mode: Literal["global", "per_device"] | str | None = None,
max_concurrency: int | None = None,
blocking: bool = True,
lock_timeout: float | None = None,
max_turns: int = 30,
**kwargs: Any,
) -> TaskResult:
"""Executes a single autonomous task on a target device and returns the final TaskResult."""
target_profile = profile or self.default_profile
target_device = device_serial or device_id or self.device_id
if target_device == "default-device":
target_device = device_pool.select_device() or "default-device"
mode = concurrency_mode or self.concurrency_mode
concurrency_limit = max_concurrency if max_concurrency is not None else self.max_concurrency
logger.info(
f"ArtemisClient running task '{goal}' on device '{target_device}' "
f"with profile '{target_profile}' (concurrency_mode='{mode}')..."
)
task_session_id = str(uuid.uuid4())
prev_ingress = os.environ.get("ARTEMIS_TASK_INGRESS")
prev_sess_id = os.environ.get("ARTEMIS_SESSION_ID")
lock = DeviceExecutionLock(
device_id=target_device,
description=f"{goal[:120]}",
concurrency_mode=mode,
max_concurrency=concurrency_limit,
session_id=task_session_id,
ingress="sdk",
)
try:
os.environ["ARTEMIS_TASK_INGRESS"] = "sdk"
os.environ["ARTEMIS_SESSION_ID"] = task_session_id
await asyncio.to_thread(
lock.acquire,
blocking=blocking,
timeout=lock_timeout,
)
# Check if targeting a live connected device
devices = device_pool.list_devices()
connected_serials = {d.serial for d in devices}
is_live_device = target_device in connected_serials and os.environ.get("ARTEMIS_MOCK_DRIVER") != "1"
if is_live_device:
# Route through unified Artemis Daemon scheduler unless standalone explicitly configured
if not self.standalone and os.environ.get("ARTEMIS_STANDALONE") != "1":
try:
is_running, base_url = ensure_daemon_running(timeout=8.0, wait_ready=True)
if is_running and base_url:
dev_serial = target_device if target_device != "default-device" else None
resp = submit_task_to_daemon(
goal=goal,
profile=target_profile,
device_serial=dev_serial,
expected_output=kwargs.get("expected_output"),
enable_outputter=kwargs.get("enable_outputter"),
locked_app_package=kwargs.get("locked_package") or kwargs.get("locked_app_package"),
app_path=kwargs.get("app_path"),
session_id=task_session_id,
ingress="sdk",
base_url=base_url,
)
if resp and resp.get("tasks"):
final_res = wait_for_daemon_task(
task_session_id,
base_url=base_url,
timeout=float(kwargs.get("timeout", 1800.0)),
)
final_st = final_res.get("status", "completed")
err = final_res.get("error")
return TaskResult(
trace_id=task_session_id,
status="completed" if final_st in ("completed", "success") else final_st,
turns=final_res.get("turns", 1),
error=err,
device_id=target_device,
)
except Exception as exc:
logger.debug(f"SDK daemon routing notice: {exc}")
from artemis.sdk.agent import Agent
from artemis.sdk.builders import Builders
from artemis.context import DevicePlatform
cfg_builder = (
Builders.AgentConfig
.for_device(DevicePlatform.ANDROID, target_device)
.with_concurrency_mode(mode)
)
if concurrency_limit is not None:
cfg_builder.with_max_concurrency(concurrency_limit)
agent = Agent(config=cfg_builder.build())
agent._session_id = task_session_id
await agent.init()
try:
task = agent.new_task(goal=goal)
if target_profile:
task.using_profile(target_profile)
if kwargs.get("locked_package"):
task.with_locked_app_package(kwargs["locked_package"])
output = await agent.run_task(request=task.build())
return TaskResult(
trace_id=task_session_id,
status="completed" if output is not None else "success",
turns=getattr(agent, "_turns_completed", 1),
output=output,
device_id=target_device,
)
finally:
await agent.clean()
state = await Pipeline.execute(
goal=goal,
profile=target_profile,
device_id=target_device,
**kwargs,
)
return TaskResult(
trace_id=state.trace_id,
status=state.status.value,
turns=state.current_turn,
error=state.error_message,
device_id=target_device,
)
finally:
if prev_sess_id is not None:
os.environ["ARTEMIS_SESSION_ID"] = prev_sess_id
else:
os.environ.pop("ARTEMIS_SESSION_ID", None)
if prev_ingress is not None:
os.environ["ARTEMIS_TASK_INGRESS"] = prev_ingress
else:
os.environ.pop("ARTEMIS_TASK_INGRESS", None)
if lock._acquired:
await asyncio.to_thread(lock.release)
async def stream_run(
self,
goal: str,
profile: Literal["flash", "pro"] | None = None,
device_id: str | None = None,
device_serial: str | None = None,
concurrency_mode: Literal["global", "per_device"] | str | None = None,
max_concurrency: int | None = None,
blocking: bool = True,
lock_timeout: float | None = None,
max_turns: int = 30,
callbacks: list[Callable[[StreamEvent], Any]] | None = None,
**kwargs: Any,
) -> AsyncGenerator[StreamEvent, None]:
"""Executes an autonomous task while yielding real-time StreamEvents asynchronously."""
target_profile = profile or self.default_profile
target_device = device_serial or device_id or self.device_id
if target_device == "default-device":
target_device = device_pool.select_device() or "default-device"
mode = concurrency_mode or self.concurrency_mode
concurrency_limit = max_concurrency if max_concurrency is not None else self.max_concurrency
logger.info(
f"ArtemisClient streaming task '{goal}' on device '{target_device}' "
f"with profile '{target_profile}' (concurrency_mode='{mode}')..."
)
task_session_id = str(uuid.uuid4())
prev_ingress = os.environ.get("ARTEMIS_TASK_INGRESS")
prev_sess_id = os.environ.get("ARTEMIS_SESSION_ID")
lock = DeviceExecutionLock(
device_id=target_device,
description=f"ArtemisClient streaming task: {goal[:120]}",
concurrency_mode=mode,
max_concurrency=concurrency_limit,
session_id=task_session_id,
ingress="sdk",
)
try:
os.environ["ARTEMIS_TASK_INGRESS"] = "sdk"
os.environ["ARTEMIS_SESSION_ID"] = task_session_id
await asyncio.to_thread(
lock.acquire,
blocking=blocking,
timeout=lock_timeout,
)
# 1. Emit initial starting event
start_event = StreamEvent(
event_type=StreamEventType.STATUS,
step_number=0,
payload={
"status": "starting",
"goal": goal,
"profile": target_profile,
"device_id": target_device,
"concurrency_mode": mode,
},
)
if callbacks:
for cb in callbacks:
try:
cb(start_event)
except Exception as e:
logger.debug(f"Callback error: {e}")
yield start_event
# 2. Execute task pipeline
try:
state = await Pipeline.execute(
goal=goal,
profile=target_profile,
device_id=target_device,
**kwargs,
)
# 3. Emit intermediate step events
for step in state.steps:
ev = StreamEvent(
event_type=StreamEventType.STEP_END,
step_number=step.step_number,
payload={
"thought": step.thought,
"action": step.action_name,
"params": step.action_params,
"result": step.result,
"duration": step.duration_seconds,
},
)
if callbacks:
for cb in callbacks:
try:
cb(ev)
except Exception as e:
logger.debug(f"Callback error: {e}")
yield ev
# 4. Emit completion event
final_ev = StreamEvent(
event_type=StreamEventType.STATUS,
step_number=state.current_turn,
payload={
"status": state.status.value,
"turns": state.current_turn,
"error": state.error_message,
"device_id": target_device,
},
)
if callbacks:
for cb in callbacks:
try:
cb(final_ev)
except Exception as e:
logger.debug(f"Callback error: {e}")
yield final_ev
except Exception as e:
err_ev = StreamEvent(
event_type=StreamEventType.ERROR,
step_number=0,
payload={"error": str(e), "device_id": target_device},
)
if callbacks:
for cb in callbacks:
try:
cb(err_ev)
except Exception:
pass
yield err_ev
finally:
if prev_sess_id is not None:
os.environ["ARTEMIS_SESSION_ID"] = prev_sess_id
else:
os.environ.pop("ARTEMIS_SESSION_ID", None)
if prev_ingress is not None:
os.environ["ARTEMIS_TASK_INGRESS"] = prev_ingress
else:
os.environ.pop("ARTEMIS_TASK_INGRESS", None)
if lock._acquired:
await asyncio.to_thread(lock.release)
async def run_task(
self,
task: Task,
profile: Literal["flash", "pro"] | None = None,
device_id: str | None = None,
device_serial: str | None = None,
concurrency_mode: Literal["global", "per_device"] | str | None = None,
**kwargs: Any,
) -> TaskResult:
"""Executes a Task instance using ArtemisClient."""
target_device = (
device_serial
or device_id
or task.device_serial
or task.device_id
or self.device_id
)
target_profile = profile or task.profile or self.default_profile
target_mode = concurrency_mode or task.concurrency_mode or self.concurrency_mode
return await self.run(
goal=task.goal,
profile=target_profile,
device_id=target_device,
concurrency_mode=target_mode,
max_turns=task.max_turns,
locked_package=task.locked_package,
**kwargs,
)
__all__ = ["ArtemisClient", "ConcurrencyMode"]