diff --git a/src/engine/stockfish_service.py b/src/engine/stockfish_service.py index 4d90ff0..bad8e6e 100644 --- a/src/engine/stockfish_service.py +++ b/src/engine/stockfish_service.py @@ -1,7 +1,7 @@ from __future__ import annotations from pathlib import Path -from typing import Any +from typing import Any, Callable import chess import chess.engine @@ -84,6 +84,68 @@ def stop(self) -> None: finally: self.engine = None + def restart(self) -> None: + """Restart Stockfish process setelah engine mati/error.""" + self.stop() + self.start() + + def _recover_engine_after_failure( + self, + ) -> None: + """Buang handle engine lama dan coba start engine baru.""" + try: + self.stop() + finally: + self.start() + + def _with_engine_recovery( + self, + action: Callable[[], Any], + *, + context: str, + ) -> Any: + """ + Jalankan action dengan retry satu kali jika event loop/proses engine mati. + + Kadang setelah pipeline berat/self-play banyak Stockfish process, handle + engine UI bisa berada dalam state "engine event loop dead". Tanpa reset, + semua langkah bot berikutnya gagal terus sampai app direstart. + """ + self._ensure_started() + + try: + return action() + + except ( + TimeoutError, + chess.engine.EngineError, + chess.engine.EngineTerminatedError, + ) as first_exc: + first_message = str(first_exc).lower() + should_retry = ( + isinstance( + first_exc, + chess.engine.EngineTerminatedError, + ) + or "event loop dead" in first_message + or "engine process died" in first_message + or "engine terminated" in first_message + ) + + if not should_retry: + raise StockfishServiceError( + f"{context}: {first_exc}" + ) from first_exc + + try: + self._recover_engine_after_failure() + return action() + + except Exception as second_exc: + raise StockfishServiceError( + f"{context}: {first_exc}; restart gagal: {second_exc}" + ) from second_exc + def get_engine_name(self) -> str: """Mengambil nama engine yang sedang aktif.""" self._ensure_started() @@ -109,10 +171,6 @@ def analyze( - positif: putih unggul - negatif: hitam unggul """ - self._ensure_started() - - assert self.engine is not None - if time_limit <= 0: raise ValueError( "Waktu analisis harus lebih besar dari 0." @@ -123,8 +181,9 @@ def analyze( "Jumlah kandidat harus lebih besar dari 0." ) - try: - analysis = self.engine.analyse( + def run_analysis() -> Any: + assert self.engine is not None + return self.engine.analyse( board, chess.engine.Limit( time=time_limit, @@ -132,14 +191,10 @@ def analyze( multipv=multipv, ) - except ( - TimeoutError, - chess.engine.EngineError, - chess.engine.EngineTerminatedError, - ) as exc: - raise StockfishServiceError( - f"Analisis Stockfish gagal: {exc}" - ) from exc + analysis = self._with_engine_recovery( + run_analysis, + context="Analisis Stockfish gagal", + ) if isinstance(analysis, dict): analysis = [analysis] @@ -195,10 +250,6 @@ def evaluate_move( Digunakan ketika langkah aktual pemain tidak masuk ke daftar kandidat MultiPV. """ - self._ensure_started() - - assert self.engine is not None - if move not in board.legal_moves: raise ValueError( f"Langkah tidak legal pada posisi ini: {move.uci()}" @@ -209,8 +260,9 @@ def evaluate_move( "Waktu analisis harus lebih besar dari 0." ) - try: - info = self.engine.analyse( + def run_analysis() -> Any: + assert self.engine is not None + return self.engine.analyse( board, chess.engine.Limit( time=time_limit, @@ -218,14 +270,10 @@ def evaluate_move( root_moves=[move], ) - except ( - TimeoutError, - chess.engine.EngineError, - chess.engine.EngineTerminatedError, - ) as exc: - raise StockfishServiceError( - f"Evaluasi langkah gagal: {exc}" - ) from exc + info = self._with_engine_recovery( + run_analysis, + context="Evaluasi langkah gagal", + ) score = info["score"].pov( chess.WHITE @@ -285,7 +333,7 @@ def _ensure_started(self) -> None: "Panggil start() terlebih dahulu." ) - def __enter__(self) -> StockfishService: + def __enter__(self) -> "StockfishService": """Menjalankan engine ketika masuk context manager.""" self.start() return self @@ -297,4 +345,4 @@ def __exit__( traceback: object, ) -> None: """Menghentikan engine ketika keluar context manager.""" - self.stop() \ No newline at end of file + self.stop() diff --git a/src/ui/main_window_parts/persona.py b/src/ui/main_window_parts/persona.py index 4c1de1f..bf36f53 100644 --- a/src/ui/main_window_parts/persona.py +++ b/src/ui/main_window_parts/persona.py @@ -318,6 +318,7 @@ def _load_persona( self._update_control_states() + def _start_persona_creation( self, ) -> None: @@ -351,7 +352,7 @@ def _start_persona_creation( preset_key = ( self.analysis_preset_combo.currentData() - or "quick" + or "full" ) preset_key_text = str( @@ -362,6 +363,26 @@ def _start_persona_creation( preset_key_text ] + selected_time_class = ( + self.time_class_combo.currentText() + or getattr( + self, + "time_class", + "rapid", + ) + or "rapid" + ) + + selected_time_class = str( + selected_time_class + ).strip().lower() + + if selected_time_class not in { + "rapid", + "blitz", + }: + selected_time_class = "rapid" + self.persona_build_busy = True self.chess_board.set_interactive( @@ -370,13 +391,21 @@ def _start_persona_creation( self.persona_dialog = PersonaProgressDialog( username=username, - preset_label=preset.label, + preset_label=( + f"{preset.label} — Full runtime pipeline" + ), parent=self, ) self.persona_thread = PersonaCreationThread( username=username, preset_key=preset_key_text, + time_class=selected_time_class, + engine_path=getattr( + self.engine, + "engine_path", + "engines/stockfish/stockfish.exe", + ), parent=self, ) @@ -410,6 +439,7 @@ def _start_persona_creation( self.persona_thread.start() + def _on_persona_creation_completed( self, result: object, @@ -430,10 +460,55 @@ def _on_persona_creation_completed( ) ) + readiness_ready = bool( + getattr( + result, + "readiness_ready", + False, + ) + ) + + recommended_policy = str( + getattr( + result, + "recommended_policy", + "", + ) + or "" + ) + + warning = str( + getattr( + result, + "warning", + "", + ) + or "" + ) + if self.persona_dialog is not None: + if readiness_ready: + final_message = ( + f"Full pipeline selesai: {username} — " + f"{', '.join(completed_modes)}. " + f"Runtime READY: {recommended_policy}" + ) + else: + final_message = ( + f"Persona {username} selesai: " + f"{', '.join(completed_modes)}. " + "Runtime belum ready; UI akan fallback aman." + ) + + if warning: + final_message = ( + final_message + + "\n" + + warning + ) + self.persona_dialog.mark_complete( - f"Persona {username} selesai: " - f"{', '.join(completed_modes)}" + final_message ) self.persona_build_busy = False diff --git a/src/ui/persona_creation_worker.py b/src/ui/persona_creation_worker.py index a71aa10..1533eda 100644 --- a/src/ui/persona_creation_worker.py +++ b/src/ui/persona_creation_worker.py @@ -1,21 +1,35 @@ from __future__ import annotations +import subprocess import threading from PySide6.QtCore import QThread, Signal -from src.persona.persona_builder import ( - PersonaBuildCancelled, - PersonaBuilder, +from src.engine.stockfish_service import DEFAULT_ENGINE_PATH +from src.persona.persona_builder import PersonaBuildCancelled +from src.runtime.readiness import ( + build_runtime_readiness_report, + render_runtime_readiness_report, +) +from src.ui.unified_persona_pipeline import ( + UnifiedPersonaPipelineConfig, + UnifiedPersonaPipelineResult, + build_unified_full_pipeline_command, + complete_persona_exists, + disk_space_status_for_pipeline, + normalize_pipeline_time_class, + normalize_pipeline_username, + progress_percent_for_pipeline_line, ) class PersonaCreationThread(QThread): """ - Menjalankan pipeline pembuatan persona di thread terpisah. + Menjalankan unified full pipeline di thread terpisah. - Dengan ini UI tetap dapat digunakan dan tidak berstatus - "Not Responding" selama analisis Stockfish berjalan. + Flow: + download games -> analyze/build persona -> Direct Ranker pipeline + -> elite evidence gate when needed -> runtime readiness check. """ progress = Signal(int, str) @@ -27,19 +41,32 @@ def __init__( self, username: str, preset_key: str, + time_class: str = "rapid", + engine_path: object = DEFAULT_ENGINE_PATH, parent: object | None = None, ) -> None: super().__init__(parent) - self.username = username - self.preset_key = preset_key + self.username = normalize_pipeline_username(username) + self.preset_key = str(preset_key or "full") + self.time_class = normalize_pipeline_time_class(time_class) + self.engine_path = str(engine_path or DEFAULT_ENGINE_PATH) self._cancel_event = threading.Event() + self._process: subprocess.Popen[str] | None = None + self._recent_lines: list[str] = [] def cancel(self) -> None: - """Meminta proses berhenti pada checkpoint berikutnya.""" + """Meminta proses berhenti dan terminate subprocess bila sedang aktif.""" self._cancel_event.set() + process = self._process + if process is not None and process.poll() is None: + try: + process.terminate() + except OSError: + pass + def is_cancel_requested(self) -> bool: return self._cancel_event.is_set() @@ -53,20 +80,161 @@ def _report_progress( message, ) - def run(self) -> None: - builder = PersonaBuilder() + def _run_subprocess( + self, + command: tuple[str, ...], + ) -> int: + self._report_progress( + 1, + "Starting unified full persona pipeline...", + ) + self._report_progress( + 2, + "Command: " + " ".join(command), + ) + + process = subprocess.Popen( + list(command), + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + encoding="utf-8", + errors="replace", + bufsize=1, + ) + self._process = process + + assert process.stdout is not None + + for raw_line in process.stdout: + line = raw_line.rstrip() + if not line: + continue + + if self.is_cancel_requested(): + self.cancel() + raise PersonaBuildCancelled() + + self._recent_lines.append(line) + self._recent_lines = self._recent_lines[-40:] + + self._report_progress( + progress_percent_for_pipeline_line(line), + line, + ) + + return_code = int(process.wait()) + self._process = None + + if self.is_cancel_requested(): + raise PersonaBuildCancelled() + + return return_code + def _build_readiness_result( + self, + pipeline_returncode: int, + ) -> UnifiedPersonaPipelineResult: + report = build_runtime_readiness_report( + username=self.username, + time_class=self.time_class, + engine_path=self.engine_path, + ) + rendered = render_runtime_readiness_report(report) + + for line in rendered.splitlines(): + if line.strip(): + self._report_progress( + 98, + line, + ) + + warning = "" + if pipeline_returncode != 0: + warning = ( + "Full pipeline ended with a non-zero exit code, " + "but complete persona output exists; UI will load it " + "and keep runtime policy safe according to readiness." + ) + + return UnifiedPersonaPipelineResult( + username=self.username, + completed_modes=(self.time_class,), + time_class=self.time_class, + pipeline_returncode=pipeline_returncode, + readiness_ready=bool(report.ready), + recommended_policy=report.recommendation.move_policy_label, + move_policy=report.recommendation.move_policy, + readiness_report_text=rendered, + warning=warning, + ) + + def _disk_full_failure_message(self) -> str: + recent_text = "\n".join( + self._recent_lines + ).lower() + + if ( + "no space left on device" in recent_text + or "errno 28" in recent_text + ): + return ( + "Disk penuh saat full pipeline menulis report. " + "Kosongkan minimal 10 GB di drive project, lalu " + "jalankan Create / Update lagi. Checkpoint yang " + "sudah selesai tetap tersimpan." + ) + + return "" + + def run(self) -> None: try: - result = builder.build( + disk_status = disk_space_status_for_pipeline( + self.preset_key + ) + self._report_progress( + 1, + disk_status.message, + ) + if not disk_status.ok: + self.failed.emit( + disk_status.message + ) + return + + config = UnifiedPersonaPipelineConfig( username=self.username, + time_class=self.time_class, preset_key=self.preset_key, - progress_callback=( - self._report_progress - ), - cancel_callback=( - self.is_cancel_requested - ), + engine_path=self.engine_path, ) + command = build_unified_full_pipeline_command(config) + + return_code = self._run_subprocess(command) + + persona_ready = complete_persona_exists( + self.username, + self.time_class, + ) + + if return_code != 0 and not persona_ready: + disk_message = self._disk_full_failure_message() + if disk_message: + self.failed.emit(disk_message) + else: + self.failed.emit( + "Unified full pipeline failed before a playable " + f"persona was produced (exit code {return_code})." + ) + return + + result = self._build_readiness_result(return_code) + + if result.warning: + self._report_progress( + 99, + result.warning, + ) except PersonaBuildCancelled: self.cancelled.emit() @@ -78,4 +246,4 @@ def run(self) -> None: ) return - self.completed.emit(result) \ No newline at end of file + self.completed.emit(result) diff --git a/src/ui/unified_persona_pipeline.py b/src/ui/unified_persona_pipeline.py new file mode 100644 index 0000000..e9d2c4c --- /dev/null +++ b/src/ui/unified_persona_pipeline.py @@ -0,0 +1,253 @@ +from __future__ import annotations + +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Sequence + +from src.engine.stockfish_service import DEFAULT_ENGINE_PATH +from src.persona.persona_builder import ANALYSIS_PRESETS + + +DEFAULT_GAMES_BY_PRESET = { + "quick": 100, + "standard": 300, + "full": 500, +} + + +@dataclass(frozen=True) +class UnifiedPersonaPipelineConfig: + username: str + time_class: str + preset_key: str = "full" + engine_path: Path | str = DEFAULT_ENGINE_PATH + python_executable: str = sys.executable + run_script: str = "run_full_persona_pipeline.py" + execute: bool = True + yes: bool = True + adaptive_direct_games: bool = True + + +@dataclass(frozen=True) +class UnifiedPersonaPipelineResult: + username: str + completed_modes: tuple[str, ...] + time_class: str + pipeline_returncode: int + readiness_ready: bool + recommended_policy: str + move_policy: str + readiness_report_text: str + warning: str = "" + + +@dataclass(frozen=True) +class DiskSpaceStatus: + root: Path + free_gb: float + required_gb: float + ok: bool + message: str + + +def normalize_pipeline_username(username: object) -> str: + clean = str(username or "").strip().lower() + if not clean: + raise ValueError("Username tidak boleh kosong.") + return clean + + +def normalize_pipeline_time_class(time_class: object) -> str: + clean = str(time_class or "").strip().lower() + if clean not in {"rapid", "blitz"}: + return "rapid" + return clean + + +def normalize_preset_key(preset_key: object) -> str: + clean = str(preset_key or "").strip().lower() + if clean in ANALYSIS_PRESETS: + return clean + return "full" + + +def _preset_int( + preset: object, + names: Sequence[str], + default: int, +) -> int: + for name in names: + value = getattr(preset, name, None) + if value is None: + continue + try: + parsed = int(value) + except (TypeError, ValueError): + continue + if parsed > 0: + return parsed + return int(default) + + +def resolve_pipeline_counts( + preset_key: object, +) -> tuple[int, int, int]: + key = normalize_preset_key(preset_key) + preset = ANALYSIS_PRESETS[key] + default_games = DEFAULT_GAMES_BY_PRESET.get(key, 500) + + games = _preset_int( + preset, + ("max_games", "target_games", "games", "download_games"), + default_games, + ) + move_quality_games = _preset_int( + preset, + ( + "move_quality_games", + "quality_games", + "analysis_games", + "target_games", + "max_games", + ), + games, + ) + + direct_games = max( + 100, + min(games, 310), + ) + + return games, move_quality_games, direct_games + + +def build_unified_full_pipeline_command( + config: UnifiedPersonaPipelineConfig, +) -> tuple[str, ...]: + username = normalize_pipeline_username(config.username) + time_class = normalize_pipeline_time_class(config.time_class) + preset_key = normalize_preset_key(config.preset_key) + games, move_quality_games, direct_games = resolve_pipeline_counts(preset_key) + + command: list[str] = [ + str(config.python_executable), + str(config.run_script), + "--username", + username, + "--time-class", + time_class, + "--games", + str(games), + "--move-quality-games", + str(move_quality_games), + "--direct-games", + str(direct_games), + "--engine-path", + str(config.engine_path), + ] + + if config.adaptive_direct_games: + command.append("--adaptive-direct-games") + + if config.execute: + command.append("--execute") + + if config.yes: + command.append("--yes") + + return tuple(command) + + +def progress_percent_for_pipeline_line(line: object) -> int: + text = str(line or "").strip().lower() + + if "download" in text or "download_games" in text: + return 8 + if "analyze_persona" in text or "behavioral profile" in text: + return 18 + if "describe_persona" in text or "persona description" in text: + return 25 + if "analyze_move_quality" in text or "move quality" in text: + return 38 + if "build_complete_persona" in text or "complete persona" in text: + return 50 + if "persona_preflight" in text or "preflight" in text: + return 57 + if "direct_ranker_pipeline" in text or "direct ranker" in text: + return 68 + if "temporal" in text or "release gate" in text: + return 78 + if "elite_guarded_selfplay" in text or "self-play" in text: + return 86 + if "elite_guarded_evidence" in text or "elite evidence" in text: + return 92 + if "runtime readiness" in text or "ui runtime" in text: + return 97 + if "pipeline selesai" in text or "final status" in text: + return 95 + + return 5 + + +def minimum_free_gb_for_preset(preset_key: object) -> float: + key = normalize_preset_key(preset_key) + if key == "quick": + return 3.0 + if key == "standard": + return 6.0 + return 10.0 + + +def disk_space_status_for_pipeline( + preset_key: object, + root: Path | str = ".", +) -> DiskSpaceStatus: + import shutil + + resolved_root = Path(root).resolve() + usage = shutil.disk_usage(resolved_root) + free_gb = usage.free / (1024 ** 3) + required_gb = minimum_free_gb_for_preset(preset_key) + ok = free_gb >= required_gb + + if ok: + message = ( + f"Disk space OK: {free_gb:.2f} GB free " + f"(minimum {required_gb:.2f} GB)." + ) + else: + message = ( + f"Disk space too low: {free_gb:.2f} GB free; " + f"need at least {required_gb:.2f} GB for this pipeline. " + "Free disk space or choose a smaller Analysis preset." + ) + + return DiskSpaceStatus( + root=resolved_root, + free_gb=free_gb, + required_gb=required_gb, + ok=ok, + message=message, + ) + + +def expected_complete_persona_path( + username: object, + time_class: object, +) -> Path: + return ( + Path("data/personas") + / normalize_pipeline_username(username) + / f"{normalize_pipeline_time_class(time_class)}_complete_persona.json" + ) + + +def complete_persona_exists( + username: object, + time_class: object, +) -> bool: + return expected_complete_persona_path( + username, + time_class, + ).exists() diff --git a/tests/test_stockfish_service_recovery.py b/tests/test_stockfish_service_recovery.py new file mode 100644 index 0000000..1549a85 --- /dev/null +++ b/tests/test_stockfish_service_recovery.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +import chess +import chess.engine +import pytest + +from src.engine.stockfish_service import ( + StockfishService, + StockfishServiceError, +) + + +class RecoveringFakeEngine: + def __init__(self) -> None: + self.calls = 0 + self.id = {"name": "Fakefish"} + + def analyse(self, board, limit, multipv=None, root_moves=None): + self.calls += 1 + if self.calls == 1: + raise chess.engine.EngineTerminatedError( + "engine event loop dead" + ) + + move = next(iter(board.legal_moves)) + return [ + { + "pv": [move], + "score": chess.engine.PovScore( + chess.engine.Cp(25), + chess.WHITE, + ), + "depth": 1, + "nodes": 1, + } + ] + + def quit(self): + return None + + def close(self): + return None + + +class AlwaysDeadFakeEngine: + id = {"name": "Deadfish"} + + def analyse(self, board, limit, multipv=None, root_moves=None): + raise chess.engine.EngineTerminatedError( + "engine event loop dead" + ) + + def quit(self): + return None + + def close(self): + return None + + +def test_stockfish_service_recovers_once_when_event_loop_dead(monkeypatch) -> None: + service = StockfishService() + service.engine = RecoveringFakeEngine() + + recovered = {"called": False} + + def fake_recover() -> None: + recovered["called"] = True + + monkeypatch.setattr( + service, + "_recover_engine_after_failure", + fake_recover, + ) + + candidates = service.analyze( + chess.Board(), + time_limit=0.01, + multipv=1, + ) + + assert recovered["called"] is True + assert candidates + assert candidates[0]["uci"] + + +def test_stockfish_service_reports_clear_error_when_recovery_fails(monkeypatch) -> None: + service = StockfishService() + service.engine = AlwaysDeadFakeEngine() + + def fake_recover() -> None: + service.engine = AlwaysDeadFakeEngine() + + monkeypatch.setattr( + service, + "_recover_engine_after_failure", + fake_recover, + ) + + with pytest.raises(StockfishServiceError) as exc_info: + service.analyze( + chess.Board(), + time_limit=0.01, + multipv=1, + ) + + assert "restart gagal" in str(exc_info.value) diff --git a/tests/test_ui_unified_persona_pipeline.py b/tests/test_ui_unified_persona_pipeline.py new file mode 100644 index 0000000..72bcd22 --- /dev/null +++ b/tests/test_ui_unified_persona_pipeline.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +from pathlib import Path + +from src.ui.unified_persona_pipeline import ( + UnifiedPersonaPipelineConfig, + build_unified_full_pipeline_command, + complete_persona_exists, + disk_space_status_for_pipeline, + expected_complete_persona_path, + normalize_pipeline_time_class, + progress_percent_for_pipeline_line, + resolve_pipeline_counts, +) + + +def test_build_unified_full_pipeline_command_includes_full_flow_flags() -> None: + command = build_unified_full_pipeline_command( + UnifiedPersonaPipelineConfig( + username="Hikaru", + time_class="rapid", + preset_key="full", + engine_path=Path("engines/stockfish/stockfish.exe"), + python_executable="python", + ) + ) + + assert command[:2] == ("python", "run_full_persona_pipeline.py") + assert "--username" in command + assert "hikaru" in command + assert "--time-class" in command + assert "rapid" in command + assert "--adaptive-direct-games" in command + assert "--execute" in command + assert "--yes" in command + assert "--skip-direct-ranker" not in command + assert "--skip-move-quality" not in command + + +def test_normalize_pipeline_time_class_defaults_to_rapid() -> None: + assert normalize_pipeline_time_class("blitz") == "blitz" + assert normalize_pipeline_time_class("daily") == "rapid" + assert normalize_pipeline_time_class("") == "rapid" + + +def test_resolve_pipeline_counts_are_positive() -> None: + games, move_quality_games, direct_games = resolve_pipeline_counts("quick") + + assert games > 0 + assert move_quality_games > 0 + assert direct_games > 0 + assert direct_games <= max(games, 310) + + +def test_progress_percent_for_pipeline_line_is_stage_hint() -> None: + assert progress_percent_for_pipeline_line("download_games") >= 8 + assert progress_percent_for_pipeline_line("analyze_move_quality") >= 38 + assert progress_percent_for_pipeline_line("direct_ranker_pipeline") >= 68 + assert progress_percent_for_pipeline_line("elite_guarded_evidence") >= 92 + + +def test_expected_complete_persona_path_and_exists( + tmp_path: Path, + monkeypatch, +) -> None: + monkeypatch.chdir(tmp_path) + + path = expected_complete_persona_path("Hikaru", "Rapid") + assert path == Path("data/personas/hikaru/rapid_complete_persona.json") + assert complete_persona_exists("hikaru", "rapid") is False + + path.parent.mkdir(parents=True) + path.write_text("{}", encoding="utf-8") + + assert complete_persona_exists("hikaru", "rapid") is True + + +def test_disk_space_status_for_pipeline_reports_requirement( + tmp_path: Path, +) -> None: + status = disk_space_status_for_pipeline( + "full", + root=tmp_path, + ) + + assert status.root == tmp_path.resolve() + assert status.required_gb >= 10.0 + assert status.free_gb >= 0.0 + assert isinstance(status.ok, bool)