fix(runtime): resolve liveness writeback and probe issues

This commit is contained in:
somew1nd committed 2026-09-01 21:21:20 -07:00
1 parent f24a6fd441
commit 9cad5b690f
6 files changed
+102 -35

No files matched your search

@@ -332,6 +332,9 @@ class SessionRepository:
conn.commit() conn.commit()
return count return count
except Exception: except Exception:
# Reconciliation is best-effort at startup, but a silent abort
# would leave every orphaned "running" row untouched -- log it.
logger.warning("Orphan-session reconciliation aborted", exc_info=True)
return 0 return 0
def cleanup_orphans_on_startup(self) -> int: def cleanup_orphans_on_startup(self) -> int:
+9 -4
View File
@@ -156,7 +156,13 @@ class MediaService:
if not d.exists(): if not d.exists():
continue continue
try: try:
for item in d.iterdir(): entries = list(d.iterdir())
except OSError as exc:
logger.warning("Video index scan failed under %s: %s", d, exc)
continue
for item in entries:
# One unreadable entry must not abort the rest of the directory.
try:
if item.is_dir(): if item.is_dir():
for ext in [".mp4", ".mkv", ".webm"]: for ext in [".mp4", ".mkv", ".webm"]:
vfile = item / f"recording{ext}" vfile = item / f"recording{ext}"
@@ -172,9 +178,8 @@ class MediaService:
url = cls.path_to_video_url(item) url = cls.path_to_video_url(item)
idx[item.stem] = url idx[item.stem] = url
idx[item.name] = url idx[item.name] = url
except OSError as exc: except OSError as exc:
# Unreadable directory entry: index the rest of the tree. logger.warning("Skipping unindexable video entry %s: %s", item, exc)
logger.warning("Video index scan failed under %s: %s", d, exc)
return idx return idx
@classmethod @classmethod
@@ -588,26 +588,46 @@ class TaskQueueService:
manual_stop, manual_stop,
) )
if should_persist: if should_persist:
session_repo.update_session_status(sess_id, new_status, time.time()) if session_repo.update_session_status(sess_id, new_status, time.time()):
print(f"[QueueWorker] Updated session {sess_id} status to '{new_status}'") print(f"[QueueWorker] Updated session {sess_id} status to '{new_status}'")
else:
# The DB row is the fallback MCP pollers reconcile
# against when status.json is stale, so a failed DB
# write must not pass silently.
logger.error(
"[QueueWorker] Could not persist terminal DB status "
"'%s' for session %s",
new_status,
sess_id,
)
else: else:
print( print(
f"[QueueWorker] Preserved authoritative session {sess_id} " f"[QueueWorker] Preserved authoritative session {sess_id} "
f"status '{new_status}'" f"status '{new_status}'"
) )
try: # A stale status.json would leave MCP pollers seeing "running"
if trace_store.read_status(str(sess_id)): # until their next DB reconcile, so retry transient write
canonical_mcp_status = "completed" if new_status in ("completed", "success") else new_status # failures before giving up.
trace_store.update_trace_status( for attempt in range(3):
str(sess_id), try:
canonical_mcp_status, 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(
str(sess_id),
canonical_mcp_status,
)
break
except OSError as exc:
if attempt < 2:
await asyncio.sleep(0.5 * (attempt + 1))
continue
logger.error(
"[QueueWorker] Could not persist terminal MCP status "
"for %s after %d attempts: %s",
sess_id,
attempt + 1,
exc,
) )
except OSError as exc:
# A stale status.json would leave MCP pollers seeing
# "running" forever, so surface the failure.
print(
f"[QueueWorker] Could not persist terminal MCP status for {sess_id}: {exc}"
)
rec_info = session_repo.get_video_recording_for_session(sess_id) rec_info = session_repo.get_video_recording_for_session(sess_id)
rec_status = (rec_info or {}).get("status") rec_status = (rec_info or {}).get("status")
recovered_video_path = None recovered_video_path = None
+2 -1
View File
@@ -172,7 +172,8 @@ class AgentApiNotifier(BaseNotifier):
env["ANTIGRAVITY_CSRF_TOKEN"], env["ANTIGRAVITY_CSRF_TOKEN"],
) )
) )
except (psutil.AccessDenied, psutil.NoSuchProcess, Exception): except psutil.Error:
# This process vanished or denies access: scan the rest.
continue continue
except ImportError: except ImportError:
import glob import glob
+18 -7
View File
@@ -58,23 +58,31 @@ def _pid_alive(pid: int | None) -> bool:
return pid_is_alive(pid_int) return pid_is_alive(pid_int)
def _session_tracked_by_lock(trace_id: str) -> bool: def _session_tracked_by_lock(trace_id: str) -> bool | None:
"""True when the device-lock layer still tracks this session (queued or active owner).""" """Whether the device-lock layer still tracks this session (queued or active owner).
Returns ``True`` when tracked, ``False`` when both probes succeeded and the
session is definitively absent, and ``None`` when a probe failed — an
unreadable queue or lock dir may hide a live session, so the caller must
not treat a failed probe as evidence of death (same "uncertain defaults to
alive" stance as ``pid_is_alive``).
"""
uncertain = False
try: try:
for q_item in DeviceExecutionLock.get_queued_tasks(): for q_item in DeviceExecutionLock.get_queued_tasks():
if str(q_item.get("session_id")) == trace_id: if str(q_item.get("session_id")) == trace_id:
return True return True
except Exception as exc: except Exception as exc:
# An unreadable queue may hide a live session; the caller then leans uncertain = True
# on DB/pid evidence, so surface the probe failure.
print(f"Could not read device queue for {trace_id}: {exc}", file=sys.stderr) print(f"Could not read device queue for {trace_id}: {exc}", file=sys.stderr)
try: try:
for owner in DeviceExecutionLock.get_active_owners().values(): for owner in DeviceExecutionLock.get_active_owners().values():
if owner.session_id and str(owner.session_id) == trace_id: if owner.session_id and str(owner.session_id) == trace_id:
return True return True
except Exception as exc: except Exception as exc:
uncertain = True
print(f"Could not read device owners for {trace_id}: {exc}", file=sys.stderr) print(f"Could not read device owners for {trace_id}: {exc}", file=sys.stderr)
return False return None if uncertain else False
def _reconcile_task_state( def _reconcile_task_state(
@@ -154,8 +162,11 @@ def _reconcile_task_state(
else: else:
is_alive = _pid_alive(pid) is_alive = _pid_alive(pid)
if not is_alive: if not is_alive:
if _session_tracked_by_lock(trace_id): tracked = _session_tracked_by_lock(trace_id)
# Still queued for a device or owned by a live lock holder. if tracked or tracked is None:
# Still queued for a device or owned by a live lock holder --
# or the lock probe failed, in which case the session may be
# hidden and must not be declared dead on this poll.
is_alive = True is_alive = True
elif db_status in ("running", "pending") and db_pid: elif db_status in ("running", "pending") and db_pid:
# The runner registered itself and its process is gone: truly dead. # The runner registered itself and its process is gone: truly dead.
+36 -9
View File
@@ -302,9 +302,24 @@ def mobile_run_task(
"stderr_log": stderr_log, "stderr_log": stderr_log,
} }
# Check if the task was already enqueued despite a slow or None response: # Check if the task was already enqueued despite a slow or None
try: # response. The probe reads a shared lock file, so transient
queued_tasks = DeviceExecutionLock.get_queued_tasks() # read failures get a few retries; if it never succeeds the
# verdict is "unknown" -- reporting "failed" here for a task
# that did land in the queue would invite a duplicate submit.
probe_failed = False
for probe_attempt in range(3):
try:
queued_tasks = DeviceExecutionLock.get_queued_tasks()
except Exception as exc:
if probe_attempt < 2:
time.sleep(0.5)
continue
probe_failed = True
logging.getLogger("mcp_server").warning(
f"Could not verify daemon queue for '{trace_id}': {exc}"
)
break
for q in queued_tasks: for q in queued_tasks:
if str(q.get("session_id")) == str(trace_id): if str(q.get("session_id")) == str(trace_id):
trace_store.update_trace_status( trace_store.update_trace_status(
@@ -323,12 +338,24 @@ def mobile_run_task(
"stdout_log": str(trace_store.get_trace_stdout_log_path(trace_id)), "stdout_log": str(trace_store.get_trace_stdout_log_path(trace_id)),
"stderr_log": str(trace_store.get_trace_stderr_log_path(trace_id)), "stderr_log": str(trace_store.get_trace_stderr_log_path(trace_id)),
} }
except Exception as exc: break
# The queue probe is a rescue path for a slow daemon reply;
# its failure downgrades the verdict to "failed" below. if probe_failed:
logging.getLogger("mcp_server").warning( # The task may or may not have been enqueued; do not mark
f"Could not verify daemon queue for '{trace_id}': {exc}" # the trace failed and do not let the caller resubmit
) # blindly.
return {
"trace_id": trace_id,
"status": "unknown",
"message": (
f"Artemis Daemon gave no enqueue confirmation for task "
f"'{task_desc}' and the queue could not be inspected. The "
f"task may still have been enqueued. Poll "
f"mobile_manage_task(action='status', trace_id='{trace_id}') "
f"before resubmitting; if it still reports 'pending' after "
f"about a minute, treat it as not enqueued."
),
}
# When Daemon is running, refuse to launch a conflicting standalone runner on the same device # When Daemon is running, refuse to launch a conflicting standalone runner on the same device
logging.getLogger("mcp_server").warning( logging.getLogger("mcp_server").warning(