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:
1 parent
e983180b5b
commit
5d92939d91
127 files changed
+13738
-1988
No files matched your search
@@ -15,6 +15,8 @@
|
||||
"""Utilities for handling app locking and initial app launch logic."""
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass, field
|
||||
import re
|
||||
|
||||
from artemis.context import AppLaunchResult, ArtemisContext
|
||||
from artemis.controllers.platform_specific_commands_controller import (
|
||||
@@ -28,43 +30,180 @@ from artemis.utils.logger import get_logger
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
SYSTEM_OVERLAYS_VALID_FOR_LAUNCH = {
|
||||
"com.google.android.permissioncontroller",
|
||||
"com.android.permissioncontroller",
|
||||
"com.google.android.packageinstaller",
|
||||
"com.android.packageinstaller",
|
||||
}
|
||||
# ``ActivityRecord{<hash> u<user> <package>/<activity> t<task id>}`` as printed by
|
||||
# ``dumpsys activity activities`` on every line that names an activity.
|
||||
_ACTIVITY_RECORD_RE = re.compile(
|
||||
r"ActivityRecord\{[0-9a-f]+ u\d+ (?P<package>[\w.]+)/(?P<activity>[\w.$]+) t(?P<task>\d+)\}"
|
||||
)
|
||||
# ``* Task{<hash> #<id> type=... A=<uid>:<affinity> ... visible=true ...}`` task headers
|
||||
# (``* TaskRecord{<hash> #<id> A=<affinity> ...}`` on Android 11 and older).
|
||||
_TASK_HEADER_RE = re.compile(r"^\s*\* Task(?:Record)?\{[0-9a-f]+ #(?P<task>\d+)(?P<attrs>[^}]*)\}")
|
||||
# Device-side pre-filter: the full dump runs to hundreds of KB on a busy device;
|
||||
# only the task headers, history entries and resumed-activity lines are parsed.
|
||||
_FOREGROUND_DUMP_CMD = (
|
||||
"dumpsys activity activities | grep -E"
|
||||
r" '^ *\* (Task|TaskRecord)\{|^ *\* Hist |ResumedActivity'"
|
||||
)
|
||||
# ``* Hist #<index>: ActivityRecord{...}`` entries; index 0 is the task's base activity.
|
||||
_HIST_RE = re.compile(r"^\s*\* Hist\s+#(?P<index>\d+): (?P<record>ActivityRecord\{[^}]*\})")
|
||||
# ``topResumedActivity=ActivityRecord{...}`` (per task, top task first) and the global
|
||||
# ``ResumedActivity: ActivityRecord{...}`` line; the first one seen is the foreground one.
|
||||
_RESUMED_RE = re.compile(r"ResumedActivity[:=]\s*(?P<record>ActivityRecord\{[^}]*\})")
|
||||
|
||||
|
||||
def get_focused_task_package(ctx: ArtemisContext) -> str | None:
|
||||
"""Inspect the active/focused task stack to find the package affinity of the top task.
|
||||
@dataclass(frozen=True)
|
||||
class ForegroundTask:
|
||||
"""What ``dumpsys activity activities`` says is on top of the display.
|
||||
|
||||
Uses adb shell dumpsys activity activities to extract this info.
|
||||
``resumed_package`` is the package of the resumed activity (what the user sees).
|
||||
``base_package`` is the package of the task's root activity (``Hist #0``), and
|
||||
``packages`` holds every package that has an activity in that task, so an app whose
|
||||
task currently shows a helper activity from another package (Settings search,
|
||||
permission dialogs, account pickers, system update pages, ...) is still
|
||||
recognised as the foreground app.
|
||||
"""
|
||||
|
||||
task_id: int | None = None
|
||||
affinity: str | None = None
|
||||
resumed_component: str | None = None
|
||||
resumed_package: str | None = None
|
||||
base_package: str | None = None
|
||||
packages: frozenset[str] = field(default_factory=frozenset)
|
||||
|
||||
def owns(self, app_package: str) -> bool:
|
||||
"""Whether ``app_package`` is the app the foreground task belongs to.
|
||||
|
||||
The task affinity is the app's own claim on the task (by default the
|
||||
package name, or a package-prefixed name such as
|
||||
``com.android.settings.root``); it stays with the task even when the
|
||||
app's root activity has been destroyed and only a companion activity
|
||||
(e.g. the Settings search UI) remains in the history."""
|
||||
affinity = self.affinity or ""
|
||||
return (
|
||||
app_package == self.resumed_package
|
||||
or app_package == self.base_package
|
||||
or app_package in self.packages
|
||||
or affinity == app_package
|
||||
or affinity.startswith(app_package + ".")
|
||||
)
|
||||
|
||||
def describe(self) -> str:
|
||||
task = f"task #{self.task_id}" if self.task_id is not None else "task ?"
|
||||
details = [f"base={self.base_package}", f"resumed={self.resumed_component}"]
|
||||
if self.affinity:
|
||||
details.append(f"affinity={self.affinity}")
|
||||
others = sorted(self.packages - {self.base_package, self.resumed_package, None})
|
||||
if others:
|
||||
details.append(f"also={','.join(others)}")
|
||||
return f"{task} ({', '.join(details)})"
|
||||
|
||||
|
||||
def parse_foreground_task(dump: str) -> ForegroundTask | None:
|
||||
"""Parse ``dumpsys activity activities`` output into the foreground task summary.
|
||||
|
||||
The foreground task is the one holding the resumed activity; when no activity is
|
||||
resumed (mid-transition) the first visible task with history entries is used.
|
||||
Returns None when the dump contains no task information at all.
|
||||
"""
|
||||
task_attrs: dict[int, str] = {}
|
||||
task_order: list[int] = []
|
||||
task_activities: dict[int, list[tuple[int, str, str]]] = {}
|
||||
resumed: re.Match[str] | None = None
|
||||
|
||||
for line in dump.splitlines():
|
||||
header = _TASK_HEADER_RE.match(line)
|
||||
if header:
|
||||
task_id = int(header.group("task"))
|
||||
if task_id not in task_attrs:
|
||||
task_attrs[task_id] = header.group("attrs")
|
||||
task_order.append(task_id)
|
||||
continue
|
||||
hist = _HIST_RE.match(line)
|
||||
if hist:
|
||||
record = _ACTIVITY_RECORD_RE.search(hist.group("record"))
|
||||
if record:
|
||||
task_activities.setdefault(int(record.group("task")), []).append(
|
||||
(int(hist.group("index")), record.group("package"), record.group("activity"))
|
||||
)
|
||||
continue
|
||||
if resumed is None:
|
||||
match = _RESUMED_RE.search(line)
|
||||
if match:
|
||||
resumed = _ACTIVITY_RECORD_RE.search(match.group("record"))
|
||||
|
||||
if not task_attrs and resumed is None:
|
||||
return None
|
||||
|
||||
task_id: int | None = None
|
||||
if resumed is not None:
|
||||
task_id = int(resumed.group("task"))
|
||||
else:
|
||||
for candidate in task_order:
|
||||
if "visible=true" in task_attrs[candidate] and candidate in task_activities:
|
||||
task_id = candidate
|
||||
break
|
||||
|
||||
activities = sorted(task_activities.get(task_id, []))
|
||||
affinity_match = re.search(r"\bA=(?:\d+:)?(\S+)", task_attrs.get(task_id, ""))
|
||||
return ForegroundTask(
|
||||
task_id=task_id,
|
||||
affinity=affinity_match.group(1) if affinity_match else None,
|
||||
resumed_component=(
|
||||
f"{resumed.group('package')}/{resumed.group('activity')}" if resumed else None
|
||||
),
|
||||
resumed_package=resumed.group("package") if resumed else None,
|
||||
base_package=activities[0][1] if activities else None,
|
||||
packages=frozenset(package for _, package, _ in activities),
|
||||
)
|
||||
|
||||
|
||||
def get_foreground_task(ctx: ArtemisContext) -> ForegroundTask | None:
|
||||
"""Read the foreground task from ``dumpsys activity activities`` on the device.
|
||||
|
||||
Blocking (a synchronous ADB shell round-trip); async callers go through
|
||||
:func:`get_foreground_task_async` so the event loop keeps serving.
|
||||
"""
|
||||
try:
|
||||
device = get_adb_device(ctx)
|
||||
output = str(device.shell("dumpsys activity activities | grep topDisplayFocusedRootTask"))
|
||||
if "topDisplayFocusedRootTask=" in output:
|
||||
segment = output.split("topDisplayFocusedRootTask=")[-1]
|
||||
if "A=" in segment:
|
||||
affinity = segment.split("A=")[-1].split("}")[0].split()[0].strip()
|
||||
if ":" in affinity:
|
||||
return affinity.split(":")[-1]
|
||||
return affinity
|
||||
if "{" in segment and "}" in segment:
|
||||
braces_content = segment.split("{")[-1].split("}")[0]
|
||||
tokens = braces_content.split()
|
||||
for token in tokens:
|
||||
if "." in token and not token.startswith("#") and not token.isdigit():
|
||||
if ":" in token:
|
||||
return token.split(":")[-1]
|
||||
return token
|
||||
return None
|
||||
if device is None:
|
||||
return None
|
||||
dump = str(device.shell(_FOREGROUND_DUMP_CMD))
|
||||
if "Task" not in dump:
|
||||
# No grep on the device (or nothing matched): parse the full dump.
|
||||
dump = str(device.shell("dumpsys activity activities"))
|
||||
return parse_foreground_task(dump)
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to retrieve focused task package via dumpsys: {e}")
|
||||
logger.debug(f"Failed to retrieve foreground task via dumpsys: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def get_foreground_task_async(ctx: ArtemisContext) -> ForegroundTask | None:
|
||||
""":func:`get_foreground_task` off the event loop."""
|
||||
return await asyncio.to_thread(get_foreground_task, ctx)
|
||||
|
||||
|
||||
async def _observe_foreground(
|
||||
ctx: ArtemisContext, app_package: str
|
||||
) -> tuple[bool, str | None, ForegroundTask | None]:
|
||||
"""Report whether ``app_package`` is in the foreground.
|
||||
|
||||
Fast path: the focused window's package equals the target. Otherwise the app also
|
||||
counts as foreground when the top task belongs to it (see ``ForegroundTask``).
|
||||
A ``None`` focused package means the window manager is mid-transition and is
|
||||
reported as "not yet" without consulting the task stack.
|
||||
|
||||
Returns (is_foreground, focused_package, foreground_task); the task is only read
|
||||
when the fast path fails on a non-null focused package.
|
||||
"""
|
||||
current_package = await get_current_foreground_package_async(ctx)
|
||||
if current_package == app_package:
|
||||
return True, current_package, None
|
||||
if current_package is None:
|
||||
return False, None, None
|
||||
task = await get_foreground_task_async(ctx)
|
||||
return task is not None and task.owns(app_package), current_package, task
|
||||
|
||||
|
||||
async def _poll_for_app_ready(
|
||||
ctx: ArtemisContext,
|
||||
app_package: str,
|
||||
@@ -88,38 +227,37 @@ async def _poll_for_app_ready(
|
||||
polls = int(max_poll_seconds / poll_interval)
|
||||
|
||||
for i in range(polls):
|
||||
current_package = await get_current_foreground_package_async(ctx)
|
||||
ready, current_package, task = await _observe_foreground(ctx, app_package)
|
||||
|
||||
if current_package == app_package:
|
||||
logger.success(f"App {app_package} is ready (took ~{i * poll_interval:.1f}s)")
|
||||
return True, None
|
||||
|
||||
if current_package in SYSTEM_OVERLAYS_VALID_FOR_LAUNCH:
|
||||
task_package = get_focused_task_package(ctx)
|
||||
if task_package == app_package:
|
||||
if ready:
|
||||
if current_package == app_package:
|
||||
logger.success(f"App {app_package} is ready (took ~{i * poll_interval:.1f}s)")
|
||||
else:
|
||||
logger.success(
|
||||
f"App {app_package} launch succeeded (system overlay"
|
||||
f" '{current_package}' focused, but top task stack affinity"
|
||||
f" matches expected app package '{task_package}', took"
|
||||
f" ~{i * poll_interval:.1f}s)"
|
||||
f"App {app_package} is ready (focused window belongs to"
|
||||
f" '{current_package}', but the foreground {task.describe()} is owned"
|
||||
f" by {app_package}, took ~{i * poll_interval:.1f}s)"
|
||||
)
|
||||
return True, None
|
||||
return True, None
|
||||
|
||||
if current_package is None:
|
||||
logger.debug(f"Poll {i + 1}/{polls}: App loading (mCurrentFocus=null)...")
|
||||
else:
|
||||
logger.debug(
|
||||
f"Poll {i + 1}/{polls}: Wrong app in foreground (expected"
|
||||
f" '{app_package}', got '{current_package}'). Still waiting..."
|
||||
f" '{app_package}', got '{current_package}', foreground"
|
||||
f" {task.describe() if task else 'task unknown'}). Still waiting..."
|
||||
)
|
||||
|
||||
if i < polls - 1:
|
||||
await asyncio.sleep(poll_interval)
|
||||
|
||||
current_package = await get_current_foreground_package_async(ctx)
|
||||
task = await get_foreground_task_async(ctx)
|
||||
error_msg = (
|
||||
f"Timeout waiting for {app_package} to load after {max_poll_seconds}s. "
|
||||
f"Current foreground: {current_package}"
|
||||
f"Current foreground: {current_package}; foreground"
|
||||
f" {task.describe() if task else 'task unknown'}"
|
||||
)
|
||||
logger.error(error_msg)
|
||||
return False, error_msg
|
||||
@@ -225,10 +363,10 @@ async def _handle_initial_app_launch(
|
||||
logger.info(f"Starting initial app launch for package: {locked_app_package}")
|
||||
|
||||
try:
|
||||
current_package = await get_current_foreground_package_async(ctx)
|
||||
already_foreground, current_package, _ = await _observe_foreground(ctx, locked_app_package)
|
||||
logger.info(f"Current foreground app: {current_package}")
|
||||
|
||||
if current_package == locked_app_package:
|
||||
if already_foreground:
|
||||
logger.info(f"App {locked_app_package} is already in foreground")
|
||||
return AppLaunchResult(
|
||||
locked_app_package=locked_app_package,
|
||||
|
||||
Reference in new issue
Block a user