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:
1 parent
06643e955b
commit
a36a66ce6d
26 files changed
+1152
-117
No files matched your search
@@ -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 }}**
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user