feat: add step-by-step visual replay and harden process lifecycle

- UI: Implement dual-mode playback in FloatingVideoPlayer supporting video and step-by-step frame replay with scrub controls and auto-fallback
- Runtime: Prevent asyncio event loop hangs on Darwin and Linux by avoiding psutil.wait_procs on direct child processes
- Task Queue: Add worker exit watchdog and automatic recording recovery for completed or interrupted sessions
- Recording: Add ffmpeg ultrafast transcode fallback, shield recording finalization against cancellation, and handle on-the-fly MP4 conversion
- Docs: Polish README_CN styling and streamline flash runner agent prompt
This commit is contained in:
somew1nd committed 2026-08-26 00:53:24 -07:00
1 parent 06643e955b
commit a36a66ce6d
26 files changed
+1152 -117

No files matched your search

+1 -1
View File
@@ -1,5 +1,5 @@
# ROLE & OBJECTIVE
You are **ARTEMIS Flash Runner**, an autonomous and highly efficient Android Device Execution Agent. Your goal is to accomplish the user's objective on the device.
You are an autonomous and highly efficient Android Device Execution Agent. Your goal is to accomplish the user's objective on the device.
**Objective: {{ goal }}**
+32 -16
View File
@@ -697,7 +697,10 @@ class UnifiedMobileController:
session.is_active = False
if session.watchdog_task and not session.watchdog_task.done():
session.watchdog_task.cancel()
await session.watchdog_task
try:
await session.watchdog_task
except (asyncio.CancelledError, Exception):
pass
try:
process = session.process
@@ -708,7 +711,14 @@ class UnifiedMobileController:
logger.warning(f"Error terminating scrcpy process: {proc_e}")
output_path = session.local_video_path
if not output_path or not output_path.exists():
has_existing_recording = (
(output_path and output_path.exists())
or any(
Path(r.get("output_path", "")).exists() or Path(r.get("path", "")).exists()
for r in session.android_segment_records
)
)
if not has_existing_recording:
message = "Recording file not found on disk"
self._record_recording_failure(session, message)
remove_active_session(device_id)
@@ -726,31 +736,37 @@ class UnifiedMobileController:
self._remux_segment_record(record)
)
)
conversion_results = []
if session.android_conversion_tasks:
conversion_results = await asyncio.gather(
await asyncio.gather(
*session.android_conversion_tasks, return_exceptions=True
)
mp4_paths = [
Path(record["output_path"])
for record in session.android_segment_records
if Path(record["output_path"]).exists()
if Path(record["output_path"]).exists() and Path(record["output_path"]).stat().st_size > 0
]
manifest_path = await write_recording_manifest(output_path.parent, mp4_paths)
conversion_failed = (
len(mp4_paths) != len(session.android_segment_records)
or any(result is not True for result in conversion_results)
or manifest_path is None
)
if conversion_failed:
# Emergency fallback: if no segment MP4 was produced, attempt to remux local_video_path directly
if not mp4_paths and output_path and output_path.exists() and output_path.stat().st_size > 0:
fallback_mp4 = (
output_path.parent / "recording.mp4"
if output_path.name != "recording.mp4"
else output_path.with_name("recording_converted.mp4")
)
if await remux_recording_to_mp4(output_path, fallback_mp4):
mp4_paths.append(fallback_mp4)
if not mp4_paths:
message = "Recording finalization failed; no complete browser-safe video was produced"
self._record_recording_failure(session, message)
remove_active_session(device_id)
return VideoRecordingResult(success=False, message=message)
remove_active_session(device_id)
final_video_path = mp4_paths[0]
output_dir = final_video_path.parent
manifest_path = await write_recording_manifest(output_dir, mp4_paths)
remove_active_session(device_id)
# Persist update to local database if Data Engine is active
if self.ctx and self.ctx.data_engine:
@@ -764,12 +780,12 @@ class UnifiedMobileController:
return VideoRecordingResult(
success=True,
message=f"Recording stopped, saved {len(mp4_paths)} orientation-safe segments",
video_path=mp4_paths[0],
message=f"Recording stopped, saved {len(mp4_paths)} video segments",
video_path=final_video_path,
video_id=session.video_id,
generation=session.generation,
sealed_until=session.sealed_until,
source_revision=f"{session.video_id}:{session.generation}:sealed",
source_revision=f"{session.video_id}:{session.generation}:ready",
)
except Exception as e:
+15
View File
@@ -29,6 +29,7 @@ from artemis.sdk.types.task import AgentProfile
from artemis.utils.cli_helpers import display_device_status
from artemis.utils.logger import get_logger
from artemis.utils.video import check_ffmpeg_available
import signal
from rich.console import Console
from rich.panel import Panel
import typer
@@ -292,6 +293,15 @@ def run_command(
display_device_status(console, adb_client=adb_client)
cancelled = False
original_sigterm = None
try:
if hasattr(signal, "SIGTERM"):
original_sigterm = signal.signal(
signal.SIGTERM, lambda s, f: (_ for _ in ()).throw(KeyboardInterrupt())
)
except Exception:
pass
try:
asyncio.run(
execute_task(
@@ -335,5 +345,10 @@ def run_command(
console.print(f"[bold red]Task execution failed:[/bold red] {e}")
raise
finally:
try:
if original_sigterm is not None and hasattr(signal, "SIGTERM"):
signal.signal(signal.SIGTERM, original_sigterm)
except Exception:
pass
if cancelled:
raise SystemExit(130)
+42 -6
View File
@@ -19,6 +19,7 @@ from pathlib import Path
import shutil
import signal
import tempfile
import time
from artemis.platform.base import IPlatform, IPlatformPaths, IPlatformProcess, OSType
@@ -98,12 +99,47 @@ class DarwinPlatformProcess(IPlatformProcess):
pass
parent.send_signal(signal.SIGTERM)
gone, alive = psutil.wait_procs(children + [parent], timeout=timeout_seconds)
for p in alive:
try:
p.kill()
except (psutil.NoSuchProcess, psutil.AccessDenied):
pass
# Avoid calling wait_procs on direct children of the current process.
# In POSIX, psutil.Process.wait() on a child of os.getpid() invokes
# os.waitpid(), which reaps the zombie and prevents asyncio / uvloop
# event loops from detecting that the child exited, causing proc.wait()
# to hang indefinitely.
current_pid = os.getpid()
external_procs = [
p
for p in children + [parent]
if p.pid != current_pid and getattr(p, "ppid", lambda: None)() != current_pid
]
direct_children = [
p
for p in children + [parent]
if getattr(p, "ppid", lambda: None)() == current_pid
]
if external_procs:
gone, alive = psutil.wait_procs(external_procs, timeout=timeout_seconds)
for p in alive:
try:
p.kill()
except (psutil.NoSuchProcess, psutil.AccessDenied):
pass
deadline = time.time() + timeout_seconds
for p in direct_children:
while time.time() < deadline:
try:
if not p.is_running() or p.status() == psutil.STATUS_ZOMBIE:
break
except (psutil.NoSuchProcess, psutil.AccessDenied):
break
time.sleep(0.05)
else:
try:
p.kill()
except (psutil.NoSuchProcess, psutil.AccessDenied):
pass
return True
except Exception:
try:
+42 -6
View File
@@ -19,6 +19,7 @@ from pathlib import Path
import shutil
import signal
import tempfile
import time
from artemis.platform.base import IPlatform, IPlatformPaths, IPlatformProcess, OSType
@@ -113,12 +114,47 @@ class LinuxPlatformProcess(IPlatformProcess):
pass
parent.send_signal(signal.SIGTERM)
gone, alive = psutil.wait_procs(children + [parent], timeout=timeout_seconds)
for p in alive:
try:
p.kill()
except (psutil.NoSuchProcess, psutil.AccessDenied):
pass
# Avoid calling wait_procs on direct children of the current process.
# In POSIX, psutil.Process.wait() on a child of os.getpid() invokes
# os.waitpid(), which reaps the zombie and prevents asyncio / uvloop
# event loops from detecting that the child exited, causing proc.wait()
# to hang indefinitely.
current_pid = os.getpid()
external_procs = [
p
for p in children + [parent]
if p.pid != current_pid and getattr(p, "ppid", lambda: None)() != current_pid
]
direct_children = [
p
for p in children + [parent]
if getattr(p, "ppid", lambda: None)() == current_pid
]
if external_procs:
gone, alive = psutil.wait_procs(external_procs, timeout=timeout_seconds)
for p in alive:
try:
p.kill()
except (psutil.NoSuchProcess, psutil.AccessDenied):
pass
deadline = time.time() + timeout_seconds
for p in direct_children:
while time.time() < deadline:
try:
if not p.is_running() or p.status() == psutil.STATUS_ZOMBIE:
break
except (psutil.NoSuchProcess, psutil.AccessDenied):
break
time.sleep(0.05)
else:
try:
p.kill()
except (psutil.NoSuchProcess, psutil.AccessDenied):
pass
return True
except Exception:
try:
+27 -16
View File
@@ -645,23 +645,34 @@ class Agent:
return output
finally:
if recording_started:
try:
logger.info(f"[{task_name}] Stopping automated screen recording...")
async def _safe_stop_recording():
try:
logger.info(f"[{task_name}] Stopping automated screen recording...")
controller = get_controller(context)
return await controller.stop_video_recording()
except Exception as e:
logger.error(f"[{task_name}] Error stopping screen recording: {e}")
return None
controller = get_controller(context)
stop_result = await controller.stop_video_recording()
if stop_result.success and stop_result.video_path:
logger.info(
f"[{task_name}] Screen recording saved"
f" to: {stop_result.video_path}"
)
else:
logger.warning(
f"[{task_name}] Failed to save screen"
f" recording: {stop_result.message}"
)
except Exception as e:
logger.error(f"[{task_name}] Error stopping screen recording: {e}")
stop_task = asyncio.create_task(_safe_stop_recording())
try:
stop_result = await asyncio.shield(stop_task)
except (asyncio.CancelledError, BaseException):
try:
stop_result = await asyncio.wait_for(stop_task, timeout=15.0)
except Exception:
stop_result = None
if stop_result and stop_result.success and stop_result.video_path:
logger.info(
f"[{task_name}] Screen recording saved"
f" to: {stop_result.video_path}"
)
elif stop_result:
logger.warning(
f"[{task_name}] Failed to save screen"
f" recording: {stop_result.message}"
)
except asyncio.CancelledError:
err = f"[{task_name}] Task cancelled"
logger.warning(err)
+46 -9
View File
@@ -297,6 +297,36 @@ async def remux_recording_to_mp4(source_path: Path, output_path: Path) -> bool:
if valid:
temporary_path.replace(output_path)
return True
logger.warning(
f"Direct remux failed for {source_path} (code {process.returncode}), "
"attempting transcode fallback with ffmpeg..."
)
# Fallback to ultrafast re-encoding in case MKV container was truncated or has timestamp irregularities
fallback_proc = await asyncio.create_subprocess_exec(
get_ffmpeg_path(),
"-y",
"-fflags",
"+genpts+discardcorrupt",
"-i",
str(source_path),
"-c:v",
"libx264",
"-preset",
"ultrafast",
"-pix_fmt",
"yuv420p",
"-movflags",
"+faststart",
str(temporary_path),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
_fout, _ferr = await fallback_proc.communicate()
if fallback_proc.returncode == 0 and temporary_path.exists() and temporary_path.stat().st_size > 0:
temporary_path.replace(output_path)
return True
logger.error(
f"Failed to remux recording segment (code {process.returncode}): "
f"{stderr.decode(errors='replace')[-2000:]}"
@@ -314,10 +344,8 @@ async def probe_video_segment(video_path: Path) -> dict[str, float | int]:
get_ffprobe_path(),
"-v",
"error",
"-select_streams",
"v:0",
"-show_entries",
"stream=width,height:format=duration",
"stream=width,height,duration,codec_type:format=duration",
"-of",
"json",
str(video_path),
@@ -329,11 +357,20 @@ async def probe_video_segment(video_path: Path) -> dict[str, float | int]:
return {}
try:
payload = json.loads(stdout)
stream = (payload.get("streams") or [{}])[0]
streams = payload.get("streams") or []
video_stream = next(
(s for s in streams if s.get("width") and s.get("height")),
next((s for s in streams if s.get("codec_type") == "video"), streams[0] if streams else {})
)
duration = float(
(payload.get("format") or {}).get("duration")
or video_stream.get("duration")
or 0
)
return {
"duration": float((payload.get("format") or {}).get("duration") or 0),
"width": int(stream.get("width") or 0),
"height": int(stream.get("height") or 0),
"duration": duration,
"width": int(video_stream.get("width") or 0),
"height": int(video_stream.get("height") or 0),
}
except (TypeError, ValueError, json.JSONDecodeError):
return {}
@@ -380,8 +417,8 @@ async def write_recording_manifest(output_dir: Path, mp4_paths: list[Path]) -> P
width = int(metadata.get("width", 0))
height = int(metadata.get("height", 0))
if duration <= 0 or width <= 0 or height <= 0:
logger.error(f"Recording segment failed validation: {path}")
return None
logger.warning(f"Recording segment skipped due to invalid metadata: {path}")
continue
segments.append(
{
"file": path.name,