MicroFish/backend/app/services/simulation_runner.py

1922 lines
83 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Trình chạy và giám sát Simulation OASIS
Vị trí trong pipeline:
─────────────────────────────────────────────────────────────────────────────
simulation_manager.py → SimulationRunner.start_simulation()
└─ subprocess.Popen(run_parallel_simulation.py --config ...)
│ (OASIS chạy ngầm, ghi log ra actions.jsonl)
└─ Thread(_monitor_simulation) [daemon thread, chạy song song với Flask]
└─ _read_action_log() [đọc actions.jsonl mỗi 2 giây]
└─ _save_run_state() [cập nhật run_state.json liên tục]
─────────────────────────────────────────────────────────────────────────────
Hai file state riêng biệt (KHÔNG phải một):
state.json — lifecycle state, quản lý bởi SimulationManager
(CREATED → PREPARING → READY → RUNNING → COMPLETED/FAILED)
run_state.json — runtime progress, quản lý bởi SimulationRunner
(round hiện tại, action count, PID, ...)
Cập nhật mỗi 2 giây trong khi OASIS đang chạy.
Giao tiếp Flask ↔ OASIS subprocess:
File-based: Flask đọc actions.jsonl để biết tiến độ
IPC: Flask ghi lệnh vào ipc_commands/, OASIS trả lời vào ipc_responses/
(dùng cho tính năng Interview agent đang chạy)
"""
import os
import sys
import json
import time
import asyncio
import threading
import subprocess
import signal
import atexit
from typing import Dict, Any, List, Optional, Union
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
from queue import Queue
from ..config import Config
from ..utils.logger import get_logger
from .zep_graph_memory_updater import ZepGraphMemoryManager
from .simulation_ipc import SimulationIPCClient, CommandType, IPCResponse
logger = get_logger('mirofish.simulation_runner')
# Cờ đánh dấu đã đăng ký hàm dọn dẹp (atexit) hay chưa — tránh đăng ký nhiều lần
_cleanup_registered = False
# Kiểm tra hệ điều hành — dùng để chọn cách kill process (killpg vs taskkill)
IS_WINDOWS = sys.platform == 'win32'
# ==============================================================================
# ENUM: RunnerStatus — Trạng thái của bộ chạy tiến trình (khác với SimulationStatus)
# ==============================================================================
# RunnerStatus theo dõi trạng thái của OASIS subprocess, không phải lifecycle tổng thể.
# Được lưu trong run_state.json — SimulationManager đọc file này để cập nhật state.json.
#
# Luồng trạng thái:
# IDLE → STARTING → RUNNING → COMPLETED
# └──────────→ FAILED (subprocess crash)
# RUNNING → STOPPING → STOPPED (người dùng stop)
# ==============================================================================
class RunnerStatus(str, Enum):
"""Trạng thái của bộ chạy tiến trình mô phỏng"""
IDLE = "idle" # Rảnh rỗi, chưa chạy
STARTING = "starting" # Đang khởi động subprocess
RUNNING = "running" # Subprocess đang chạy, agents đang hành động
PAUSED = "paused" # Đã tạm dừng (tính năng tương lai)
STOPPING = "stopping" # Đang gửi SIGTERM / taskkill
STOPPED = "stopped" # Đã dừng theo yêu cầu người dùng
COMPLETED = "completed" # OASIS chạy hết số vòng và thoát thành công (exit_code=0)
FAILED = "failed" # Subprocess crash (exit_code != 0) hoặc exception không xử lý được
# ==============================================================================
# DATACLASS: AgentAction — Bản ghi một hành động đơn lẻ từ actions.jsonl
# ==============================================================================
# Mỗi dòng trong actions.jsonl tương ứng với 1 AgentAction.
# _read_action_log() parse từng dòng JSON thành AgentAction và lưu vào SimulationRunState.
#
# Ví dụ 1 dòng trong twitter/actions.jsonl:
# {"round": 15, "agent_id": 3, "agent_name": "tran_van_an_492",
# "action_type": "CREATE_POST", "action_args": {"content": "..."}, "success": true}
# ==============================================================================
@dataclass
class AgentAction:
"""Bản ghi hành động của Agent"""
round_num: int # Số thứ tự vòng (round) mô phỏng
timestamp: str # Dấu thời gian
platform: str # Nền tảng: "twitter" hoặc "reddit"
agent_id: int # ID của agent (khớp với user_id trong profile file)
agent_name: str # Tên tài khoản (ví dụ: "tran_van_an_492")
action_type: str # Loại hành động: CREATE_POST, LIKE_POST, REPOST, FOLLOW, DO_NOTHING...
action_args: Dict[str, Any] = field(default_factory=dict) # Tham số của hành động
result: Optional[str] = None # Kết quả thực thi (ví dụ: "Post created with id 142")
success: bool = True # Hành động có thành công hay không
def to_dict(self) -> Dict[str, Any]:
return {
"round_num": self.round_num,
"timestamp": self.timestamp,
"platform": self.platform,
"agent_id": self.agent_id,
"agent_name": self.agent_name,
"action_type": self.action_type,
"action_args": self.action_args,
"result": self.result,
"success": self.success,
}
# ==============================================================================
# DATACLASS: RoundSummary — Tóm tắt một vòng (round) mô phỏng
# ==============================================================================
# Được tổng hợp từ các AgentAction trong cùng round_num.
# Lưu vào SimulationRunState.rounds để hiển thị timeline trên frontend.
# ==============================================================================
@dataclass
class RoundSummary:
"""Tóm tắt thông tin của mỗi vòng (round)"""
round_num: int # Số thứ tự vòng
start_time: str # Thời gian bắt đầu vòng
end_time: Optional[str] = None # Thời gian kết thúc vòng
simulated_hour: int = 0 # Giờ mô phỏng tương ứng (071 nếu total_hours=72)
twitter_actions: int = 0 # Số hành động trên Twitter trong vòng này
reddit_actions: int = 0 # Số hành động trên Reddit trong vòng này
active_agents: List[int] = field(default_factory=list) # Danh sách agent_id đã hành động
actions: List[AgentAction] = field(default_factory=list) # Chi tiết các hành động
def to_dict(self) -> Dict[str, Any]:
return {
"round_num": self.round_num,
"start_time": self.start_time,
"end_time": self.end_time,
"simulated_hour": self.simulated_hour,
"twitter_actions": self.twitter_actions,
"reddit_actions": self.reddit_actions,
"active_agents": self.active_agents,
"actions_count": len(self.actions),
"actions": [a.to_dict() for a in self.actions],
}
# ==============================================================================
# DATACLASS: SimulationRunState — Trạng thái runtime của OASIS subprocess
# ==============================================================================
# Đây là object lưu trạng thái "đang chạy" — được cập nhật mỗi 2 giây bởi monitor thread.
# Được ghi ra run_state.json để Frontend có thể poll API và hiển thị tiến độ.
#
# Phân biệt với SimulationState (state.json):
# SimulationState = lifecycle tổng thể (CREATED→READY→RUNNING→COMPLETED)
# SimulationRunState = tiến độ chi tiết (round hiện tại, action count, PID, ...)
#
# recent_actions: chỉ giữ 50 hành động gần nhất (max_recent_actions)
# → Không lưu toàn bộ actions vào RAM để tránh tốn bộ nhớ với simulation dài.
# ==============================================================================
@dataclass
class SimulationRunState:
"""Trạng thái đang thực thi của tiến trình mô phỏng (cập nhật theo thời gian thực)"""
simulation_id: str
runner_status: RunnerStatus = RunnerStatus.IDLE
# --- Tiến độ chung ---
current_round: int = 0 # Vòng hiện tại (số lớn nhất của 2 platform)
total_rounds: int = 0 # Tổng số vòng cần chạy
simulated_hours: int = 0 # Số giờ đã mô phỏng
total_simulation_hours: int = 0 # Tổng số giờ cần mô phỏng
# --- Tiến độ riêng từng platform ---
# Hai platform chạy song song asyncio → hoàn thành không đồng bộ
# Frontend hiển thị 2 progress bar riêng dựa trên các field này
twitter_current_round: int = 0
reddit_current_round: int = 0
twitter_simulated_hours: int = 0
reddit_simulated_hours: int = 0
# --- Trạng thái nền tảng ---
twitter_running: bool = False # True khi OASIS Twitter đang chạy
reddit_running: bool = False # True khi OASIS Reddit đang chạy
twitter_actions_count: int = 0 # Tổng hành động Twitter đã ghi được
reddit_actions_count: int = 0 # Tổng hành động Reddit đã ghi được
# Phát hiện qua event_type="simulation_end" trong actions.jsonl
# (KHÔNG dựa vào exit_code của subprocess — vì exit_code chỉ biết khi process kết thúc)
twitter_completed: bool = False
reddit_completed: bool = False
# --- Lịch sử vòng ---
rounds: List[RoundSummary] = field(default_factory=list)
# --- Hành động gần nhất (tối đa 50) ---
# insert(0, ...) → luôn giữ mới nhất ở đầu list
recent_actions: List[AgentAction] = field(default_factory=list)
max_recent_actions: int = 50
# --- Timestamps ---
started_at: Optional[str] = None
updated_at: str = field(default_factory=lambda: datetime.now().isoformat())
completed_at: Optional[str] = None
# --- Lỗi ---
error: Optional[str] = None
# --- Process ID ---
# Lưu lại để có thể kill process khi cần dừng
process_pid: Optional[int] = None
def add_action(self, action: AgentAction):
"""Thêm hành động vào đầu recent_actions, giữ tối đa max_recent_actions."""
self.recent_actions.insert(0, action)
if len(self.recent_actions) > self.max_recent_actions:
self.recent_actions = self.recent_actions[:self.max_recent_actions]
if action.platform == "twitter":
self.twitter_actions_count += 1
else:
self.reddit_actions_count += 1
self.updated_at = datetime.now().isoformat()
def to_dict(self) -> Dict[str, Any]:
"""Serialize ra dict gọn — dùng cho API response và ghi run_state.json."""
return {
"simulation_id": self.simulation_id,
"runner_status": self.runner_status.value,
"current_round": self.current_round,
"total_rounds": self.total_rounds,
"simulated_hours": self.simulated_hours,
"total_simulation_hours": self.total_simulation_hours,
# Phần trăm hoàn thành: tránh ZeroDivisionError khi total_rounds=0
"progress_percent": round(self.current_round / max(self.total_rounds, 1) * 100, 1),
"twitter_current_round": self.twitter_current_round,
"reddit_current_round": self.reddit_current_round,
"twitter_simulated_hours": self.twitter_simulated_hours,
"reddit_simulated_hours": self.reddit_simulated_hours,
"twitter_running": self.twitter_running,
"reddit_running": self.reddit_running,
"twitter_completed": self.twitter_completed,
"reddit_completed": self.reddit_completed,
"twitter_actions_count": self.twitter_actions_count,
"reddit_actions_count": self.reddit_actions_count,
"total_actions_count": self.twitter_actions_count + self.reddit_actions_count,
"started_at": self.started_at,
"updated_at": self.updated_at,
"completed_at": self.completed_at,
"error": self.error,
"process_pid": self.process_pid,
}
def to_detail_dict(self) -> Dict[str, Any]:
"""Serialize đầy đủ bao gồm recent_actions — dùng để ghi run_state.json."""
result = self.to_dict()
result["recent_actions"] = [a.to_dict() for a in self.recent_actions]
result["rounds_count"] = len(self.rounds)
return result
# ==============================================================================
# CLASS: SimulationRunner — Trình chạy và giám sát OASIS subprocess
# ==============================================================================
# SimulationRunner là class-only (tất cả methods đều là @classmethod) — dùng như singleton.
# Không cần khởi tạo instance, gọi trực tiếp: SimulationRunner.start_simulation(...)
#
# Tại sao class-level dict thay vì instance dict?
# Flask chạy nhiều request đồng thời → nhiều thread cùng truy cập runner state.
# Class-level dict được chia sẻ giữa tất cả thread trong cùng Flask process.
# → Không cần singleton pattern phức tạp, class dict là đủ.
#
# _processes: simulation_id → subprocess.Popen (để kill khi cần)
# _run_states: simulation_id → SimulationRunState (RAM cache của run_state.json)
# _monitor_threads: simulation_id → Thread (daemon thread đọc actions.jsonl)
# _stdout_files: simulation_id → file handle (để đóng khi process kết thúc)
# ==============================================================================
class SimulationRunner:
"""
Trình chạy mô phỏng
Quy trách nhiệm:
1. Chạy mô phỏng OASIS trong tiến trình nền (background process)
2. Phân tích nhật ký chạy (log), ghi lại hành động của mỗi Agent
3. Cung cấp API truy vấn trạng thái thời gian thực
4. Hỗ trợ thao tác tạm dừng (pause)/dừng (stop)/tiếp tục (resume)
"""
# Thư mục lưu trữ trạng thái chạy (mỗi simulation có subfolder riêng)
RUN_STATE_DIR = os.path.join(
os.path.dirname(__file__),
'../../uploads/simulations'
)
# Thư mục chứa các OASIS script (run_parallel_simulation.py, ...)
# Scripts không được copy vào thư mục simulation — gọi thẳng từ đây
SCRIPTS_DIR = os.path.join(
os.path.dirname(__file__),
'../../scripts'
)
# --- Class-level state (chia sẻ giữa tất cả request threads) ---
_run_states: Dict[str, SimulationRunState] = {}
_processes: Dict[str, subprocess.Popen] = {}
_action_queues: Dict[str, Queue] = {}
_monitor_threads: Dict[str, threading.Thread] = {}
_stdout_files: Dict[str, Any] = {} # Lưu file handle để đóng khi process kết thúc
_stderr_files: Dict[str, Any] = {} # Không dùng riêng (stderr merge vào stdout)
# Cấu hình Graph Memory Update (tính năng tùy chọn: ghi hành động agent vào Zep)
_graph_memory_enabled: Dict[str, bool] = {} # simulation_id → True/False
@classmethod
def get_run_state(cls, simulation_id: str) -> Optional[SimulationRunState]:
"""
Lấy trạng thái runtime hiện tại — cache-aside (RAM trước, disk fallback).
Trả về None nếu simulation chưa được start.
"""
if simulation_id in cls._run_states:
return cls._run_states[simulation_id]
# Không có trong RAM → thử load từ run_state.json
state = cls._load_run_state(simulation_id)
if state:
cls._run_states[simulation_id] = state
return state
@classmethod
def _load_run_state(cls, simulation_id: str) -> Optional[SimulationRunState]:
"""
Load SimulationRunState từ run_state.json trên disk.
Được gọi khi:
1. Server restart → RAM cache trống → cần load lại từ disk
2. Lần đầu gọi get_run_state() sau khi start_simulation()
Lưu ý: nếu run_state.json có status="running" sau server restart,
đó là "false positive" — process đã chết cùng server. Phải gọi
cleanup_simulation_logs() trước khi start lại.
"""
state_file = os.path.join(cls.RUN_STATE_DIR, simulation_id, "run_state.json")
if not os.path.exists(state_file):
return None
try:
with open(state_file, 'r', encoding='utf-8') as f:
data = json.load(f)
state = SimulationRunState(
simulation_id=simulation_id,
runner_status=RunnerStatus(data.get("runner_status", "idle")),
current_round=data.get("current_round", 0),
total_rounds=data.get("total_rounds", 0),
simulated_hours=data.get("simulated_hours", 0),
total_simulation_hours=data.get("total_simulation_hours", 0),
twitter_current_round=data.get("twitter_current_round", 0),
reddit_current_round=data.get("reddit_current_round", 0),
twitter_simulated_hours=data.get("twitter_simulated_hours", 0),
reddit_simulated_hours=data.get("reddit_simulated_hours", 0),
twitter_running=data.get("twitter_running", False),
reddit_running=data.get("reddit_running", False),
twitter_completed=data.get("twitter_completed", False),
reddit_completed=data.get("reddit_completed", False),
twitter_actions_count=data.get("twitter_actions_count", 0),
reddit_actions_count=data.get("reddit_actions_count", 0),
started_at=data.get("started_at"),
updated_at=data.get("updated_at", datetime.now().isoformat()),
completed_at=data.get("completed_at"),
error=data.get("error"),
process_pid=data.get("process_pid"),
)
# Restore 50 hành động gần nhất (để hiển thị lại sau restart)
actions_data = data.get("recent_actions", [])
for a in actions_data:
state.recent_actions.append(AgentAction(
round_num=a.get("round_num", 0),
timestamp=a.get("timestamp", ""),
platform=a.get("platform", ""),
agent_id=a.get("agent_id", 0),
agent_name=a.get("agent_name", ""),
action_type=a.get("action_type", ""),
action_args=a.get("action_args", {}),
result=a.get("result"),
success=a.get("success", True),
))
return state
except Exception as e:
logger.error(f"Failed to load run state: {str(e)}")
return None
@classmethod
def _save_run_state(cls, state: SimulationRunState):
"""
Ghi SimulationRunState ra run_state.json VÀ cập nhật RAM cache.
Được gọi:
1. Sau mỗi lần đọc actions.jsonl trong monitor thread (mỗi 2 giây)
2. Khi trạng thái thay đổi (STARTING → RUNNING → COMPLETED/FAILED)
3. Khi process kết thúc (cleanup trong finally block)
"""
sim_dir = os.path.join(cls.RUN_STATE_DIR, state.simulation_id)
os.makedirs(sim_dir, exist_ok=True)
state_file = os.path.join(sim_dir, "run_state.json")
# to_detail_dict() bao gồm recent_actions — đầy đủ hơn to_dict()
data = state.to_detail_dict()
with open(state_file, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
cls._run_states[state.simulation_id] = state
# --------------------------------------------------------------------------
# PUBLIC: start_simulation — Khởi động OASIS subprocess + monitor thread
# --------------------------------------------------------------------------
@classmethod
def start_simulation(
cls,
simulation_id: str,
platform: str = "parallel", # "twitter" / "reddit" / "parallel"
max_rounds: int = None, # Giới hạn số vòng tối đa (None = không giới hạn)
enable_graph_memory_update: bool = False, # Ghi hành động agent vào Zep Graph
graph_id: str = None # Bắt buộc nếu enable_graph_memory_update=True
) -> SimulationRunState:
"""
Khởi động simulation:
1. Đọc simulation_config.json → tính total_rounds
2. subprocess.Popen(run_parallel_simulation.py --config ...) → OASIS chạy ngầm
3. Thread(_monitor_simulation) → daemon thread đọc actions.jsonl mỗi 2 giây
Sau khi hàm này return, OASIS đang chạy trong background.
Flask vẫn nhận request bình thường — không bị block.
start_new_session=True: tạo process group mới → có thể kill toàn bộ cây process
bằng os.killpg() (thay vì chỉ kill process cha).
Args:
simulation_id: ID của simulation đã qua prepare (status=READY)
platform: Nền tảng chạy ("twitter"/"reddit"/"parallel")
max_rounds: Cắt ngắn số vòng nếu chỉ muốn chạy thử
enable_graph_memory_update: Ghi lại hành động agent vào Zep Graph theo real-time
graph_id: Zep Graph ID (bắt buộc khi enable_graph_memory_update=True)
Returns:
SimulationRunState với runner_status=RUNNING
"""
# Kiểm tra không cho start khi đã đang chạy
existing = cls.get_run_state(simulation_id)
if existing and existing.runner_status in [RunnerStatus.RUNNING, RunnerStatus.STARTING]:
raise ValueError(f"Simulation is already running: {simulation_id}")
# Đọc simulation_config.json để tính total_rounds
sim_dir = os.path.join(cls.RUN_STATE_DIR, simulation_id)
config_path = os.path.join(sim_dir, "simulation_config.json")
if not os.path.exists(config_path):
raise ValueError(f"Simulation configuration not found, please call the /prepare API first")
with open(config_path, 'r', encoding='utf-8') as f:
config = json.load(f)
# Tính total_rounds từ time_config
time_config = config.get("time_config", {})
total_hours = time_config.get("total_simulation_hours", 72)
minutes_per_round = time_config.get("minutes_per_round", 30)
total_rounds = int(total_hours * 60 / minutes_per_round)
# Ví dụ: 72h × 60min / 60min/round = 72 rounds
# Áp dụng giới hạn max_rounds nếu có (dùng để test chạy nhanh)
if max_rounds is not None and max_rounds > 0:
original_rounds = total_rounds
total_rounds = min(total_rounds, max_rounds)
if total_rounds < original_rounds:
logger.info(f"Rounds truncated: {original_rounds} -> {total_rounds} (max_rounds={max_rounds})")
# Khởi tạo run state ban đầu với status=STARTING
state = SimulationRunState(
simulation_id=simulation_id,
runner_status=RunnerStatus.STARTING,
total_rounds=total_rounds,
total_simulation_hours=total_hours,
started_at=datetime.now().isoformat(),
)
cls._save_run_state(state)
# Khởi tạo Graph Memory Updater nếu được bật
if enable_graph_memory_update:
if not graph_id:
raise ValueError("graph_id is required to enable graph memory updates")
try:
ZepGraphMemoryManager.create_updater(simulation_id, graph_id)
cls._graph_memory_enabled[simulation_id] = True
logger.info(f"Graph memory update enabled: simulation_id={simulation_id}, graph_id={graph_id}")
except Exception as e:
logger.error(f"Failed to create graph memory updater: {e}")
cls._graph_memory_enabled[simulation_id] = False
else:
cls._graph_memory_enabled[simulation_id] = False
# Chọn script OASIS phù hợp với platform
if platform == "twitter":
script_name = "run_twitter_simulation.py"
state.twitter_running = True
elif platform == "reddit":
script_name = "run_reddit_simulation.py"
state.reddit_running = True
else:
# "parallel" — chạy cả hai platform đồng thời (asyncio bên trong script)
script_name = "run_parallel_simulation.py"
state.twitter_running = True
state.reddit_running = True
script_path = os.path.join(cls.SCRIPTS_DIR, script_name)
if not os.path.exists(script_path):
raise ValueError(f"Script no longer exists: {script_path}")
# Tạo action queue (hiện chưa dùng nhưng giữ lại cho tương lai)
action_queue = Queue()
cls._action_queues[simulation_id] = action_queue
# Khởi động OASIS subprocess
try:
cmd = [
sys.executable, # Python interpreter hiện tại
script_path,
"--config", config_path,
]
# Truyền max_rounds vào script nếu có
if max_rounds is not None and max_rounds > 0:
cmd.extend(["--max-rounds", str(max_rounds)])
# stdout/stderr → simulation.log (tránh pipe buffer đầy gây block)
main_log_path = os.path.join(sim_dir, "simulation.log")
main_log_file = open(main_log_path, 'w', encoding='utf-8')
# Đảm bảo UTF-8 trên mọi OS (đặc biệt Windows mặc định CP1252)
env = os.environ.copy()
env['PYTHONUTF8'] = '1' # Python 3.7+: buộc mọi open() dùng UTF-8
env['PYTHONIOENCODING'] = 'utf-8' # stdout/stderr cũng UTF-8
process = subprocess.Popen(
cmd,
cwd=sim_dir, # Working dir = thư mục simulation (script đọc file tương đối từ đây)
stdout=main_log_file,
stderr=subprocess.STDOUT, # Merge stderr vào stdout → 1 file log duy nhất
text=True,
encoding='utf-8',
bufsize=1,
env=env,
start_new_session=True, # Tạo process group mới → os.killpg() kill được toàn cây
)
cls._stdout_files[simulation_id] = main_log_file
cls._stderr_files[simulation_id] = None # Không cần file riêng cho stderr
state.process_pid = process.pid
state.runner_status = RunnerStatus.RUNNING
cls._processes[simulation_id] = process
cls._save_run_state(state)
# Khởi động daemon monitor thread — chạy song song với Flask
# daemon=True → thread tự chết khi main process kết thúc
monitor_thread = threading.Thread(
target=cls._monitor_simulation,
args=(simulation_id,),
daemon=True
)
monitor_thread.start()
cls._monitor_threads[simulation_id] = monitor_thread
logger.info(f"Simulation started successfully: {simulation_id}, pid={process.pid}, platform={platform}")
except Exception as e:
state.runner_status = RunnerStatus.FAILED
state.error = str(e)
cls._save_run_state(state)
raise
return state
# --------------------------------------------------------------------------
# PRIVATE: _monitor_simulation — Daemon thread theo dõi tiến độ real-time
# --------------------------------------------------------------------------
@classmethod
def _monitor_simulation(cls, simulation_id: str):
"""
Daemon thread chạy liên tục, đọc actions.jsonl và cập nhật run_state.json.
Cấu trúc log (mới — tách theo platform):
uploads/simulations/<sim_id>/twitter/actions.jsonl
uploads/simulations/<sim_id>/reddit/actions.jsonl
Cơ chế file seek:
Mỗi lần đọc, hàm _read_action_log() trả về vị trí cuối file (f.tell()).
Lần tiếp theo, seek đến vị trí đó → chỉ đọc các dòng MỚI thêm vào.
→ Tránh parse lại toàn bộ file mỗi 2 giây.
Vòng lặp kết thúc khi process.poll() != None (process đã thoát).
Sau đó: đọc log lần cuối → xử lý exit_code → cleanup resources.
"""
sim_dir = os.path.join(cls.RUN_STATE_DIR, simulation_id)
# Cấu trúc log mới: tách log hành động theo từng nền tảng
twitter_actions_log = os.path.join(sim_dir, "twitter", "actions.jsonl")
reddit_actions_log = os.path.join(sim_dir, "reddit", "actions.jsonl")
process = cls._processes.get(simulation_id)
state = cls.get_run_state(simulation_id)
if not process or not state:
return
# Vị trí đọc cuối cùng trong mỗi file (file seek position)
twitter_position = 0
reddit_position = 0
try:
# Vòng lặp chính: chạy cho đến khi process kết thúc
while process.poll() is None:
# Đọc log Twitter (chỉ đọc phần mới từ twitter_position)
if os.path.exists(twitter_actions_log):
twitter_position = cls._read_action_log(
twitter_actions_log, twitter_position, state, "twitter"
)
# Đọc log Reddit (chỉ đọc phần mới từ reddit_position)
if os.path.exists(reddit_actions_log):
reddit_position = cls._read_action_log(
reddit_actions_log, reddit_position, state, "reddit"
)
# Lưu trạng thái → Frontend có thể poll API để lấy tiến độ
cls._save_run_state(state)
time.sleep(2) # Poll mỗi 2 giây
# Process đã kết thúc — đọc log lần cuối để không bỏ sót action cuối
if os.path.exists(twitter_actions_log):
cls._read_action_log(twitter_actions_log, twitter_position, state, "twitter")
if os.path.exists(reddit_actions_log):
cls._read_action_log(reddit_actions_log, reddit_position, state, "reddit")
# Xử lý kết quả dựa trên exit_code
exit_code = process.returncode
if exit_code == 0:
state.runner_status = RunnerStatus.COMPLETED
state.completed_at = datetime.now().isoformat()
logger.info(f"Simulation completed: {simulation_id}")
else:
state.runner_status = RunnerStatus.FAILED
# Đọc 2000 ký tự cuối của simulation.log làm error message
main_log_path = os.path.join(sim_dir, "simulation.log")
error_info = ""
try:
if os.path.exists(main_log_path):
with open(main_log_path, 'r', encoding='utf-8') as f:
error_info = f.read()[-2000:]
except Exception:
pass
state.error = f"Process exit code: {exit_code}, error: {error_info}"
logger.error(f"Simulation failed: {simulation_id}, error={state.error}")
state.twitter_running = False
state.reddit_running = False
cls._save_run_state(state)
except Exception as e:
logger.error(f"Monitor thread exception: {simulation_id}, error={str(e)}")
state.runner_status = RunnerStatus.FAILED
state.error = str(e)
cls._save_run_state(state)
finally:
# Dừng Graph Memory Updater nếu đang chạy
if cls._graph_memory_enabled.get(simulation_id, False):
try:
ZepGraphMemoryManager.stop_updater(simulation_id)
logger.info(f"Graph memory update stopped: simulation_id={simulation_id}")
except Exception as e:
logger.error(f"Failed to stop graph memory updater: {e}")
cls._graph_memory_enabled.pop(simulation_id, None)
# Dọn dẹp tài nguyên process
cls._processes.pop(simulation_id, None)
cls._action_queues.pop(simulation_id, None)
# Đóng file handle log (stdout)
if simulation_id in cls._stdout_files:
try:
cls._stdout_files[simulation_id].close()
except Exception:
pass
cls._stdout_files.pop(simulation_id, None)
if simulation_id in cls._stderr_files and cls._stderr_files[simulation_id]:
try:
cls._stderr_files[simulation_id].close()
except Exception:
pass
cls._stderr_files.pop(simulation_id, None)
# --------------------------------------------------------------------------
# PRIVATE: _read_action_log — Parse actions.jsonl từ vị trí đã đọc
# --------------------------------------------------------------------------
@classmethod
def _read_action_log(
cls,
log_path: str,
position: int,
state: SimulationRunState,
platform: str
) -> int:
"""
Đọc các dòng mới trong actions.jsonl từ vị trí `position`.
Cơ chế file seek:
f.seek(position) → bỏ qua phần đã đọc trước
f.tell() → trả về vị trí hiện tại sau khi đọc → dùng cho lần tiếp theo
Hai loại dòng JSON trong actions.jsonl:
1. Sự kiện hệ thống (có field "event_type"):
{"event_type": "round_end", "round": 15, "simulated_hours": 15}
{"event_type": "simulation_end", "total_rounds": 72, "total_actions": 3420}
→ Cập nhật round_num, simulated_hours, completed flag
2. Hành động agent (không có "event_type", có "agent_id"):
{"round": 15, "agent_id": 3, "action_type": "CREATE_POST", ...}
→ Tạo AgentAction object, thêm vào state.recent_actions
Args:
log_path: Đường dẫn file actions.jsonl
position: Vị trí byte đã đọc tới lần trước
state: SimulationRunState cần cập nhật
platform: "twitter" hoặc "reddit"
Returns:
Vị trí byte mới (dùng cho lần gọi tiếp theo)
"""
graph_memory_enabled = cls._graph_memory_enabled.get(state.simulation_id, False)
graph_updater = None
if graph_memory_enabled:
graph_updater = ZepGraphMemoryManager.get_updater(state.simulation_id)
try:
with open(log_path, 'r', encoding='utf-8') as f:
f.seek(position) # Nhảy đến vị trí đã đọc lần trước
for line in f:
line = line.strip()
if line:
try:
action_data = json.loads(line)
# Xử lý sự kiện hệ thống (event_type)
if "event_type" in action_data:
event_type = action_data.get("event_type")
if event_type == "simulation_end":
# OASIS báo hiệu đã chạy hết số vòng → đánh dấu platform completed
if platform == "twitter":
state.twitter_completed = True
state.twitter_running = False
logger.info(f"Twitter simulation completed: {state.simulation_id}, total_rounds={action_data.get('total_rounds')}, total_actions={action_data.get('total_actions')}")
elif platform == "reddit":
state.reddit_completed = True
state.reddit_running = False
logger.info(f"Reddit simulation completed: {state.simulation_id}, total_rounds={action_data.get('total_rounds')}, total_actions={action_data.get('total_actions')}")
# Kiểm tra nếu tất cả platform đã xong → đánh dấu COMPLETED
# Logic: platform nào bật (có actions.jsonl) thì phải xong; phải có ít nhất 1 xong
all_completed = cls._check_all_platforms_completed(state)
if all_completed:
state.runner_status = RunnerStatus.COMPLETED
state.completed_at = datetime.now().isoformat()
logger.info(f"Simulation completed for all platforms: {state.simulation_id}")
elif event_type == "round_end":
# Cập nhật số vòng và giờ đã mô phỏng (độc lập cho từng platform)
round_num = action_data.get("round", 0)
simulated_hours = action_data.get("simulated_hours", 0)
if platform == "twitter":
if round_num > state.twitter_current_round:
state.twitter_current_round = round_num
state.twitter_simulated_hours = simulated_hours
elif platform == "reddit":
if round_num > state.reddit_current_round:
state.reddit_current_round = round_num
state.reddit_simulated_hours = simulated_hours
# Vòng chung = max của 2 platform (platform nhanh hơn làm mốc)
if round_num > state.current_round:
state.current_round = round_num
state.simulated_hours = max(state.twitter_simulated_hours, state.reddit_simulated_hours)
continue # Không tạo AgentAction cho sự kiện hệ thống
# Xử lý hành động agent (có agent_id)
action = AgentAction(
round_num=action_data.get("round", 0),
timestamp=action_data.get("timestamp", datetime.now().isoformat()),
platform=platform,
agent_id=action_data.get("agent_id", 0),
agent_name=action_data.get("agent_name", ""),
action_type=action_data.get("action_type", ""),
action_args=action_data.get("action_args", {}),
result=action_data.get("result"),
success=action_data.get("success", True),
)
state.add_action(action)
# Cập nhật current_round từ action nếu cần
if action.round_num and action.round_num > state.current_round:
state.current_round = action.round_num
# Ghi hành động vào Zep Graph nếu tính năng được bật
if graph_updater:
graph_updater.add_activity_from_dict(action_data, platform)
except json.JSONDecodeError:
pass # Dòng không phải JSON hợp lệ → bỏ qua
return f.tell() # Trả về vị trí cuối để lần sau tiếp tục từ đây
except Exception as e:
logger.warning(f"Failed to read action logs: {log_path}, error={e}")
return position # Giữ nguyên position nếu đọc lỗi
@classmethod
def _check_all_platforms_completed(cls, state: SimulationRunState) -> bool:
"""
Kiểm tra xem tất cả platform đã hoàn thành chưa.
Cách xác định platform có được bật:
Kiểm tra file actions.jsonl có tồn tại không (không dùng enable_twitter/enable_reddit flag,
vì flag đó chỉ có trong SimulationState — không truyền sang đây).
Logic:
Nếu twitter/actions.jsonl tồn tại → twitter được bật → phải twitter_completed=True
Nếu reddit/actions.jsonl tồn tại → reddit được bật → phải reddit_completed=True
Phải có ít nhất 1 platform xong (tránh trả True khi cả 2 chưa tạo file)
Returns:
True nếu tất cả platform đã bật đều đã completed
"""
sim_dir = os.path.join(cls.RUN_STATE_DIR, state.simulation_id)
twitter_log = os.path.join(sim_dir, "twitter", "actions.jsonl")
reddit_log = os.path.join(sim_dir, "reddit", "actions.jsonl")
twitter_enabled = os.path.exists(twitter_log)
reddit_enabled = os.path.exists(reddit_log)
# Platform nào được bật mà chưa xong → False
if twitter_enabled and not state.twitter_completed:
return False
if reddit_enabled and not state.reddit_completed:
return False
# Phải có ít nhất 1 platform chạy xong
return twitter_enabled or reddit_enabled
# --------------------------------------------------------------------------
# PRIVATE: _terminate_process — Kill process + toàn bộ process group
# --------------------------------------------------------------------------
@classmethod
def _terminate_process(cls, process: subprocess.Popen, simulation_id: str, timeout: int = 10):
"""
Dừng process và toàn bộ process group (bao gồm child processes).
Tại sao phải kill cả process group?
OASIS script có thể spawn thêm asyncio workers hoặc subprocess con.
Kill chỉ process cha sẽ để lại các zombie child processes.
Unix: os.killpg(pgid, SIGTERM) → chờ 10s → os.killpg(pgid, SIGKILL) nếu không thoát
Windows: taskkill /T /PID → chờ → taskkill /F /T /PID nếu không thoát
start_new_session=True (trong Popen) đảm bảo pgid == pid của process cha.
Args:
process: Subprocess.Popen object
simulation_id: Chỉ dùng để log
timeout: Giây chờ trước khi SIGKILL (mặc định 10s)
"""
if IS_WINDOWS:
logger.info(f"Terminating process (Windows): simulation={simulation_id}, pid={process.pid}")
try:
# Bước 1: Dừng nhẹ nhàng (/T = kill cả tree)
subprocess.run(
['taskkill', '/PID', str(process.pid), '/T'],
capture_output=True,
timeout=5
)
try:
process.wait(timeout=timeout)
except subprocess.TimeoutExpired:
# Bước 2: Kill cưỡng bức (/F = force)
logger.warning(f"Process unresponsive, force terminating: {simulation_id}")
subprocess.run(
['taskkill', '/F', '/PID', str(process.pid), '/T'],
capture_output=True,
timeout=5
)
process.wait(timeout=5)
except Exception as e:
logger.warning(f"taskkill failed, attempting terminate: {e}")
process.terminate()
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
else:
# Unix: dùng process group để kill toàn bộ cây
pgid = os.getpgid(process.pid)
logger.info(f"Terminating process group (Unix): simulation={simulation_id}, pgid={pgid}")
# Bước 1: SIGTERM — tín hiệu nhẹ nhàng, cho phép process dọn dẹp
os.killpg(pgid, signal.SIGTERM)
try:
process.wait(timeout=timeout)
except subprocess.TimeoutExpired:
# Bước 2: SIGKILL — kill cưỡng bức, không thể bị bắt hay bỏ qua
logger.warning(f"Process group unresponsive to SIGTERM, force terminating: {simulation_id}")
os.killpg(pgid, signal.SIGKILL)
process.wait(timeout=5)
# --------------------------------------------------------------------------
# PUBLIC: stop_simulation — Dừng simulation theo yêu cầu người dùng
# --------------------------------------------------------------------------
@classmethod
def stop_simulation(cls, simulation_id: str) -> SimulationRunState:
"""
Dừng OASIS subprocess → state = STOPPED.
Thứ tự:
1. Chuyển state sang STOPPING (để frontend biết đang xử lý)
2. _terminate_process() — kill process group
3. Chuyển state sang STOPPED
4. Dừng Graph Memory Updater nếu đang chạy
Khác với FAILED: STOPPED là do người dùng chủ động, FAILED là do crash.
"""
state = cls.get_run_state(simulation_id)
if not state:
raise ValueError(f"Simulation not found: {simulation_id}")
if state.runner_status not in [RunnerStatus.RUNNING, RunnerStatus.PAUSED]:
raise ValueError(f"Simulation is not running: {simulation_id}, status={state.runner_status}")
state.runner_status = RunnerStatus.STOPPING
cls._save_run_state(state)
# Kill OASIS subprocess (và toàn bộ process group)
process = cls._processes.get(simulation_id)
if process and process.poll() is None:
try:
cls._terminate_process(process, simulation_id)
except ProcessLookupError:
# Process đã tự thoát trước khi kịp kill — OK
pass
except Exception as e:
logger.error(f"Failed to terminate process group: {simulation_id}, error={e}")
# Fallback: try terminate() rồi kill()
try:
process.terminate()
process.wait(timeout=5)
except Exception:
process.kill()
state.runner_status = RunnerStatus.STOPPED
state.twitter_running = False
state.reddit_running = False
state.completed_at = datetime.now().isoformat()
cls._save_run_state(state)
# Dừng Graph Memory Updater
if cls._graph_memory_enabled.get(simulation_id, False):
try:
ZepGraphMemoryManager.stop_updater(simulation_id)
logger.info(f"Graph memory update stopped: simulation_id={simulation_id}")
except Exception as e:
logger.error(f"Failed to stop graph memory updater: {e}")
cls._graph_memory_enabled.pop(simulation_id, None)
logger.info(f"Simulation stopped: {simulation_id}")
return state
# --------------------------------------------------------------------------
# PUBLIC: Đọc lịch sử hành động (dùng sau khi simulation kết thúc)
# --------------------------------------------------------------------------
@classmethod
def _read_actions_from_file(
cls,
file_path: str,
default_platform: Optional[str] = None,
platform_filter: Optional[str] = None,
agent_id: Optional[int] = None,
round_num: Optional[int] = None
) -> List[AgentAction]:
"""
Đọc toàn bộ actions từ 1 file actions.jsonl với các bộ lọc tùy chọn.
Bỏ qua:
- Dòng có "event_type" (sự kiện hệ thống: round_end, simulation_end)
- Dòng không có "agent_id" (không phải hành động của agent)
Args:
file_path: Đường dẫn file actions.jsonl
default_platform: Điền tự động nếu record không có field "platform"
platform_filter: Chỉ lấy record của platform này
agent_id: Chỉ lấy record của agent này
round_num: Chỉ lấy record của vòng này
"""
if not os.path.exists(file_path):
return []
actions = []
with open(file_path, 'r', encoding='utf-8') as f:
for line in f:
line = line.strip()
if not line:
continue
try:
data = json.loads(line)
# Bỏ qua sự kiện hệ thống
if "event_type" in data:
continue
# Bỏ qua record không có agent_id
if "agent_id" not in data:
continue
record_platform = data.get("platform") or default_platform or ""
# Áp dụng bộ lọc
if platform_filter and record_platform != platform_filter:
continue
if agent_id is not None and data.get("agent_id") != agent_id:
continue
if round_num is not None and data.get("round") != round_num:
continue
actions.append(AgentAction(
round_num=data.get("round", 0),
timestamp=data.get("timestamp", ""),
platform=record_platform,
agent_id=data.get("agent_id", 0),
agent_name=data.get("agent_name", ""),
action_type=data.get("action_type", ""),
action_args=data.get("action_args", {}),
result=data.get("result"),
success=data.get("success", True),
))
except json.JSONDecodeError:
continue
return actions
@classmethod
def get_all_actions(
cls,
simulation_id: str,
platform: Optional[str] = None,
agent_id: Optional[int] = None,
round_num: Optional[int] = None
) -> List[AgentAction]:
"""
Lấy toàn bộ lịch sử hành động — đọc từ file (không giới hạn phân trang).
Thứ tự ưu tiên đọc file:
1. twitter/actions.jsonl (cấu trúc mới — tách theo platform)
2. reddit/actions.jsonl
3. actions.jsonl (cấu trúc cũ — fallback tương thích ngược)
Kết quả được sort theo timestamp giảm dần (mới nhất lên đầu).
"""
sim_dir = os.path.join(cls.RUN_STATE_DIR, simulation_id)
actions = []
# Đọc Twitter actions
twitter_actions_log = os.path.join(sim_dir, "twitter", "actions.jsonl")
if not platform or platform == "twitter":
actions.extend(cls._read_actions_from_file(
twitter_actions_log,
default_platform="twitter",
platform_filter=platform,
agent_id=agent_id,
round_num=round_num
))
# Đọc Reddit actions
reddit_actions_log = os.path.join(sim_dir, "reddit", "actions.jsonl")
if not platform or platform == "reddit":
actions.extend(cls._read_actions_from_file(
reddit_actions_log,
default_platform="reddit",
platform_filter=platform,
agent_id=agent_id,
round_num=round_num
))
# Fallback: đọc file actions.jsonl cũ nếu không có thư mục twitter/reddit
if not actions:
actions_log = os.path.join(sim_dir, "actions.jsonl")
actions = cls._read_actions_from_file(
actions_log,
default_platform=None, # File cũ đã có field platform trong record
platform_filter=platform,
agent_id=agent_id,
round_num=round_num
)
actions.sort(key=lambda x: x.timestamp, reverse=True)
return actions
@classmethod
def get_actions(
cls,
simulation_id: str,
limit: int = 100,
offset: int = 0,
platform: Optional[str] = None,
agent_id: Optional[int] = None,
round_num: Optional[int] = None
) -> List[AgentAction]:
"""
Lấy lịch sử hành động có phân trang (offset + limit).
Gọi get_all_actions() rồi cắt — không tối ưu cho file lớn nhưng đơn giản.
Nếu hiệu năng là vấn đề, cần cải thiện bằng cách chỉ đọc đến offset+limit.
"""
actions = cls.get_all_actions(
simulation_id=simulation_id,
platform=platform,
agent_id=agent_id,
round_num=round_num
)
return actions[offset:offset + limit]
@classmethod
def get_timeline(
cls,
simulation_id: str,
start_round: int = 0,
end_round: Optional[int] = None
) -> List[Dict[str, Any]]:
"""
Lấy timeline mô phỏng — tóm tắt theo từng vòng.
Nhóm tất cả actions theo round_num, tính:
- Số action twitter/reddit trong vòng
- Danh sách agent_id đã hoạt động
- Phân phối action_type (CREATE_POST, LIKE_POST, ...)
Dùng để hiển thị biểu đồ hoạt động theo thời gian trên frontend.
"""
actions = cls.get_actions(simulation_id, limit=10000)
rounds: Dict[int, Dict[str, Any]] = {}
for action in actions:
round_num = action.round_num
if round_num < start_round:
continue
if end_round is not None and round_num > end_round:
continue
if round_num not in rounds:
rounds[round_num] = {
"round_num": round_num,
"twitter_actions": 0,
"reddit_actions": 0,
"active_agents": set(),
"action_types": {},
"first_action_time": action.timestamp,
"last_action_time": action.timestamp,
}
r = rounds[round_num]
if action.platform == "twitter":
r["twitter_actions"] += 1
else:
r["reddit_actions"] += 1
r["active_agents"].add(action.agent_id)
r["action_types"][action.action_type] = r["action_types"].get(action.action_type, 0) + 1
r["last_action_time"] = action.timestamp
# Chuyển set → list để JSON serializable, sort theo round_num tăng dần
result = []
for round_num in sorted(rounds.keys()):
r = rounds[round_num]
result.append({
"round_num": round_num,
"twitter_actions": r["twitter_actions"],
"reddit_actions": r["reddit_actions"],
"total_actions": r["twitter_actions"] + r["reddit_actions"],
"active_agents_count": len(r["active_agents"]),
"active_agents": list(r["active_agents"]),
"action_types": r["action_types"],
"first_action_time": r["first_action_time"],
"last_action_time": r["last_action_time"],
})
return result
@classmethod
def get_agent_stats(cls, simulation_id: str) -> List[Dict[str, Any]]:
"""
Thống kê hoạt động của từng agent — sort theo tổng số hành động giảm dần.
Dùng để hiển thị bảng xếp hạng "agent nào hoạt động nhiều nhất".
"""
actions = cls.get_actions(simulation_id, limit=10000)
agent_stats: Dict[int, Dict[str, Any]] = {}
for action in actions:
agent_id = action.agent_id
if agent_id not in agent_stats:
agent_stats[agent_id] = {
"agent_id": agent_id,
"agent_name": action.agent_name,
"total_actions": 0,
"twitter_actions": 0,
"reddit_actions": 0,
"action_types": {},
"first_action_time": action.timestamp,
"last_action_time": action.timestamp,
}
stats = agent_stats[agent_id]
stats["total_actions"] += 1
if action.platform == "twitter":
stats["twitter_actions"] += 1
else:
stats["reddit_actions"] += 1
stats["action_types"][action.action_type] = stats["action_types"].get(action.action_type, 0) + 1
stats["last_action_time"] = action.timestamp
result = sorted(agent_stats.values(), key=lambda x: x["total_actions"], reverse=True)
return result
# --------------------------------------------------------------------------
# PUBLIC: cleanup — Dọn dẹp file log để cho phép chạy lại
# --------------------------------------------------------------------------
@classmethod
def cleanup_simulation_logs(cls, simulation_id: str) -> Dict[str, Any]:
"""
Xóa các file runtime để buộc simulation được khởi động lại từ đầu.
CÁC FILE BỊ XÓA (runtime logs):
run_state.json, simulation.log, stdout.log, stderr.log,
twitter_simulation.db, reddit_simulation.db, env_status.json,
twitter/actions.jsonl, reddit/actions.jsonl
CÁC FILE GIỮ LẠI (config + profiles — không xóa):
simulation_config.json, reddit_profiles.json, twitter_profiles.csv, state.json
Khi nào cần gọi:
Sau server restart nếu run_state.json vẫn hiển thị status="running"
(process đã chết cùng server → không thể tiếp tục, phải cleanup rồi start lại)
Returns:
{"success": bool, "cleaned_files": [...], "errors": [...]}
"""
import shutil
sim_dir = os.path.join(cls.RUN_STATE_DIR, simulation_id)
if not os.path.exists(sim_dir):
return {"success": True, "message": "Simulation directory does not exist, no need to clean."}
cleaned_files = []
errors = []
files_to_delete = [
"run_state.json",
"simulation.log",
"stdout.log",
"stderr.log",
"twitter_simulation.db",
"reddit_simulation.db",
"env_status.json",
]
dirs_to_clean = ["twitter", "reddit"]
for filename in files_to_delete:
file_path = os.path.join(sim_dir, filename)
if os.path.exists(file_path):
try:
os.remove(file_path)
cleaned_files.append(filename)
except Exception as e:
errors.append(f"Failed to delete {filename}: {str(e)}")
for dir_name in dirs_to_clean:
dir_path = os.path.join(sim_dir, dir_name)
if os.path.exists(dir_path):
actions_file = os.path.join(dir_path, "actions.jsonl")
if os.path.exists(actions_file):
try:
os.remove(actions_file)
cleaned_files.append(f"{dir_name}/actions.jsonl")
except Exception as e:
errors.append(f"Failed to delete {dir_name}/actions.jsonl: {str(e)}")
# Xóa RAM cache cho simulation này
if simulation_id in cls._run_states:
del cls._run_states[simulation_id]
logger.info(f"Clean up complete for simulation: {simulation_id}, Deleted files: {cleaned_files}")
return {
"success": len(errors) == 0,
"cleaned_files": cleaned_files,
"errors": errors if errors else None
}
# Cờ chống gọi cleanup nhiều lần (atexit có thể gọi nhiều lần trong một số trường hợp)
_cleanup_done = False
@classmethod
def cleanup_all_simulations(cls):
"""
Dọn dẹp tất cả simulation đang chạy — gọi khi server tắt.
Được đăng ký bởi:
atexit.register(cls.cleanup_all_simulations) → khi Python interpreter kết thúc
signal.signal(SIGTERM, cleanup_handler) → khi nhận SIGTERM (kill server)
signal.signal(SIGINT, cleanup_handler) → khi Ctrl+C
Thực hiện:
1. Dừng tất cả Graph Memory Updaters
2. Kill toàn bộ OASIS subprocess đang chạy
3. Cập nhật state.json → status="stopped" (để frontend không hiển thị "running" sau khi restart)
4. Cập nhật run_state.json → runner_status="stopped"
5. Đóng tất cả file handle
6. Clear RAM cache
"""
if cls._cleanup_done:
return
cls._cleanup_done = True
has_processes = bool(cls._processes)
has_updaters = bool(cls._graph_memory_enabled)
if not has_processes and not has_updaters:
return # Không có gì để dọn
logger.info("Cleaning up all simulation processes...")
# Dừng tất cả Graph Memory Updaters
try:
ZepGraphMemoryManager.stop_all()
except Exception as e:
logger.error(f"Failed to stop Zep Graph update daemon: {e}")
cls._graph_memory_enabled.clear()
# Tạo bản sao để tránh RuntimeError khi dict thay đổi trong khi lặp
processes = list(cls._processes.items())
for simulation_id, process in processes:
try:
if process.poll() is None: # Process vẫn đang chạy
logger.info(f"Terminating simulation process: {simulation_id}, pid={process.pid}")
try:
cls._terminate_process(process, simulation_id, timeout=5)
except (ProcessLookupError, OSError):
# Process đã biến mất → fallback terminate/kill
try:
process.terminate()
process.wait(timeout=3)
except Exception:
process.kill()
# Cập nhật run_state.json → status=stopped
state = cls.get_run_state(simulation_id)
if state:
state.runner_status = RunnerStatus.STOPPED
state.twitter_running = False
state.reddit_running = False
state.completed_at = datetime.now().isoformat()
state.error = "Server shut down, simulation terminated."
cls._save_run_state(state)
# Cập nhật state.json → status="stopped" (SimulationManager's file)
# Lý do: SimulationManager không tự biết server đang tắt → phải cập nhật thủ công
try:
sim_dir = os.path.join(cls.RUN_STATE_DIR, simulation_id)
state_file = os.path.join(sim_dir, "state.json")
logger.info(f"Attempting to update state.json: {state_file}")
if os.path.exists(state_file):
with open(state_file, 'r', encoding='utf-8') as f:
state_data = json.load(f)
state_data['status'] = 'stopped'
state_data['updated_at'] = datetime.now().isoformat()
with open(state_file, 'w', encoding='utf-8') as f:
json.dump(state_data, f, indent=2, ensure_ascii=False)
logger.info(f"Updated state.json status to 'stopped': {simulation_id}")
else:
logger.warning(f"state.json not found: {state_file}")
except Exception as state_err:
logger.warning(f"Failed to update state.json: {simulation_id}, error={state_err}")
except Exception as e:
logger.error(f"Failed to clean up process: {simulation_id}, error={e}")
# Đóng tất cả file handle log
for simulation_id, file_handle in list(cls._stdout_files.items()):
try:
if file_handle:
file_handle.close()
except Exception:
pass
cls._stdout_files.clear()
for simulation_id, file_handle in list(cls._stderr_files.items()):
try:
if file_handle:
file_handle.close()
except Exception:
pass
cls._stderr_files.clear()
# Clear RAM cache
cls._processes.clear()
cls._action_queues.clear()
logger.info("Simulation process clean up completed.")
@classmethod
def register_cleanup(cls):
"""
Đăng ký hàm dọn dẹp khi server tắt.
Gọi 1 lần duy nhất trong app initialization (Flask app factory).
Đăng ký 3 signal handlers + atexit fallback:
SIGTERM: kill server lệnh (Linux/Mac: systemd stop, Docker stop)
SIGINT: Ctrl+C từ terminal
SIGHUP: Terminal bị đóng (Unix only)
atexit: Fallback khi signal handling không khả dụng
Lưu ý Flask debug mode:
Werkzeug reload spawns 2 processes — chỉ đăng ký trong reloader child process
(WERKZEUG_RUN_MAIN=true). Production luôn đăng ký.
"""
global _cleanup_registered
if _cleanup_registered:
return
is_reloader_process = os.environ.get('WERKZEUG_RUN_MAIN') == 'true'
is_debug_mode = os.environ.get('FLASK_DEBUG') == '1' or os.environ.get('WERKZEUG_RUN_MAIN') is not None
# Debug mode: chỉ đăng ký trong child process của Werkzeug reloader
if is_debug_mode and not is_reloader_process:
_cleanup_registered = True
return
# Lưu signal handlers gốc để gọi lại sau khi cleanup
original_sigint = signal.getsignal(signal.SIGINT)
original_sigterm = signal.getsignal(signal.SIGTERM)
original_sighup = None
has_sighup = hasattr(signal, 'SIGHUP')
if has_sighup:
original_sighup = signal.getsignal(signal.SIGHUP)
def cleanup_handler(signum=None, frame=None):
"""Cleanup toàn bộ simulation rồi forward signal về handler gốc."""
if cls._processes or cls._graph_memory_enabled:
logger.info(f"Received signal {signum}, starting clean up...")
cls.cleanup_all_simulations()
# Forward signal về Flask/Werkzeug handler gốc
if signum == signal.SIGINT and callable(original_sigint):
original_sigint(signum, frame)
elif signum == signal.SIGTERM and callable(original_sigterm):
original_sigterm(signum, frame)
elif has_sighup and signum == signal.SIGHUP:
if callable(original_sighup):
original_sighup(signum, frame)
else:
sys.exit(0)
else:
raise KeyboardInterrupt
# Đăng ký atexit làm fallback (nếu signal handler không chạy được)
atexit.register(cls.cleanup_all_simulations)
# Đăng ký signal handlers — chỉ hoạt động trong main thread
try:
signal.signal(signal.SIGTERM, cleanup_handler)
signal.signal(signal.SIGINT, cleanup_handler)
if has_sighup:
signal.signal(signal.SIGHUP, cleanup_handler)
except ValueError:
# Không ở main thread → chỉ dùng atexit fallback
logger.warning("Failed to register signal handlers (not in main thread). Falling back to atexit.")
_cleanup_registered = True
@classmethod
def get_running_simulations(cls) -> List[str]:
"""Lấy danh sách simulation_id đang có process chạy (process.poll() is None)."""
running = []
for sim_id, process in cls._processes.items():
if process.poll() is None:
running.append(sim_id)
return running
# --------------------------------------------------------------------------
# PUBLIC: Interview — Phỏng vấn agent đang chạy qua IPC
# --------------------------------------------------------------------------
@classmethod
def check_env_alive(cls, simulation_id: str) -> bool:
"""
Kiểm tra xem OASIS environment có còn nhận lệnh IPC không.
Đọc env_status.json → status == "alive".
Phải kiểm tra trước khi gửi bất kỳ lệnh Interview nào.
Nếu False → raise ValueError ngay, không gửi IPC để tránh timeout.
"""
sim_dir = os.path.join(cls.RUN_STATE_DIR, simulation_id)
if not os.path.exists(sim_dir):
return False
ipc_client = SimulationIPCClient(sim_dir)
return ipc_client.check_env_alive()
@classmethod
def get_env_status_detail(cls, simulation_id: str) -> Dict[str, Any]:
"""
Lấy thông tin chi tiết trạng thái IPC environment.
Returns dict:
status: "alive" hoặc "stopped"
twitter_available: True nếu Twitter environment đang active
reddit_available: True nếu Reddit environment đang active
timestamp: Thời điểm cập nhật env_status.json cuối cùng
"""
sim_dir = os.path.join(cls.RUN_STATE_DIR, simulation_id)
status_file = os.path.join(sim_dir, "env_status.json")
default_status = {
"status": "stopped",
"twitter_available": False,
"reddit_available": False,
"timestamp": None
}
if not os.path.exists(status_file):
return default_status
try:
with open(status_file, 'r', encoding='utf-8') as f:
status = json.load(f)
return {
"status": status.get("status", "stopped"),
"twitter_available": status.get("twitter_available", False),
"reddit_available": status.get("reddit_available", False),
"timestamp": status.get("timestamp")
}
except (json.JSONDecodeError, OSError):
return default_status
@classmethod
def interview_agent(
cls,
simulation_id: str,
agent_id: int,
prompt: str,
platform: str = None,
timeout: float = 60.0
) -> Dict[str, Any]:
"""
Phỏng vấn 1 agent đang chạy qua IPC.
Flow:
1. check_env_alive() → nếu False → raise ValueError ngay
2. SimulationIPCClient.send_interview() → ghi file ipc_commands/{uuid}.json
3. Poll ipc_responses/{uuid}.json mỗi 0.5s, timeout 60s
4. OASIS script phát hiện command → chạy ManualAction INTERVIEW → ghi response
5. Trả về response về caller
Args:
agent_id: ID của agent (khớp với user_id trong profile file)
prompt: Câu hỏi phỏng vấn
platform: "twitter"/"reddit"/None (None = phỏng vấn trên platform đang active)
timeout: Timeout tính bằng giây
"""
sim_dir = os.path.join(cls.RUN_STATE_DIR, simulation_id)
if not os.path.exists(sim_dir):
raise ValueError(f"Simulation does not exist: {simulation_id}")
ipc_client = SimulationIPCClient(sim_dir)
if not ipc_client.check_env_alive():
raise ValueError(f"Simulation environment is not running or closed, cannot perform Interview: {simulation_id}")
logger.info(f"Sending Interview Command: simulation_id={simulation_id}, agent_id={agent_id}, platform={platform}")
response = ipc_client.send_interview(
agent_id=agent_id,
prompt=prompt,
platform=platform,
timeout=timeout
)
if response.status.value == "completed":
return {
"success": True,
"agent_id": agent_id,
"prompt": prompt,
"result": response.result,
"timestamp": response.timestamp
}
else:
return {
"success": False,
"agent_id": agent_id,
"prompt": prompt,
"error": response.error,
"timestamp": response.timestamp
}
@classmethod
def interview_agents_batch(
cls,
simulation_id: str,
interviews: List[Dict[str, Any]],
platform: str = None,
timeout: float = 120.0
) -> Dict[str, Any]:
"""
Phỏng vấn nhiều agent cùng lúc — gửi 1 batch command duy nhất.
Hiệu quả hơn gọi interview_agent() N lần vì chỉ cần 1 round-trip IPC.
OASIS xử lý tất cả interviews trong batch rồi trả về kết quả gộp.
Args:
interviews: List[{"agent_id": int, "prompt": str, "platform": str (optional)}]
platform: Platform mặc định cho tất cả (nếu không chỉ định riêng từng phần tử)
timeout: Timeout tổng cho toàn bộ batch
"""
sim_dir = os.path.join(cls.RUN_STATE_DIR, simulation_id)
if not os.path.exists(sim_dir):
raise ValueError(f"Simulation does not exist: {simulation_id}")
ipc_client = SimulationIPCClient(sim_dir)
if not ipc_client.check_env_alive():
raise ValueError(f"Simulation environment is not running or closed, cannot perform Interview: {simulation_id}")
logger.info(f"Sending batch Interview command: simulation_id={simulation_id}, count={len(interviews)}, platform={platform}")
response = ipc_client.send_batch_interview(
interviews=interviews,
platform=platform,
timeout=timeout
)
if response.status.value == "completed":
return {
"success": True,
"interviews_count": len(interviews),
"result": response.result,
"timestamp": response.timestamp
}
else:
return {
"success": False,
"interviews_count": len(interviews),
"error": response.error,
"timestamp": response.timestamp
}
@classmethod
def interview_all_agents(
cls,
simulation_id: str,
prompt: str,
platform: str = None,
timeout: float = 180.0
) -> Dict[str, Any]:
"""
Phỏng vấn TẤT CẢ agent với cùng 1 câu hỏi.
Đọc danh sách agent từ simulation_config.json → build interviews list →
gọi interview_agents_batch().
Dùng khi muốn biết toàn bộ quan điểm của cộng đồng về 1 chủ đề.
Timeout mặc định 180s vì số lượng agent nhiều.
"""
sim_dir = os.path.join(cls.RUN_STATE_DIR, simulation_id)
if not os.path.exists(sim_dir):
raise ValueError(f"Simulation does not exist: {simulation_id}")
config_path = os.path.join(sim_dir, "simulation_config.json")
if not os.path.exists(config_path):
raise ValueError(f"Simulation config not found: {simulation_id}")
with open(config_path, 'r', encoding='utf-8') as f:
config = json.load(f)
agent_configs = config.get("agent_configs", [])
if not agent_configs:
raise ValueError(f"No Agent defined under simulation configs: {simulation_id}")
interviews = []
for agent_config in agent_configs:
agent_id = agent_config.get("agent_id")
if agent_id is not None:
interviews.append({
"agent_id": agent_id,
"prompt": prompt
})
logger.info(f"Sending GLOBAL Interview command: simulation_id={simulation_id}, agent_count={len(interviews)}, platform={platform}")
return cls.interview_agents_batch(
simulation_id=simulation_id,
interviews=interviews,
platform=platform,
timeout=timeout
)
@classmethod
def close_simulation_env(
cls,
simulation_id: str,
timeout: float = 30.0
) -> Dict[str, Any]:
"""
Gửi lệnh đóng environment qua IPC (không kill process ngay).
Khác với stop_simulation():
stop_simulation() → SIGTERM ngay lập tức (brutal)
close_simulation_env() → gửi lệnh IPC để OASIS tự dọn dẹp rồi thoát (graceful)
Dùng khi muốn kết thúc simulation sớm nhưng để OASIS lưu trạng thái trước.
Nếu timeout → trả về success=True vì environment có thể đang trong quá trình đóng.
"""
sim_dir = os.path.join(cls.RUN_STATE_DIR, simulation_id)
if not os.path.exists(sim_dir):
raise ValueError(f"Simulation does not exist: {simulation_id}")
ipc_client = SimulationIPCClient(sim_dir)
if not ipc_client.check_env_alive():
return {
"success": True,
"message": "Environment is already closed"
}
logger.info(f"Sending command to close Environment: simulation_id={simulation_id}")
try:
response = ipc_client.send_close_env(timeout=timeout)
return {
"success": response.status.value == "completed",
"message": "Environment close command sent",
"result": response.result,
"timestamp": response.timestamp
}
except TimeoutError:
# Timeout thường xảy ra khi environment đang trong quá trình đóng → OK
return {
"success": True,
"message": "Environment close command sent (timeout waiting for response, env might be closing)"
}
except Exception as e:
return {
"success": False,
"message": f"Failed to send close env command: {str(e)}"
}
@classmethod
def _get_interview_history_from_db(
cls,
db_path: str,
platform_name: str,
agent_id: Optional[int] = None,
limit: int = 100
) -> List[Dict[str, Any]]:
"""
Đọc lịch sử phỏng vấn từ SQLite database của OASIS.
OASIS lưu kết quả interview vào bảng `trace` trong database
(twitter_simulation.db hoặc reddit_simulation.db).
Truy vấn WHERE action='interview' để lọc ra các record phỏng vấn.
"""
import sqlite3
if not os.path.exists(db_path):
return []
results = []
try:
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
if agent_id is not None:
cursor.execute("""
SELECT user_id, info, created_at
FROM trace
WHERE action = 'interview' AND user_id = ?
ORDER BY created_at DESC
LIMIT ?
""", (agent_id, limit))
else:
cursor.execute("""
SELECT user_id, info, created_at
FROM trace
WHERE action = 'interview'
ORDER BY created_at DESC
LIMIT ?
""", (limit,))
for user_id, info_json, created_at in cursor.fetchall():
try:
info = json.loads(info_json) if info_json else {}
except json.JSONDecodeError:
info = {"raw": info_json}
results.append({
"agent_id": user_id,
"response": info.get("response", info),
"prompt": info.get("prompt", ""),
"timestamp": created_at,
"platform": platform_name
})
conn.close()
except Exception as e:
logger.error(f"Failed to load Interview history ({platform_name}): {e}")
return results
@classmethod
def get_interview_history(
cls,
simulation_id: str,
platform: str = None,
agent_id: Optional[int] = None,
limit: int = 100
) -> List[Dict[str, Any]]:
"""
Lấy lịch sử phỏng vấn từ database — dùng sau khi simulation đã chạy.
Đọc từ:
twitter_simulation.db (nếu platform="twitter" hoặc None)
reddit_simulation.db (nếu platform="reddit" hoặc None)
Kết quả sort theo timestamp giảm dần, giới hạn `limit` record.
Khi query kết hợp cả 2 platform, giới hạn tổng = limit (không phải limit×2).
Args:
platform: "twitter"/"reddit"/None (None = cả hai)
agent_id: Lọc theo agent cụ thể
limit: Số record tối đa trả về
"""
sim_dir = os.path.join(cls.RUN_STATE_DIR, simulation_id)
results = []
platforms = [platform] if platform in ("reddit", "twitter") else ["twitter", "reddit"]
for p in platforms:
db_path = os.path.join(sim_dir, f"{p}_simulation.db")
platform_results = cls._get_interview_history_from_db(
db_path=db_path,
platform_name=p,
agent_id=agent_id,
limit=limit
)
results.extend(platform_results)
# Sort tổng hợp theo timestamp mới nhất
results.sort(key=lambda x: x.get("timestamp", ""), reverse=True)
# Cắt về đúng limit khi query kết hợp nhiều platform
if len(platforms) > 1 and len(results) > limit:
results = results[:limit]
return results