merge(runtime): integrate trace store relocation

This commit is contained in:
somew1nd committed 2026-09-01 15:59:35 -07:00
commit 8003cf39c2
21 files changed
+134 -64

No files matched your search

@@ -15,6 +15,8 @@
import time
from typing import Any
from artemis.runtime import trace_store
try:
from admin_console.database.connection import db_session
except ImportError:
@@ -309,8 +311,6 @@ class SessionRepository:
)
count += cursor.rowcount
try:
from mcp_server.utils import trace_store
if trace_store.read_status(str(row["session_id"])):
trace_store.update_trace_status(
str(row["session_id"]),
@@ -410,3 +410,11 @@ class SessionRepository:
session_repo = SessionRepository()
# Give the base runtime package access to orphan-session reconciliation without
# letting it import this application-layer module (inverted dependency): any
# process that loads the admin console DB layer -- the server itself, or an
# entry point stopping one -- thereby arms stop_server()'s post-kill cleanup.
from artemis.runtime.server_lifecycle import register_session_reconciler # noqa: E402
register_session_reconciler(session_repo.reconcile_orphaned_sessions)
+1 -2
View File
@@ -19,6 +19,7 @@ from uuid import UUID
from fastapi import APIRouter, HTTPException
from artemis.config import DB_PATH, TRACES_PATH
from artemis.runtime import trace_store
try:
from admin_console.core.state import state
@@ -149,8 +150,6 @@ def _list_sessions_sync():
harvested = session_repo.harvest_orphaned_sessions(orphaned_ids)
print(f"[list_sessions] Auto-harvested {harvested} orphaned running session(s).")
try:
from mcp_server.utils import trace_store
for o_id in orphaned_ids:
if trace_store.read_status(str(o_id)):
trace_store.update_trace_status(
@@ -45,6 +45,7 @@ from artemis.runtime import (
DeviceExecutionLock,
current_adb_endpoint,
process_supervisor,
trace_store,
)
@@ -510,8 +511,6 @@ class TaskQueueService:
}
if sess_id:
try:
from mcp_server.utils import trace_store
st = trace_store.read_status(str(sess_id))
if st:
st["pid"] = proc.pid
@@ -536,8 +535,6 @@ class TaskQueueService:
log_path = None
if sess_id:
try:
from mcp_server.utils import trace_store
log_path = trace_store.get_trace_stdout_log_path(str(sess_id))
except Exception:
log_path = None
@@ -580,8 +577,6 @@ class TaskQueueService:
f"status '{new_status}'"
)
try:
from mcp_server.utils import trace_store
if trace_store.read_status(str(sess_id)):
canonical_mcp_status = "completed" if new_status in ("completed", "success") else new_status
trace_store.update_trace_status(
@@ -870,8 +865,6 @@ class TaskQueueService:
if sid:
session_repo.update_session_status(str(sid), "cancelled", time.time())
try:
from mcp_server.utils import trace_store
is_mcp = bool(dev_owner and dev_owner.ingress == "mcp")
if is_mcp or trace_store.read_status(str(sid)):
trace_store.update_trace_status(
@@ -1133,8 +1126,6 @@ class TaskQueueService:
str(stopped_session_id), "cancelled", time.time()
)
try:
from mcp_server.utils import trace_store
is_mcp = bool(owner and owner.ingress == "mcp")
if is_mcp or trace_store.read_status(str(stopped_session_id)):
trace_store.update_trace_status(
@@ -26,7 +26,11 @@ from rich.panel import Panel
from rich.table import Table
import typer
from artemis.interfaces.cli.commands.ui import ensure_showcase_built, ui_command
from artemis.interfaces.cli.commands.ui import (
ensure_showcase_built,
load_session_reconciler,
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,
@@ -128,6 +132,7 @@ def restart_command(
else:
console.print(f" [dim]ℹ No active Artemis server found on port {port}.[/dim]")
load_session_reconciler()
success, msg, stopped_pids = stop_server(port=port, force=force, timeout=12.0)
if stopped_pids:
console.print(f" [green]✓ {msg}[/green]\n")
@@ -213,6 +218,7 @@ def stop_command(
else:
console.print(f" [dim]Scanning port {port}...[/dim]")
load_session_reconciler()
success, msg, stopped_pids = stop_server(port=port, force=force, timeout=12.0)
if stopped_pids:
console.print(
+19
View File
@@ -103,6 +103,20 @@ def ensure_showcase_built(console: Console) -> None:
console.print(f" [red]⚠ Failed to auto-build Showcase UI: {e}[/red]\n")
def load_session_reconciler() -> None:
"""Entry-layer assembly: arm stop_server()'s orphan-session cleanup.
Importing the admin console session repository registers its reconciler
with ``artemis.runtime.server_lifecycle`` (inverted dependency: the base
runtime package never imports the application layer). Guarded so a broken
or absent admin console install can never block stopping a server.
"""
try:
import apps.admin_console.database.repositories.session_repository # noqa: F401
except Exception:
pass
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()
@@ -169,6 +183,7 @@ def ui_command(
if restart:
console.print(f" [yellow]🔄 Restart requested. Recycling port {port}...[/yellow]")
load_session_reconciler()
stop_server(port=port, timeout=12.0)
elif is_port_in_use(port):
pids = find_server_pids(port)
@@ -185,6 +200,7 @@ def ui_command(
choice = "1"
if choice == "2":
console.print(f"\n [yellow]🔄 Terminating previous Artemis instance on port {port}...[/yellow]")
load_session_reconciler()
stop_server(port=port, timeout=12.0)
elif choice == "3":
return
@@ -244,6 +260,9 @@ def ui_command(
t.start()
try:
# Intentional entry-layer lazy load: the CLI assembles the application
# here, and importing the FastAPI server at module import time would
# drag the whole admin console into every `artemis <cmd>` invocation.
from apps.admin_console.server import run_ui_server
run_ui_server(host=host, port=port, reload=reload)
+21 -8
View File
@@ -29,12 +29,13 @@ import socket
import subprocess
import sys
import time
from typing import Any
from typing import Any, Callable
import urllib.error
import urllib.request
from artemis.config.paths import ROOT_DIR, get_server_info_file
from artemis.runtime.device_lock import DeviceExecutionLock
from artemis.runtime.process_probe import pid_is_alive
from artemis.runtime.supervisor import ProcessSupervisor
from artemis.utils.logger import get_logger
@@ -155,12 +156,28 @@ def request_graceful_shutdown(port: int, timeout: float = 1.5) -> bool:
return False
# Inverted dependency: the session database lives in the application layer
# (apps.admin_console), which this base runtime package must not import.
# The application layer registers its orphan-session reconciler here
# (apps/admin_console/database/repositories/session_repository.py does so on
# import); entry points that stop servers without loading the admin console
# perform that import as part of their assembly. Without a registration,
# reconciliation is skipped -- the admin console re-runs it on next startup.
_session_reconciler: Callable[[], int] | None = None
def register_session_reconciler(reconciler: Callable[[], int]) -> None:
"""Register the app-layer callback that marks orphaned sessions as failed."""
global _session_reconciler
_session_reconciler = reconciler
def _reconcile_orphaned_sessions() -> int:
"""Immediately mark sessions whose worker PIDs died during forced stop."""
if _session_reconciler is None:
return 0
try:
from apps.admin_console.database.repositories.session_repository import session_repo
return session_repo.reconcile_orphaned_sessions()
return _session_reconciler()
except Exception as exc:
logger.debug(f"Could not reconcile orphaned sessions after server stop: {exc}")
return 0
@@ -168,8 +185,6 @@ def _reconcile_orphaned_sessions() -> int:
def _any_pid_alive(pids: list[int]) -> bool:
"""Return whether any target process is still running (zombies excluded)."""
from artemis.runtime.process_probe import pid_is_alive
return any(pid_is_alive(pid) for pid in pids)
@@ -188,8 +203,6 @@ def find_server_pids(port: int = 8000) -> list[int]:
if info and info.get("port") == port:
saved_pid = info.get("pid")
if saved_pid and isinstance(saved_pid, int):
from artemis.runtime.process_probe import pid_is_alive
if pid_is_alive(saved_pid):
discovered.add(saved_pid)
@@ -35,6 +35,8 @@ import time
from typing import Any
import uuid
from artemis.runtime.process_probe import pid_is_alive
logger = logging.getLogger(__name__)
try:
@@ -137,8 +139,6 @@ def _lock_is_stale(lock_path: str) -> bool:
return time.time() - os.path.getmtime(lock_path) >= _MALFORMED_LOCK_GRACE_SECONDS
except OSError:
return False
from artemis.runtime.process_probe import pid_is_alive
return not pid_is_alive(pid, created_at if created_at > 0 else None)
+6 -3
View File
@@ -25,12 +25,15 @@ mcp_server/
│ ├── task_manager.py # mobile_manage_task
│ ├── device_state.py # mobile_get_device_state
│ └── inspect_trace.py # mobile_inspect_trace
└── utils/ # Environment, device, and trace utilities
└── utils/ # Environment and device utilities
├── device_utils.py # Cross-platform ADB and emulator resolver
├── env_utils.py # Python interpreter and process manager
└── trace_store.py # Traces directory and status manager
└── env_utils.py # Python interpreter and process manager
```
The trace status store (traces directory layout and `status.json` lifecycle)
lives in the neutral base package as `artemis/runtime/trace_store.py`, shared
by the MCP server, the admin console, and spawned worker processes.
## 🔔 Multi-Platform Notification & Wakeup Support
When you start an asynchronous mobile test via `mobile_run_task`, Artemis MCP runs the job as a detached background process so the AI agent is not blocked. Upon completion or failure, Artemis dispatches alerts across **all available notification channels** so that both human developers and AI agents are notified immediately across different platforms:
+12 -1
View File
@@ -41,8 +41,9 @@ try:
except Exception:
load_dotenv(os.path.join(PROJECT_ROOT, ".env"))
from artemis.runtime import trace_store
from mcp_server.notifiers import notify
from mcp_server.utils import device_utils, trace_store
from mcp_server.utils import device_utils
async def _initialize_agent(
@@ -80,6 +81,9 @@ def resolve_profile_file() -> str | None:
]
)
try:
# Deliberate guarded import mirroring the module-level bootstrap above:
# profile resolution must degrade to repo-relative candidates when the
# artemis config package cannot be loaded.
from artemis.config.paths import get_app_dir
app_dir = str(get_app_dir())
@@ -146,6 +150,11 @@ async def run_task(
target_serial = device_serial
try:
# Deliberate lazy imports: the SDK pulls in the full agent stack
# (LLM clients, graph, drivers), and importing it inside this try
# converts an import-time failure into a properly recorded task
# failure (trace status + wakeup notification) instead of a crash
# before status.json is ever updated.
from artemis.config import settings
from artemis.sdk import Agent
from artemis.sdk.builders import Builders
@@ -167,6 +176,8 @@ async def run_task(
target_serial = settings.ADB_DEVICE_SERIAL or os.environ.get("ADB_DEVICE_SERIAL")
if not target_serial:
try:
# Optional path: pool-based selection falls back to the
# first connected device on any import or query failure.
from artemis.runtime import device_pool
target_serial = device_pool.select_device()
except Exception:
+1 -1
View File
@@ -20,8 +20,8 @@ import os
import time
from typing import Any
from artemis.runtime import trace_store
from mcp_server.notifiers.base import BaseNotifier
from mcp_server.utils import trace_store
logger = logging.getLogger("mcp_server.notifiers.file")
+2 -1
View File
@@ -23,7 +23,8 @@ from typing import Any
from PIL import Image, ImageDraw
from mcp_server.base import mcp
from mcp_server.utils import env_utils, trace_store
from mcp_server.utils import env_utils
from artemis.runtime import trace_store
from artemis.utils.visualization import draw_action_overlay_on_image
from artemis.utils.task_tree import (
_render_step_detailed,
+9 -6
View File
@@ -24,8 +24,15 @@ from typing import Any
from mcp_server.base import mcp
from mcp_server.notifiers import notify
from mcp_server.utils import env_utils, trace_store
from artemis.runtime import DeviceExecutionLock, process_supervisor
from mcp_server.utils import env_utils
from artemis.runtime import (
DeviceExecutionLock,
is_daemon_running,
process_supervisor,
stop_task_on_daemon,
trace_store,
)
from artemis.runtime.process_probe import pid_is_alive
_LIVENESS_FAILURE_ERROR = "Task runner process terminated unexpectedly."
# Grace window covering the spawn race: the launcher pid may already have exited
@@ -48,8 +55,6 @@ def _pid_alive(pid: int | None) -> bool:
pid_int = int(pid)
except (TypeError, ValueError):
return False
from artemis.runtime.process_probe import pid_is_alive
return pid_is_alive(pid_int)
@@ -451,8 +456,6 @@ def mobile_manage_task(
stopped_via_daemon = False
if os.environ.get("ARTEMIS_STANDALONE") != "1":
try:
from artemis.runtime import is_daemon_running, stop_task_on_daemon
if is_daemon_running():
stopped_via_daemon = stop_task_on_daemon(trace_id)
except Exception:
+10 -10
View File
@@ -14,6 +14,7 @@
"""MCP Tool: mobile_run_task."""
import logging
import os
import subprocess
import sys
@@ -24,8 +25,15 @@ import uuid
from mcp_server.base import mcp
from mcp_server.notifiers import notify
from mcp_server.utils import env_utils, trace_store
from artemis.runtime import DeviceExecutionLock
from mcp_server.utils import env_utils
from artemis.config.runtime import read_ipc_port
from artemis.runtime import (
DeviceExecutionLock,
device_pool,
ensure_daemon_running,
submit_task_to_daemon,
trace_store,
)
# Seconds the spawned runner gets to finish its imports and open its log files
# before the spawn is declared dead. Normal startup creates stdout.log within a
@@ -136,8 +144,6 @@ def _validate_device_serial(device_serial: str) -> dict[str, Any] | None:
validation is skipped rather than blocking task submission.
"""
try:
from artemis.runtime import device_pool
# The shared validator fails open on an indeterminate/empty enumeration:
# the task proceeds and fails downstream with a clear no-device error.
detail = device_pool.validate_explicit_serial(device_serial)
@@ -249,8 +255,6 @@ def mobile_run_task(
# 3. Dispatch via unified Artemis Daemon scheduler if available (unless standalone forced)
if os.environ.get("ARTEMIS_STANDALONE") != "1":
try:
from artemis.runtime import ensure_daemon_running, submit_task_to_daemon
is_running, base_url = ensure_daemon_running(timeout=2.0, wait_ready=True)
if is_running:
resp = submit_task_to_daemon(
@@ -321,7 +325,6 @@ def mobile_run_task(
pass
# When Daemon is running, refuse to launch a conflicting standalone runner on the same device
import logging
logging.getLogger("mcp_server").warning(
f"Daemon dispatch failed for task '{task_desc}'. Aborting dispatch to prevent runner collision."
)
@@ -332,7 +335,6 @@ def mobile_run_task(
"message": "Task rejected or timed out in Artemis Daemon. Standalone fallback blocked to prevent runner collision.",
}
except Exception as exc:
import logging
logging.getLogger("mcp_server").warning(
f"Daemon dispatch failed: {exc}"
)
@@ -387,8 +389,6 @@ def mobile_run_task(
env["ARTEMIS_DEVICE_ID"] = device_serial
env[DeviceExecutionLock.QUEUE_TICKET_ENV] = queue_ticket
try:
from artemis.config.runtime import read_ipc_port
ipc_port = read_ipc_port()
if ipc_port:
env["ARTEMIS_IPC_PORT"] = str(ipc_port)
+3
View File
@@ -22,6 +22,9 @@ from typing import Any
def get_project_root() -> str:
"""Returns the absolute path to the project root repository."""
# Deliberate lazy import: this helper must keep working when the artemis
# package (or its config bootstrap) is unavailable or broken, falling back
# to filesystem-relative resolution below.
try:
from artemis.config.paths import ROOT_DIR
+13
View File
@@ -104,6 +104,15 @@ def main() -> None:
from artemis.runtime.server_lifecycle import get_server_status, stop_server
def _arm_session_reconciler() -> None:
# Entry-layer assembly: importing the admin console session repository
# registers its orphan-session reconciler with the runtime, so a forced
# stop below can mark sessions whose worker processes were killed.
try:
import apps.admin_console.database.repositories.session_repository # noqa: F401
except Exception:
pass
if args.status:
st = get_server_status(args.port)
if st["running"]:
@@ -116,6 +125,7 @@ def main() -> None:
if args.stop:
print(f"\033[1;33m🛑 Stopping Artemis server on port {args.port}...\033[0m")
_arm_session_reconciler()
ok, msg, pids = stop_server(args.port)
print(f"\033[1;32m✓ {msg}\033[0m")
return
@@ -130,6 +140,7 @@ def main() -> None:
# Check if restart requested
if args.restart:
print(f"\033[1;33m🔄 Restart requested. Recycling port {args.port}...\033[0m")
_arm_session_reconciler()
ok, msg, pids = stop_server(args.port)
if pids:
print(f"\033[1;32m✓ {msg}\033[0m\n")
@@ -155,9 +166,11 @@ def main() -> None:
if choice == "2":
print(f"\n\033[1;33m🔄 Restarting Artemis server on port {args.port}...\033[0m")
_arm_session_reconciler()
stop_server(args.port)
elif choice == "3":
print(f"\n\033[1;33m🛑 Stopping Artemis server on port {args.port}...\033[0m")
_arm_session_reconciler()
ok, msg, _ = stop_server(args.port)
print(f"\033[1;32m✓ {msg}\033[0m")
return
+1 -1
View File
@@ -55,7 +55,7 @@ from artemis.interfaces.sdk.client import ArtemisClient, ConcurrencyMode
from mcp_server.tools.device_state import mobile_get_device_state
from mcp_server.tools.task_runner import mobile_run_task
from mcp_server.tools.task_manager import mobile_manage_task
from mcp_server.utils import trace_store
from artemis.runtime import trace_store
@pytest.fixture(scope="module", autouse=True)
@@ -431,7 +431,7 @@ def test_stop_tasks_terminates_external_global_owner_and_preserves_local_waiter(
"apps.admin_console.services.task_queue_service.session_repo.update_session_status"
) as update_status,
patch(
"mcp_server.utils.trace_store.update_trace_status"
"artemis.runtime.trace_store.update_trace_status"
) as update_trace_status,
):
assert task_queue_service.stop_tasks(clear_all=False) is True
@@ -845,7 +845,7 @@ def test_stop_tasks_by_session_id_targets_correct_task_among_multiple():
patch(
"apps.admin_console.services.task_queue_service.session_repo.update_session_status"
) as update_status,
patch("mcp_server.utils.trace_store.update_trace_status") as update_trace,
patch("artemis.runtime.trace_store.update_trace_status") as update_trace,
):
# Explicitly stop session-b
assert task_queue_service.stop_tasks(clear_all=False, session_id="session-b") is True
@@ -52,7 +52,7 @@ def test_legacy_jetski_tools_imports():
def test_legacy_jetski_utils_imports():
from jetski.utils.trace_store import init_trace
from mcp_server.utils.trace_store import init_trace as a_init
from artemis.runtime.trace_store import init_trace as a_init
assert init_trace is a_init
+5 -5
View File
@@ -31,7 +31,7 @@ from mcp_server.tools import (
mobile_manage_task,
mobile_run_task,
)
from mcp_server.utils import trace_store
from artemis.runtime import trace_store
@pytest.fixture
@@ -162,11 +162,11 @@ def test_mobile_run_task_dispatched_to_daemon(temp_trace_env, monkeypatch):
monkeypatch.delenv("ARTEMIS_STANDALONE", raising=False)
with (
patch(
"artemis.runtime.ensure_daemon_running",
"mcp_server.tools.task_runner.ensure_daemon_running",
return_value=(True, "http://127.0.0.1:8000"),
),
patch(
"artemis.runtime.submit_task_to_daemon",
"mcp_server.tools.task_runner.submit_task_to_daemon",
return_value={"status": "started", "tasks": [{"session_id": "daemon-sid-1"}]},
),
patch("mcp_server.tools.task_runner.subprocess.Popen") as popen,
@@ -399,8 +399,8 @@ def test_mobile_manage_task_stop_via_daemon(temp_trace_env, monkeypatch):
trace_store.init_trace(trace_id, "Daemon task to stop", "Flash", "conv-stop")
with (
patch("artemis.runtime.is_daemon_running", return_value=True),
patch("artemis.runtime.stop_task_on_daemon", return_value=True) as mock_stop,
patch("mcp_server.tools.task_manager.is_daemon_running", return_value=True),
patch("mcp_server.tools.task_manager.stop_task_on_daemon", return_value=True) as mock_stop,
):
res = mobile_manage_task(action="stop", trace_id=trace_id)
mock_stop.assert_called_once_with(trace_id)
+1 -1
View File
@@ -28,7 +28,7 @@ from mcp_server.notifiers import (
WebhookNotifier,
notify,
)
from mcp_server.utils import trace_store
from artemis.runtime import trace_store
class DummyNotifier(BaseNotifier):
@@ -1,4 +1,4 @@
# Copyright 2026 Google LLC
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -12,7 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
"""Unit tests for MCP trace store."""
"""Unit tests for the shared trace store (artemis.runtime.trace_store)."""
import glob
import logging
@@ -23,7 +23,7 @@ import threading
import uuid
import pytest
from mcp_server.utils import trace_store
from artemis.runtime import trace_store
@pytest.fixture
@@ -139,7 +139,7 @@ def test_corrupt_status_is_quarantined_and_read_returns_none(temp_trace_env, cap
with open(status_path, "w", encoding="utf-8") as f:
f.write('{"status": "running", "trace_id"') # torn write
with caplog.at_level(logging.WARNING, logger="mcp_server.utils.trace_store"):
with caplog.at_level(logging.WARNING, logger="artemis.runtime.trace_store"):
assert trace_store.read_status(trace_id) is None
assert any("Corrupt status.json" in rec.message for rec in caplog.records)
@@ -148,7 +148,7 @@ def test_corrupt_status_is_quarantined_and_read_returns_none(temp_trace_env, cap
def test_missing_status_stays_silent(temp_trace_env, caplog):
with caplog.at_level(logging.WARNING, logger="mcp_server.utils.trace_store"):
with caplog.at_level(logging.WARNING, logger="artemis.runtime.trace_store"):
assert trace_store.read_status(str(uuid.uuid4())) is None
assert caplog.records == []
@@ -161,13 +161,13 @@ def test_update_on_corrupt_status_logs_and_drops(temp_trace_env, caplog):
with open(status_path, "w", encoding="utf-8") as f:
f.write("not json at all")
with caplog.at_level(logging.WARNING, logger="mcp_server.utils.trace_store"):
with caplog.at_level(logging.WARNING, logger="artemis.runtime.trace_store"):
assert trace_store.update_trace_status(trace_id, "completed") is None
assert any("Dropping status update" in rec.message for rec in caplog.records)
# Updating a trace that never existed stays a silent no-op.
caplog.clear()
with caplog.at_level(logging.WARNING, logger="mcp_server.utils.trace_store"):
with caplog.at_level(logging.WARNING, logger="artemis.runtime.trace_store"):
assert trace_store.update_trace_status(str(uuid.uuid4()), "completed") is None
assert not any("Dropping status update" in rec.message for rec in caplog.records)