diff --git a/backend/app/api/simulation.py b/backend/app/api/simulation.py index 0df3f43c..4adf627e 100644 --- a/backend/app/api/simulation.py +++ b/backend/app/api/simulation.py @@ -337,8 +337,9 @@ def _check_simulation_prepared(simulation_id: str) -> tuple: # - running: 正在运行,说明准备早就完成了 # - completed: 运行完成,说明准备早就完成了 # - stopped: 已停止,说明准备早就完成了 + # - paused: 手动停止后会写入 paused,配置仍然可复用 # - failed: 运行失败(但准备是完成的) - prepared_statuses = ["ready", "preparing", "running", "completed", "stopped", "failed"] + prepared_statuses = ["ready", "preparing", "running", "completed", "stopped", "paused", "failed"] if status in prepared_statuses and config_generated: # 获取文件统计信息 profiles_file = os.path.join(simulation_dir, "reddit_profiles.json") @@ -1691,6 +1692,40 @@ def start_simulation(): "error": t('api.graphIdRequiredForMemory') }), 400 + existing_run_state = SimulationRunner.get_run_state(simulation_id) + restartable_statuses = { + RunnerStatus.IDLE, + RunnerStatus.STOPPED, + RunnerStatus.COMPLETED, + RunnerStatus.FAILED, + } + if ( + existing_run_state + and existing_run_state.runner_status in restartable_statuses + ): + if ZepGraphMemoryManager.get_updater(simulation_id) is not None: + return jsonify({ + "success": False, + "error": ( + "The previous simulation still has pending graph " + "memory updates; finalize or reset it before restarting" + ), + }), 409 + logger.info( + f"清理已结束的旧运行记录后重新启动: " + f"simulation_id={simulation_id}, runner_status={existing_run_state.runner_status.value}" + ) + cleanup_result = SimulationRunner.cleanup_simulation_logs(simulation_id) + if not cleanup_result.get("success"): + return jsonify({ + "success": False, + "error": ( + "Failed to clean previous simulation logs: " + f"{cleanup_result.get('errors')}" + ), + }), 500 + force_restarted = True + graph_guard = ( graph_lifecycle_lock(graph_id) if enable_graph_memory_update diff --git a/backend/scripts/run_parallel_simulation.py b/backend/scripts/run_parallel_simulation.py index 9ec72f72..a6411931 100644 --- a/backend/scripts/run_parallel_simulation.py +++ b/backend/scripts/run_parallel_simulation.py @@ -608,7 +608,7 @@ def load_config(config_path: str) -> Dict[str, Any]: # 需要过滤掉的非核心动作类型(这些动作对分析价值较低) -FILTERED_ACTIONS = {'refresh', 'sign_up'} +FILTERED_ACTIONS = {'refresh', 'sign_up', 'do_nothing'} # 动作类型映射表(数据库中的名称 -> 标准名称) ACTION_TYPE_MAP = { diff --git a/backend/tests/test_zep_simulation_barrier.py b/backend/tests/test_zep_simulation_barrier.py index 63289fce..cb6878e6 100644 --- a/backend/tests/test_zep_simulation_barrier.py +++ b/backend/tests/test_zep_simulation_barrier.py @@ -270,6 +270,119 @@ def test_force_restart_does_not_continue_while_old_ingestion_is_pending(monkeypa assert cleanup_called == [] +def test_terminal_restart_cleans_old_logs_before_start(monkeypatch): + simulation = SimpleNamespace( + simulation_id="sim-terminal", + project_id="proj-1", + graph_id=None, + status=SimulationStatus.READY, + ) + events = [] + monkeypatch.setattr( + simulation_api, + "SimulationManager", + lambda: SimpleNamespace(get_simulation=lambda _simulation_id: simulation), + ) + monkeypatch.setattr( + simulation_api.SimulationRunner, + "get_run_state", + classmethod( + lambda _cls, _simulation_id: SimulationRunState( + simulation_id="sim-terminal", + runner_status=RunnerStatus.COMPLETED, + ) + ), + ) + monkeypatch.setattr( + simulation_api.SimulationRunner, + "cleanup_simulation_logs", + classmethod( + lambda _cls, _simulation_id: ( + events.append("cleanup") or {"success": True, "errors": []} + ) + ), + ) + monkeypatch.setattr( + simulation_api.SimulationRunner, + "start_simulation", + classmethod( + lambda _cls, **_kwargs: ( + events.append("start") + or SimulationRunState( + simulation_id="sim-terminal", + runner_status=RunnerStatus.STARTING, + ) + ) + ), + ) + monkeypatch.setattr( + simulation_api.ZepGraphMemoryManager, + "get_updater", + classmethod(lambda _cls, _simulation_id: None), + ) + + app = Flask(__name__) + with app.test_request_context( + "/api/simulation/start", + method="POST", + json={"simulation_id": "sim-terminal"}, + ): + response = simulation_api.start_simulation() + + assert response.status_code == 200 + assert response.get_json()["data"]["force_restarted"] is True + assert events == ["cleanup", "start"] + + +def test_terminal_restart_rejects_pending_graph_updates(monkeypatch): + simulation = SimpleNamespace( + simulation_id="sim-pending-terminal", + project_id="proj-1", + graph_id=None, + status=SimulationStatus.READY, + ) + cleanup_called = [] + monkeypatch.setattr( + simulation_api, + "SimulationManager", + lambda: SimpleNamespace(get_simulation=lambda _simulation_id: simulation), + ) + monkeypatch.setattr( + simulation_api.SimulationRunner, + "get_run_state", + classmethod( + lambda _cls, _simulation_id: SimulationRunState( + simulation_id="sim-pending-terminal", + runner_status=RunnerStatus.FAILED, + ) + ), + ) + monkeypatch.setattr( + simulation_api.SimulationRunner, + "cleanup_simulation_logs", + classmethod( + lambda _cls, _simulation_id: cleanup_called.append(True) + ), + ) + monkeypatch.setattr( + simulation_api.ZepGraphMemoryManager, + "get_updater", + classmethod(lambda _cls, _simulation_id: object()), + ) + + app = Flask(__name__) + with app.test_request_context( + "/api/simulation/start", + method="POST", + json={"simulation_id": "sim-pending-terminal"}, + ): + response, status = simulation_api.start_simulation() + + assert status == 409 + assert "pending graph memory updates" in response.get_json()["error"] + assert cleanup_called == [] + + def test_monitor_start_failure_terminates_the_spawned_process(monkeypatch, tmp_path): simulation_id = "sim-start-failure" sim_dir = tmp_path / "runs" / simulation_id @@ -482,3 +595,45 @@ def test_shutdown_drain_failure_remains_failed_and_retryable(monkeypatch): SimulationRunner._cleanup_done = False SimulationRunner._graph_memory_enabled.pop(simulation_id, None) SimulationRunner._manual_stop_requests.discard(simulation_id) + + +def test_shutdown_preserves_completed_state_without_pending_resources(monkeypatch): + simulation_id = "sim-shutdown-completed" + state = SimulationRunState( + simulation_id=simulation_id, + runner_status=RunnerStatus.COMPLETED, + completed_at="2026-07-23T00:00:00", + ) + + class FinishedProcess: + def poll(self): + return 0 + + monkeypatch.setattr( + SimulationRunner, + "get_run_state", + classmethod(lambda _cls, _simulation_id: state), + ) + monkeypatch.setattr( + runner_module.ZepGraphMemoryManager, + "get_simulation_ids", + classmethod(lambda _cls: []), + ) + monkeypatch.setattr( + runner_module.ZepGraphMemoryManager, + "get_updater", + classmethod(lambda _cls, _simulation_id: None), + ) + + SimulationRunner._cleanup_done = False + SimulationRunner._processes[simulation_id] = FinishedProcess() + SimulationRunner._graph_memory_enabled.pop(simulation_id, None) + try: + SimulationRunner.cleanup_all_simulations() + assert state.runner_status == RunnerStatus.COMPLETED + assert state.completed_at == "2026-07-23T00:00:00" + assert state.error is None + finally: + SimulationRunner._cleanup_done = False + SimulationRunner._processes.pop(simulation_id, None) + SimulationRunner._manual_stop_requests.discard(simulation_id) diff --git a/frontend/src/components/Step3Simulation.vue b/frontend/src/components/Step3Simulation.vue index e9dd3dc3..834fd678 100644 --- a/frontend/src/components/Step3Simulation.vue +++ b/frontend/src/components/Step3Simulation.vue @@ -106,9 +106,9 @@