refactor: unify agent history and harden runtime diagnostics

Preserve user guidance through history compression, stabilize Flash index targets, and retire withdrawn checks without applying stale verdicts.

Improve runtime diagnostics, tool responses, and history rendering. Simplify documentation and comments.

Validation: 2076 tests passed in the full suite; 381 affected tests and 26 frontend tests passed after cleanup. Ruff and the quality ratchet passed.
This commit is contained in:
somew1nd committed 2026-09-09 21:35:40 -07:00
1 parent e983180b5b
commit 5d92939d91
127 files changed
+13738 -1988

No files matched your search

+18
View File
@@ -20,6 +20,7 @@ from artemis.core.diagnostics.adb_keys import (
heal_adb_keys,
inspect_adb_keys,
)
from artemis.core.diagnostics.device_smoke import smoke_test_device
from artemis.core.diagnostics.emulator_manager import (
EmulatorLaunchStage,
EmulatorLaunchState,
@@ -32,11 +33,20 @@ from artemis.core.diagnostics.probes.credentials_probe import (
LLMCredentialsProbe,
VisionOCRProbe,
)
from artemis.core.diagnostics.probes.host_probe import IntegrationHostProbe
from artemis.core.diagnostics.probes.runtime_probe import (
PythonRuntimeProbe,
SystemConfigProbe,
)
from artemis.core.diagnostics.probes.toolchain_probe import ToolchainProbe
from artemis.core.diagnostics.readiness import (
CHECK_ORDER,
Verdict,
adb_keys_corrupted,
base_verdict,
collect_readiness,
sort_by_fix_order,
)
from artemis.core.diagnostics.schema import (
DeviceInfo,
ProbeAction,
@@ -53,6 +63,7 @@ __all__ = [
"EmulatorLaunchStage",
"EmulatorLaunchState",
"BaseProbe",
"IntegrationHostProbe",
"PythonRuntimeProbe",
"SystemConfigProbe",
"AdbDeviceProbe",
@@ -69,4 +80,11 @@ __all__ = [
"get_adb_key_paths",
"inspect_adb_keys",
"heal_adb_keys",
"smoke_test_device",
"CHECK_ORDER",
"Verdict",
"collect_readiness",
"base_verdict",
"adb_keys_corrupted",
"sort_by_fix_order",
]
+359
View File
@@ -0,0 +1,359 @@
# 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.
# 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.
"""End-to-end device observation smoke test.
The readiness ADB probe only proves that ``adb devices`` and ``dumpsys`` answer.
The most common "device shows Connected but ARTEMIS cannot observe it" failure
is UIAutomator not answering (screenshot / hierarchy dump hangs), which today
only surfaces as a failed task. :func:`smoke_test_device` performs exactly the
observation the ``mobile_get_device_state`` MCP tool performs -- build (or reuse)
the cached device controller through ``artemis.mcp.adb_server._get_controller``
and call ``controller.get_screen_data()`` -- and reports a structured verdict
with concrete repair guidance instead of raising.
Design notes
------------
* ``_get_controller`` is synchronous, caches one controller per serial for the
life of the process and never touches ``DeviceExecutionLock``. Its first call
for a serial blocks on ``UIAutomatorClient.get_screen_data()`` (three
``u2.connect`` attempts plus a screenshot and hierarchy dump), so it runs on a
worker thread here.
* ``AdbDriver.get_screen_data`` invokes the synchronous UIAutomator client on
the event-loop thread. A plain ``asyncio.wait_for`` around the coroutine can
therefore never fire while UIAutomator hangs. The coroutine is executed on a
dedicated daemon thread with its own event loop, and the deadline is enforced
on the join. A thread abandoned by a timeout finishes on its own once the
underlying adb / HTTP timeouts elapse; being a daemon it never delays
interpreter shutdown.
* ``u2.connect`` restarts the on-device UIAutomator server when it believes the
server is unhealthy. Running that against a device another ARTEMIS process is
driving would break the running task's session, so a device with a live
``DeviceExecutionLock`` owner is reported as busy instead of probed.
* Nothing is left behind: no screenshot is written to disk, no lock is taken,
and -- mirroring ``mobile_get_device_state`` -- the cached controller is kept
for reuse rather than closed.
"""
from __future__ import annotations
import asyncio
import base64
from collections.abc import Callable
import os
import threading
import time
from typing import Any, TypeVar
from artemis.runtime.device_lock import DeviceExecutionLock
from artemis.utils.logger import get_logger
logger = get_logger(__name__)
T = TypeVar("T")
#: On-device packages installed by uiautomator2 (``python -m uiautomator2 init``).
UIAUTOMATOR_PACKAGES: tuple[str, ...] = (
"com.github.uiautomator",
"com.github.uiautomator.test",
)
#: A real JPEG screenshot of any phone screen is tens of kilobytes; the Android
#: driver substitutes a 1x1 PNG (~70 bytes) when every capture path failed.
_MIN_PLAUSIBLE_SCREENSHOT_BYTES = 512
_SERIAL_PLACEHOLDER = "<serial>"
def _serial_or_placeholder(serial: str | None) -> str:
return serial or _SERIAL_PLACEHOLDER
def _uiautomator_fix(serial: str | None) -> list[str]:
s = _serial_or_placeholder(serial)
force_stops = ", then ".join(
f"adb -s {s} shell am force-stop {pkg}" for pkg in UIAUTOMATOR_PACKAGES
)
return [
"Unlock the phone and keep the screen on (UIAutomator cannot dump a locked or sleeping screen).",
f"Restart the on-device UIAutomator server, then retry: {force_stops}",
(
"If it keeps failing, reinstall the UIAutomator server APKs: "
f"python -m uiautomator2 purge --serial {s}, then python -m uiautomator2 init --serial {s} "
"(run with the ARTEMIS virtualenv Python), or reboot the device."
),
]
def fix_for_error(error: str | None, serial: str | None) -> list[str]:
"""Map an error string to concrete repair steps (substring match, case-insensitive)."""
if not error:
return []
text = error.lower()
s = _serial_or_placeholder(serial)
if "busy" in text or "another task" in text:
return [
"Wait for the running task to finish, or stop it with mobile_manage_task(action='stop', ...).",
"Then rerun the smoke test.",
]
if "unauthorized" in text:
return [
"Accept the 'Allow USB debugging' prompt on the phone (tick 'Always allow from this computer').",
f"If no prompt appears, re-plug the USB cable and run: adb -s {s} devices",
]
if "offline" in text:
return [
"Re-plug the USB cable (try another port or cable).",
"Run: adb reconnect (or adb kill-server, then adb start-server) and check adb devices -l.",
]
if "no android devices" in text or "no device" in text or "not found" in text:
return [
"Connect an Android device with USB debugging enabled, or start an emulator.",
"mobile_diagnose(launch_avd='<avd name>') boots a named AVD; adb devices -l lists what is attached.",
]
if (
"timeout" in text
or "timed out" in text
or "uiautomator" in text
or "not responding" in text
or "did not respond" in text
or "hierarchy" in text
or "screenshot" in text
or "http" in text
or "deadobject" in text
):
return _uiautomator_fix(serial)
return [
f"Check the raw hierarchy dump manually: adb -s {s} shell uiautomator dump, then adb -s {s} shell cat /sdcard/window_dump.xml",
f"Check the raw screenshot path: adb -s {s} exec-out screencap -p > screen.png",
]
#: Exceptions the smoke test must never swallow.
_PASSTHROUGH_EXCEPTIONS: tuple[type[BaseException], ...] = (
KeyboardInterrupt,
SystemExit,
asyncio.CancelledError,
)
def _describe_failure(exc: BaseException) -> str:
"""uiautomator2 errors derive from ``BaseException`` and often carry an empty message."""
text = str(exc).strip()
name = type(exc).__name__
if not text:
return name
return text if name in ("Exception", "RuntimeError") else f"{name}: {text}"
def _resolve_requested_serial(device_serial: str | None) -> str | None:
"""Mirror ``_get_controller``'s serial resolution (explicit arg, then env)."""
return (
device_serial or os.environ.get("ARTEMIS_DEVICE_ID") or os.environ.get("ADB_DEVICE_SERIAL")
)
def _find_busy_owner(target_serial: str | None) -> tuple[str, Any] | None:
"""Return ``(device_key, owner)`` when a live ARTEMIS process owns the target device.
With no target serial ``_get_controller`` would pick the first attached device,
which cannot be matched against lock records cheaply; any live owner is then
treated as busy to stay on the safe side.
"""
try:
owners = DeviceExecutionLock.get_active_owners()
except Exception as exc: # pylint: disable=broad-exception-caught
logger.debug(f"Device lock inspection failed, skipping busy guard: {exc}")
return None
if not owners:
return None
if target_serial is None:
key, owner = next(iter(owners.items()))
return key, owner
clean = DeviceExecutionLock._normalize_device_id(target_serial)
for key, owner in owners.items():
if key == clean or key.endswith(f"__{clean}"):
return key, owner
return None
async def _run_in_daemon_thread(fn: Callable[[], T], timeout: float, what: str) -> T:
"""Run ``fn`` on a daemon thread and wait at most ``timeout`` seconds.
Raises :class:`TimeoutError` on deadline. The thread keeps running until
``fn`` returns on its own, but the caller is released immediately.
"""
loop = asyncio.get_running_loop()
future: asyncio.Future[T] = loop.create_future()
def _deliver(setter: Callable[[], None]) -> None:
try:
loop.call_soon_threadsafe(setter)
except RuntimeError:
# Event loop already closed: the caller gave up on us.
pass
def _worker() -> None:
try:
value = fn()
except BaseException as exc: # pylint: disable=broad-exception-caught
error: BaseException = exc
def _fail() -> None:
if not future.done():
future.set_exception(error)
_deliver(_fail)
return
def _succeed() -> None:
if not future.done():
future.set_result(value)
_deliver(_succeed)
threading.Thread(target=_worker, name=f"artemis-smoke-{what}", daemon=True).start()
try:
return await asyncio.wait_for(future, timeout=timeout)
except TimeoutError:
logger.warning(f"Device smoke test: {what} did not finish within {timeout:.1f}s")
raise
def _run_coroutine_blocking(coro_factory: Callable[[], Any]) -> Any:
"""Execute a coroutine on a fresh event loop owned by the current thread."""
return asyncio.run(coro_factory())
def _count_elements(elements: Any) -> int | None:
if elements is None:
return None
if isinstance(elements, str):
return elements.count("<node")
if isinstance(elements, dict):
return 1
try:
return len(elements)
except TypeError:
return None
def _decoded_length(b64: Any) -> int | None:
if not isinstance(b64, str) or not b64:
return None
try:
return len(base64.b64decode(b64))
except (ValueError, TypeError):
return None
async def smoke_test_device(
device_serial: str | None = None, timeout_seconds: float = 20.0
) -> dict[str, Any]:
"""Observe the device exactly like ``mobile_get_device_state`` and report a verdict.
Never raises. Returns::
{"ok": bool, "serial": str | None, "elapsed_seconds": float,
"screenshot_bytes": int | None, "element_count": int | None,
"error": str | None, "fix": list[str]}
"""
started = time.monotonic()
requested_serial = _resolve_requested_serial(device_serial)
result: dict[str, Any] = {
"ok": False,
"serial": requested_serial,
"elapsed_seconds": 0.0,
"screenshot_bytes": None,
"element_count": None,
"error": None,
"fix": [],
}
def _finish(error: str | None = None, cause: str | None = None) -> dict[str, Any]:
"""Record the verdict; ``cause`` is the raw failure text used to pick the fix."""
result["elapsed_seconds"] = round(time.monotonic() - started, 3)
result["error"] = error
result["ok"] = error is None
result["fix"] = fix_for_error(cause if cause is not None else error, result["serial"])
return result
busy = _find_busy_owner(requested_serial)
if busy is not None:
key, owner = busy
description = getattr(owner, "description", "") or "unknown task"
pid = getattr(owner, "pid", "?")
result["serial"] = requested_serial or getattr(owner, "device_id", None)
return _finish(
f"Device {key} is busy: another task is running on it ({description}, pid {pid});"
" skipping the screen capture so the running task is not disturbed."
)
try:
from artemis.mcp import adb_server
controller = await _run_in_daemon_thread(
lambda: adb_server._get_controller(device_serial=device_serial),
timeout_seconds,
"controller-init",
)
except TimeoutError:
return _finish(
f"UIAutomator/controller initialization did not respond within {timeout_seconds:g}s"
)
except _PASSTHROUGH_EXCEPTIONS:
raise
except BaseException as exc: # pylint: disable=broad-exception-caught
cause = _describe_failure(exc)
return _finish(f"Failed to initialize Android device controller: {cause}", cause)
controller_ctx = getattr(controller, "ctx", None)
device = getattr(controller_ctx, "device", None)
device_id = getattr(device, "device_id", None)
if isinstance(device_id, str) and device_id:
result["serial"] = device_id
remaining = max(0.5, timeout_seconds - (time.monotonic() - started))
try:
device_data = await _run_in_daemon_thread(
lambda: _run_coroutine_blocking(controller.get_screen_data),
remaining,
"screen-data",
)
except TimeoutError:
return _finish(f"UIAutomator/screen capture did not respond within {timeout_seconds:g}s")
except _PASSTHROUGH_EXCEPTIONS:
raise
except BaseException as exc: # pylint: disable=broad-exception-caught
cause = _describe_failure(exc)
return _finish(f"Screen capture failed: {cause}", cause)
result["screenshot_bytes"] = _decoded_length(getattr(device_data, "base64", None))
result["element_count"] = _count_elements(getattr(device_data, "elements", None))
if result["screenshot_bytes"] is None:
return _finish("Screen capture returned no screenshot data")
if result["screenshot_bytes"] < _MIN_PLAUSIBLE_SCREENSHOT_BYTES:
return _finish(
f"Screenshot capture failed (driver returned a {result['screenshot_bytes']}-byte placeholder image)"
)
if not result["element_count"]:
return _finish(
"UIAutomator hierarchy dump returned no UI elements (screenshot worked, hierarchy did not)"
)
return _finish(None)
__all__ = ["UIAUTOMATOR_PACKAGES", "fix_for_error", "smoke_test_device"]
+80 -4
View File
@@ -15,6 +15,7 @@
"""System Readiness & Diagnostic Orchestration Engine."""
import asyncio
import os
import subprocess
import time
from typing import Any
@@ -33,6 +34,7 @@ from artemis.core.diagnostics.probes.runtime_probe import (
from artemis.core.diagnostics.probes.toolchain_probe import ToolchainProbe
from artemis.core.diagnostics.schema import (
DeviceInfo,
ProbeAction,
ProbeCategory,
ProbeResult,
ProbeStatus,
@@ -50,6 +52,13 @@ class ReadinessEngine:
_REPORT_CACHE_TTL_SECONDS = 2.0
#: Deadline for one probe inside a report. The ADB probe shells out to
#: ``adb`` without a deadline of its own, and a wedged ADB server would
#: otherwise hang every surface that runs the report (``artemis doctor``,
#: the console wizard, ``mobile_diagnose``). A probe that overruns is
#: reported as a FAIL with the recovery steps instead.
PROBE_TIMEOUT_SECONDS = 30.0
def __init__(self):
self._probes: dict[str, BaseProbe] = {}
self._python_probe = PythonRuntimeProbe()
@@ -178,11 +187,22 @@ class ReadinessEngine:
if categories is None or probe.category in categories
]
# Concurrently execute probes
results: list[ProbeResult] = await asyncio.gather(
*[probe.probe() for probe in target_probes],
return_exceptions=False,
# Concurrently execute probes. A probe that raises must not take the
# whole report down with it: it becomes a structured FAIL so the
# remaining probes still reach the user and the verdict stays honest.
outcomes = await asyncio.gather(
*[
asyncio.wait_for(probe.probe(), timeout=self.PROBE_TIMEOUT_SECONDS)
for probe in target_probes
],
return_exceptions=True,
)
results: list[ProbeResult] = [
outcome
if isinstance(outcome, ProbeResult)
else self._crashed_probe_result(probe, outcome)
for probe, outcome in zip(target_probes, outcomes, strict=True)
]
blockers = [r for r in results if r.is_blocker]
passed_blockers = [r for r in blockers if r.status == ProbeStatus.PASS]
@@ -211,6 +231,62 @@ class ReadinessEngine:
timestamp=time.time(),
)
@staticmethod
def _crashed_probe_result(probe: BaseProbe, exc: object) -> ProbeResult:
"""Turn an exception escaping ``probe.probe()`` into a FAIL result.
Cancellation and other non-``Exception`` errors are re-raised: they are
not a diagnosis of the host, they are the event loop shutting us down.
"""
if isinstance(exc, BaseException) and not isinstance(exc, Exception):
raise exc
if isinstance(exc, TimeoutError):
logger.warning(
f"[ReadinessEngine] Probe '{probe.probe_id}' did not finish within"
f" {ReadinessEngine.PROBE_TIMEOUT_SECONDS:.0f}s; reporting it as FAIL."
)
return ProbeResult(
id=probe.probe_id,
category=probe.category,
title=probe.probe_id.replace("_", " ").title(),
status=ProbeStatus.FAIL,
is_blocker=probe.is_blocker,
summary="Probe timed out",
description=(
f"The '{probe.probe_id}' check did not finish within"
f" {ReadinessEngine.PROBE_TIMEOUT_SECONDS:.0f}s. A hung ADB server is the"
" usual cause: restart it and run the diagnosis again."
),
actions=[
ProbeAction(
action_type="command",
label="Restart ADB",
payload="adb kill-server && adb start-server",
),
],
metadata={
"exception_type": "TimeoutError",
"timeout_seconds": ReadinessEngine.PROBE_TIMEOUT_SECONDS,
},
)
logger.warning(
f"[ReadinessEngine] Probe '{probe.probe_id}' crashed; reporting it as FAIL: "
f"{type(exc).__name__}: {exc}"
)
return ProbeResult(
id=probe.probe_id,
category=probe.category,
title=probe.probe_id.replace("_", " ").title(),
status=ProbeStatus.FAIL,
is_blocker=probe.is_blocker,
summary="Probe crashed",
description=(
f"The '{probe.probe_id}' check raised {type(exc).__name__}: {exc}. "
"This is a diagnostics bug or a host permission problem, not a device fault."
),
metadata={"exception_type": type(exc).__name__, "exception": str(exc)},
)
async def heal_adb_keys(self, force: bool = False) -> dict[str, Any]:
"""Auto-heal corrupted or 0-byte ADB authentication keys."""
from artemis.core.diagnostics.adb_keys import heal_adb_keys
@@ -284,7 +284,7 @@ class VisionOCRProbe(BaseProbe):
status=ProbeStatus.PASS,
is_blocker=False,
summary="Not Configured (Optional)",
description="OCR_API_KEY is not set. Perception operates seamlessly in pure UI XML hierarchy mode.",
description="OCR_API_KEY is not set. Perception uses the UI XML hierarchy without OCR.",
metadata={"configured": False},
actions=[
ProbeAction(
@@ -0,0 +1,427 @@
# 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.
# 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.
"""Integration-host probe: the process environment an IDE / MCP client launched us in.
The standard readiness probes answer "can Artemis drive a phone on this
machine?". This probe answers the question that precedes it when Artemis is
embedded in an AI coding assistant (Claude Code, Cursor, Codex, Antigravity,
...): "was the server launched the way the installer expects, and can it
spawn a background runner?". The failure modes it covers are the ones users
hit mostly through MCP, rarely from the CLI or the web console:
* the MCP client started the server with a different interpreter than the
project virtualenv, so the spawned runner imports a different package set;
* the ``.env`` file the settings loader reads is not where the user put the key;
* the traces directory is not writable from the client's sandbox;
* the daemon port is held by another process (a very common collision with
dev servers that also default to port 8000).
The probe is not registered in the default :class:`ReadinessEngine` probe set:
the web console has its own device wizard and would only render noise. The
``mobile_diagnose`` MCP tool and ``artemis doctor`` run it explicitly.
Only the unwritable-traces case blocks task execution; the result carries
``is_blocker`` accordingly, so the WARN findings degrade the verdict without
blocking it.
"""
from __future__ import annotations
import os
from pathlib import Path
import sys
import tempfile
from typing import Any
from artemis.core.diagnostics.probes.base import BaseProbe
from artemis.core.diagnostics.schema import (
ProbeAction,
ProbeCategory,
ProbeResult,
ProbeStatus,
)
def _same_file(a: str | os.PathLike[str], b: str | os.PathLike[str]) -> bool:
"""Compare two interpreter paths tolerantly (symlinks, case on Windows)."""
try:
return os.path.samefile(a, b)
except OSError:
return os.path.normcase(os.path.abspath(a)) == os.path.normcase(os.path.abspath(b))
#: Client names accepted by ``artemis mcp --install`` (see interfaces/cli/commands/mcp.py).
MCP_CLIENT_NAMES: tuple[str, ...] = (
"antigravity",
"claude",
"cursor",
"windsurf",
"vscode",
"cline",
"roo",
"openclaw",
"codex",
)
def _env_has_prefix(prefix: str, *, exclude: tuple[str, ...] = ()) -> bool:
return any(key.startswith(prefix) and key not in exclude for key in os.environ)
def detect_mcp_client() -> str | None:
"""Best-effort guess of which MCP client launched this process, from ``os.environ``.
Returns one of :data:`MCP_CLIENT_NAMES` or ``None`` when unsure. MCP hosts
spawn the server with their own process environment, so the variables the
IDE exports to its child processes leak through. Specific hosts are checked
before the generic VS Code markers because Cursor, Windsurf, Cline and Roo
are (or run inside) VS Code and inherit ``VSCODE_*`` / ``TERM_PROGRAM``.
"""
# Antigravity injects its language-server endpoint and CSRF token
# (ANTIGRAVITY_LS_ADDRESS / ANTIGRAVITY_CSRF_TOKEN) so child processes can
# call `agentapi`; mcp_server/notifiers/agentapi.py relies on the same pair.
if _env_has_prefix("ANTIGRAVITY_"):
return "antigravity"
# Claude Code marks every child with CLAUDECODE=1 plus CLAUDE_CODE_* details
# (entrypoint, session id, ...). Claude Desktop shares the same installer
# target, so both map to "claude".
if os.environ.get("CLAUDECODE") == "1" or _env_has_prefix("CLAUDE_CODE_"):
return "claude"
# Cursor tags its integrated terminal and spawned processes with
# CURSOR_TRACE_ID (and CURSOR_AGENT in agent mode).
if "CURSOR_TRACE_ID" in os.environ or _env_has_prefix("CURSOR_"):
return "cursor"
# Windsurf (Codeium) exports WINDSURF_* to its child processes.
if _env_has_prefix("WINDSURF_"):
return "windsurf"
# Codex CLI sets CODEX_SANDBOX* / CODEX_* for the processes it runs.
# CODEX_HOME alone is excluded: users set it by hand to relocate the Codex
# config directory, so on its own it does not prove Codex launched us.
if _env_has_prefix("CODEX_", exclude=("CODEX_HOME",)):
return "codex"
# OPENCLAW_WEBHOOK_URL is a notifier setting users put in any host's env
# block (see mcp_server/notifiers/webhook.py); only other OPENCLAW_* vars
# indicate the OpenClaw runtime itself.
if _env_has_prefix("OPENCLAW_", exclude=("OPENCLAW_WEBHOOK_URL",)):
return "openclaw"
# Cline / Roo Code run as VS Code extensions; their own prefixes are rare
# but unambiguous when present, and must win over the generic VS Code check.
if _env_has_prefix("CLINE_"):
return "cline"
if _env_has_prefix("ROO_"):
return "roo"
# Plain VS Code (Copilot agent mode) exports VSCODE_PID / VSCODE_IPC_HOOK
# and sets TERM_PROGRAM=vscode. Weakest signal, so it is checked last.
if (
"VSCODE_PID" in os.environ
or "VSCODE_IPC_HOOK" in os.environ
or os.environ.get("TERM_PROGRAM") == "vscode"
):
return "vscode"
return None
def mcp_install_actions(client: str | None) -> list[ProbeAction]:
"""Actions that regenerate the MCP client config for ``client`` (or ask which one)."""
if client:
return [
ProbeAction(
action_type="command",
label="Regenerate MCP Config",
payload=f"uv run artemis mcp --install {client}",
)
]
return [
ProbeAction(
action_type="hint",
label="Regenerate MCP Config",
payload=(
"Could not tell which MCP client launched this server. Ask the user which IDE"
" they are using, then run `uv run artemis mcp --install <client>` with"
" <client> being one of: " + ", ".join(MCP_CLIENT_NAMES) + "."
),
)
]
def project_venv_python(project_root: Path) -> Path:
"""Return the interpreter path the installer writes into MCP client configs."""
if sys.platform == "win32":
return project_root / ".venv" / "Scripts" / "python.exe"
return project_root / ".venv" / "bin" / "python"
def directory_is_writable(path: Path) -> tuple[bool, str | None]:
"""Try to create and delete a scratch file; return (ok, error)."""
try:
path.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile(dir=path, prefix=".artemis-diag-", delete=True):
pass
return True, None
except OSError as exc:
return False, str(exc)
def intended_traces_path() -> Path:
"""Return the traces directory the settings loader *would* pick, without creating it.
Mirrors the selection order of :func:`artemis.config.paths.get_default_traces_path`
(``ARTEMIS_TRACES_DIR`` env, else the user app dir when the install mode
keeps mutable state there, else ``ROOT_DIR/traces``) minus its ``mkdir``.
Used only when that function raised, so the probe can still name the
offending directory in its FAIL result instead of crashing.
"""
from artemis.config import paths as artemis_paths
env_traces = os.getenv(artemis_paths.ENV_ARTEMIS_TRACES_DIR)
if env_traces:
return Path(env_traces)
if artemis_paths._use_user_app_dir():
return artemis_paths.get_app_dir() / "traces"
return Path(artemis_paths.ROOT_DIR) / "traces"
def resolve_traces_dir() -> tuple[Path, str | None]:
"""Return ``(traces_dir, error)``; ``error`` is set when the directory could not be created.
``get_default_traces_path`` creates the directory as a side effect, so on a
read-only parent it raises before :func:`directory_is_writable` ever runs.
Surface that as data rather than letting the probe itself crash.
"""
from artemis.config.paths import get_default_traces_path
try:
return Path(get_default_traces_path()), None
except OSError as exc:
return intended_traces_path(), str(exc)
class IntegrationHostProbe(BaseProbe):
"""Probe verifying the MCP / IDE host process can actually run Artemis tasks."""
@property
def probe_id(self) -> str:
return "integration_host"
@property
def category(self) -> ProbeCategory:
return ProbeCategory.RUNTIME
@property
def is_blocker(self) -> bool:
return True
async def probe(self) -> ProbeResult:
# Lazy imports keep this module importable in stripped test environments
# and avoid a core -> runtime import at package import time.
from artemis.config.paths import ROOT_DIR, get_env_file, is_source_checkout
from artemis.runtime.daemon_client import (
DEFAULT_DAEMON_HOST,
DEFAULT_DAEMON_PORT,
daemon_log_path,
is_artemis_daemon,
is_standalone_forced,
)
from artemis.runtime.server_lifecycle import is_port_in_use
project_root = Path(ROOT_DIR)
source_checkout = is_source_checkout()
server_python = Path(sys.executable)
# A project virtualenv is only meaningful for a source checkout. In a
# wheel install ROOT_DIR is site-packages, which never carries a .venv;
# the current interpreter also runs background tasks.
venv_python: Path | None = project_venv_python(project_root) if source_checkout else None
venv_exists = venv_python is not None and venv_python.is_file()
runner_python = venv_python if venv_exists else server_python
interpreter_matches_venv = venv_exists and _same_file(server_python, venv_python)
mcp_client = detect_mcp_client()
env_file = Path(get_env_file())
traces_dir, traces_error = resolve_traces_dir()
if traces_error is None:
traces_writable, traces_error = directory_is_writable(traces_dir)
else:
traces_writable = False
standalone = is_standalone_forced()
# Identity, not liveness: a dev server squatting on the port answers
# HTTP too, and must show up as a hijacked port, not as the daemon.
daemon_reachable = False if standalone else is_artemis_daemon()
port_in_use = is_port_in_use(DEFAULT_DAEMON_PORT, DEFAULT_DAEMON_HOST)
port_hijacked = port_in_use and not daemon_reachable and not standalone
metadata: dict[str, Any] = {
"os": sys.platform,
"mcp_client": mcp_client,
"project_root": str(project_root),
"is_source_checkout": source_checkout,
"server_python": str(server_python),
"venv_python": str(venv_python) if venv_python is not None else None,
"venv_exists": venv_exists,
"interpreter_matches_venv": interpreter_matches_venv,
"runner_python": str(runner_python),
"env_file": str(env_file),
"env_file_exists": env_file.is_file(),
"traces_dir": str(traces_dir),
"traces_dir_writable": traces_writable,
"traces_dir_error": traces_error,
"daemon": {
"host": DEFAULT_DAEMON_HOST,
"port": DEFAULT_DAEMON_PORT,
"standalone_forced": standalone,
"reachable": daemon_reachable,
"port_in_use": port_in_use,
"port_held_by_other_process": port_hijacked,
"log_path": str(daemon_log_path()),
},
}
problems: list[str] = []
actions: list[ProbeAction] = []
status = ProbeStatus.PASS
if not traces_writable:
status = ProbeStatus.FAIL
problems.append(f"traces directory {traces_dir} is not writable ({traces_error})")
actions.append(
ProbeAction(
action_type="hint",
label="Fix Traces Directory",
payload=(
f"Make {traces_dir} writable for the user running the MCP server, or point "
"ARTEMIS_TRACES_DIR at a writable directory in the MCP server's env block, "
"then restart the MCP server."
),
)
)
if port_hijacked:
status = ProbeStatus.WARN if status is ProbeStatus.PASS else status
problems.append(
f"port {DEFAULT_DAEMON_PORT} is held by a process that is not the Artemis daemon"
)
actions.append(
ProbeAction(
action_type="hint",
label="Free the Daemon Port",
payload=(
f"Another program is listening on {DEFAULT_DAEMON_HOST}:{DEFAULT_DAEMON_PORT}"
" (often a dev server). Stop it, or set ARTEMIS_DAEMON_PORT to a free port in"
" the MCP server's env block and restart the MCP server. Tasks still run in"
" standalone mode meanwhile, without the shared scheduler."
),
)
)
if not source_checkout:
# Wheel / frozen install: no virtualenv expectation, nothing to warn about.
pass
elif venv_exists and not interpreter_matches_venv:
status = ProbeStatus.WARN if status is ProbeStatus.PASS else status
problems.append(
f"MCP server runs on {server_python}, not the project virtualenv {venv_python}"
)
actions.extend(mcp_install_actions(mcp_client))
actions.append(
ProbeAction(
action_type="hint",
label="Interpreter Mismatch",
payload=(
"The MCP client config should point `command` at the .venv interpreter so the"
" server and the background runner share one package set. Restart the MCP"
" server after changing the config."
),
)
)
elif not venv_exists:
status = ProbeStatus.WARN if status is ProbeStatus.PASS else status
problems.append(
"no project virtualenv (.venv) found; runner falls back to the server interpreter"
)
actions.append(
ProbeAction(
action_type="command",
label="Create Virtualenv",
payload="uv sync",
)
)
if not env_file.is_file():
actions.append(
ProbeAction(
action_type="hint",
label="Env File Location",
payload=(
f"No .env file at {env_file}. API keys are read from that file or from the MCP"
" server's env block; the file is created by `artemis init`."
),
)
)
if not daemon_reachable and not port_hijacked:
actions.append(
ProbeAction(
action_type="hint",
label="Daemon Auto-Start",
payload=(
"Standalone mode is forced (ARTEMIS_STANDALONE=1); tasks run as detached"
" runner processes."
if standalone
else "The Artemis daemon is not running; mobile_run_task starts it on the"
f" first task. Its log is {daemon_log_path()}."
),
)
)
if status is ProbeStatus.PASS:
summary = "Host Ready"
description = (
f"MCP server runs on {server_python}; runner uses {runner_python}. "
f"Traces at {traces_dir}. "
+ (
"Daemon reachable."
if daemon_reachable
else "Daemon will auto-start on the first task."
)
)
actions.insert(
0,
ProbeAction(
action_type="hint",
label="Host OK",
payload="Interpreter, traces directory and daemon port look consistent.",
),
)
else:
summary = "Host Misconfigured" if status is ProbeStatus.FAIL else "Host Warning"
joined = "; ".join(problems)
description = joined[:1].upper() + joined[1:] + "."
# Only an unwritable traces directory stops tasks from running. The
# WARN findings (interpreter mismatch, missing venv, squatted port) are
# degradations the runner works around, so they must not turn the
# verdict into "blocked" — a pip/conda checkout without .venv would
# otherwise never leave that state.
return ProbeResult(
id=self.probe_id,
category=self.category,
title="MCP / IDE Integration Host",
status=status,
is_blocker=status is not ProbeStatus.WARN,
summary=summary,
description=description,
metadata=metadata,
actions=actions,
)
+83
View File
@@ -0,0 +1,83 @@
# 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.
# 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.
"""Readiness collection and verdict shared by every diagnosis entry point.
``artemis doctor`` and the ``mobile_diagnose`` MCP tool must agree on which
probes run, in which order they are fixed, and what "ready" means. This module
holds exactly that common core; each entry point layers its own extra
conditions, fix policy and rendering on top.
"""
from __future__ import annotations
import asyncio
from collections.abc import Sequence
from typing import Literal
from artemis.core.diagnostics.engine import readiness_engine
from artemis.core.diagnostics.probes.host_probe import IntegrationHostProbe
from artemis.core.diagnostics.schema import ProbeResult, ProbeStatus, SystemReadinessReport
Verdict = Literal["ready", "degraded", "blocked"]
#: Fix order. Runtime problems mask everything after them, credentials mask
#: device problems (a task cannot start without a model), and the toolchain is
#: optional.
CHECK_ORDER: dict[str, int] = {
"python_runtime": 0,
"system_config": 1,
"integration_host": 2,
"gemini_api_key": 3,
"android_adb": 4,
"toolchain": 5,
"vision_ocr_key": 6,
}
def sort_by_fix_order(results: Sequence[ProbeResult]) -> list[ProbeResult]:
"""Order probe results so the first problem listed is the one to fix first."""
return sorted(results, key=lambda r: CHECK_ORDER.get(r.id, len(CHECK_ORDER)))
async def collect_readiness() -> tuple[SystemReadinessReport, ProbeResult]:
"""Run the engine probes and the integration-host probe concurrently."""
report, host = await asyncio.gather(
readiness_engine.run_all(force_refresh=True),
IntegrationHostProbe().probe(),
)
return report, host
def base_verdict(results: Sequence[ProbeResult]) -> Verdict:
"""Verdict from probe results alone.
Blocked when there is no blocker at all (nothing was checked) or any
blocker is not PASS; degraded when only optional checks fail; ready
otherwise.
"""
blockers = [r for r in results if r.is_blocker]
if not blockers or any(r.status is not ProbeStatus.PASS for r in blockers):
return "blocked"
if any(r.status is not ProbeStatus.PASS for r in results):
return "degraded"
return "ready"
def adb_keys_corrupted(results: Sequence[ProbeResult]) -> bool:
"""Whether the ADB probe reported corrupted RSA keys (the one self-heal both entry points share)."""
adb = next((r for r in results if r.id == "android_adb"), None)
if adb is None:
return False
return bool((adb.metadata.get("adb_keys") or {}).get("is_corrupted"))