|
|
@@ -1,5 +1,7 @@
|
|
|
from __future__ import annotations
|
|
|
|
|
|
+import os
|
|
|
+import socket
|
|
|
from datetime import date, datetime
|
|
|
from pathlib import Path
|
|
|
from typing import Any
|
|
|
@@ -76,7 +78,7 @@ class DashboardService:
|
|
|
page_size=page_size,
|
|
|
)
|
|
|
items = [
|
|
|
- self._local_run_list_item(run_id)
|
|
|
+ self._apply_liveness(self._local_run_list_item(run_id))
|
|
|
for run_id in getattr(self.runtime, "list_runs", lambda: [])()
|
|
|
]
|
|
|
items = [
|
|
|
@@ -382,7 +384,7 @@ class DashboardService:
|
|
|
tuple([*params, page_size, (page - 1) * page_size]),
|
|
|
)
|
|
|
return {
|
|
|
- "items": [_json_safe(_db_run_row_to_item(row)) for row in rows],
|
|
|
+ "items": [self._apply_liveness(_json_safe(_db_run_row_to_item(row))) for row in rows],
|
|
|
"page": page,
|
|
|
"page_size": page_size,
|
|
|
"total": int((count_row or {}).get("cnt") or 0),
|
|
|
@@ -399,7 +401,29 @@ class DashboardService:
|
|
|
"FROM `content_agent_runs` r WHERE r.run_id = %s LIMIT 1",
|
|
|
(run_id,),
|
|
|
)
|
|
|
- return _json_safe(_db_run_row_to_item(row or {"run_id": run_id, "status": "unknown"}))
|
|
|
+ return self._apply_liveness(_json_safe(_db_run_row_to_item(row or {"run_id": run_id, "status": "unknown"})))
|
|
|
+
|
|
|
+ def _apply_liveness(self, item: dict[str, Any]) -> dict[str, Any]:
|
|
|
+ # status=running 但执行进程已死(硬杀/OOM/重启,来不及写终态)→ 显示「已中断」。无时间阈值。
|
|
|
+ if item.get("status") != "running":
|
|
|
+ return item
|
|
|
+ events = self._read_jsonl_optional(item.get("run_id") or "", "run_events.jsonl") or []
|
|
|
+ executor = next(
|
|
|
+ (
|
|
|
+ row["raw_payload"]["executor"]
|
|
|
+ for row in events
|
|
|
+ if isinstance(row.get("raw_payload"), dict)
|
|
|
+ and isinstance(row["raw_payload"].get("executor"), dict)
|
|
|
+ ),
|
|
|
+ None,
|
|
|
+ )
|
|
|
+ if not executor:
|
|
|
+ return item # 旧 run 没记 executor,无法判定,保持原状
|
|
|
+ if executor.get("host") and executor.get("host") != socket.gethostname():
|
|
|
+ return item # 跨机,本地查不了进程
|
|
|
+ if not _process_alive(executor.get("pid"), executor.get("proc_start")):
|
|
|
+ return {**item, "status": "interrupted", "current_step": item.get("current_step") or "unknown"}
|
|
|
+ return item
|
|
|
|
|
|
def _local_run_list_item(self, run_id: str) -> dict[str, Any]:
|
|
|
final_output = self._read_json_optional(run_id, "final_output.json") or {}
|
|
|
@@ -553,6 +577,36 @@ def _db_run_filters(filters: dict[str, Any]) -> tuple[str, list[Any]]:
|
|
|
return "WHERE " + " AND ".join(clauses), params
|
|
|
|
|
|
|
|
|
+def _proc_start_time(pid: int) -> str | None:
|
|
|
+ try:
|
|
|
+ with open(f"/proc/{pid}/stat", encoding="utf-8") as handle:
|
|
|
+ content = handle.read()
|
|
|
+ return content[content.rindex(")") + 2:].split()[19]
|
|
|
+ except Exception:
|
|
|
+ return None
|
|
|
+
|
|
|
+
|
|
|
+def _process_alive(pid: Any, expected_start: Any) -> bool:
|
|
|
+ # True = 活着 / 无法判定(绝不误杀真实运行中的 run);False = 进程确实没了(或 PID 被复用)。
|
|
|
+ try:
|
|
|
+ pid_int = int(pid)
|
|
|
+ except (TypeError, ValueError):
|
|
|
+ return True
|
|
|
+ try:
|
|
|
+ os.kill(pid_int, 0)
|
|
|
+ except ProcessLookupError:
|
|
|
+ return False
|
|
|
+ except PermissionError:
|
|
|
+ return True # 进程在,只是不归当前用户
|
|
|
+ except Exception:
|
|
|
+ return True
|
|
|
+ if expected_start:
|
|
|
+ actual = _proc_start_time(pid_int)
|
|
|
+ if actual is not None and actual != str(expected_start):
|
|
|
+ return False # PID 被复用成了别的进程
|
|
|
+ return True
|
|
|
+
|
|
|
+
|
|
|
def _db_run_row_to_item(row: dict[str, Any]) -> dict[str, Any]:
|
|
|
return {
|
|
|
"run_id": row.get("run_id"),
|