From 05bea4a330b5f2199516598a39b904cca80ea102 Mon Sep 17 00:00:00 2001 From: 666ghj <670939375@qq.com> Date: Wed, 22 Jul 2026 01:32:30 +0800 Subject: [PATCH] fix: stop Step2 polling after preparation failure --- backend/app/api/simulation.py | 34 ++++-- backend/app/services/simulation_manager.py | 12 ++- .../tests/test_simulation_prepare_failure.py | 102 ++++++++++++++++++ frontend/src/components/Step2EnvSetup.vue | 44 +++++--- locales/en.json | 1 + locales/zh.json | 1 + 6 files changed, 173 insertions(+), 21 deletions(-) create mode 100644 backend/tests/test_simulation_prepare_failure.py diff --git a/backend/app/api/simulation.py b/backend/app/api/simulation.py index 342557cf..54842436 100644 --- a/backend/app/api/simulation.py +++ b/backend/app/api/simulation.py @@ -612,12 +612,17 @@ def prepare_simulation(): progress_callback=progress_callback, parallel_profile_count=parallel_profile_count ) - - # 任务完成 - task_manager.complete_task( - task_id, - result=result_state.to_simple_dict() - ) + + if result_state.status == SimulationStatus.FAILED: + task_manager.fail_task( + task_id, + result_state.error or "模拟准备失败" + ) + else: + task_manager.complete_task( + task_id, + result=result_state.to_simple_dict() + ) except Exception as e: logger.error(f"准备模拟失败: {str(e)}") @@ -1123,6 +1128,8 @@ def get_simulation_profiles_realtime(simulation_id: str): # 检查是否正在生成(通过 state.json 判断) is_generating = False total_expected = None + status = None + error = None state_file = os.path.join(sim_dir, "state.json") if os.path.exists(state_file): @@ -1132,6 +1139,7 @@ def get_simulation_profiles_realtime(simulation_id: str): status = state_data.get("status", "") is_generating = status == "preparing" total_expected = state_data.get("entities_count") + error = state_data.get("error") except Exception: pass @@ -1143,6 +1151,8 @@ def get_simulation_profiles_realtime(simulation_id: str): "count": len(profiles), "total_expected": total_expected, "is_generating": is_generating, + "status": status, + "error": error, "file_exists": file_exists, "file_modified_at": file_modified_at, "profiles": profiles @@ -1218,6 +1228,9 @@ def get_simulation_config_realtime(simulation_id: str): # 检查是否正在生成(通过 state.json 判断) is_generating = False generation_stage = None + status = None + error = None + profiles_generated = False config_generated = False state_file = os.path.join(sim_dir, "state.json") @@ -1226,17 +1239,21 @@ def get_simulation_config_realtime(simulation_id: str): with open(state_file, 'r', encoding='utf-8') as f: state_data = json.load(f) status = state_data.get("status", "") + error = state_data.get("error") is_generating = status == "preparing" + profiles_generated = state_data.get("profiles_generated", False) config_generated = state_data.get("config_generated", False) # 判断当前阶段 if is_generating: - if state_data.get("profiles_generated", False): + if profiles_generated: generation_stage = "generating_config" else: generation_stage = "generating_profiles" elif status == "ready": generation_stage = "completed" + elif status == "failed": + generation_stage = "failed" except Exception: pass @@ -1246,7 +1263,10 @@ def get_simulation_config_realtime(simulation_id: str): "file_exists": file_exists, "file_modified_at": file_modified_at, "is_generating": is_generating, + "status": status, + "error": error, "generation_stage": generation_stage, + "profiles_generated": profiles_generated, "config_generated": config_generated, "config": config } diff --git a/backend/app/services/simulation_manager.py b/backend/app/services/simulation_manager.py index ff63d020..76dc0c9a 100644 --- a/backend/app/services/simulation_manager.py +++ b/backend/app/services/simulation_manager.py @@ -60,6 +60,7 @@ class SimulationState: entity_types: List[str] = field(default_factory=list) # 配置生成信息 + profiles_generated: bool = False config_generated: bool = False config_reasoning: str = "" @@ -87,6 +88,7 @@ class SimulationState: "entities_count": self.entities_count, "profiles_count": self.profiles_count, "entity_types": self.entity_types, + "profiles_generated": self.profiles_generated, "config_generated": self.config_generated, "config_reasoning": self.config_reasoning, "current_round": self.current_round, @@ -116,6 +118,7 @@ class SimulationState: "entities_count": self.entities_count, "profiles_count": self.profiles_count, "entity_types": self.entity_types, + "profiles_generated": self.profiles_generated, "config_generated": self.config_generated, "error": self.error, } @@ -187,6 +190,7 @@ class SimulationManager: entities_count=data.get("entities_count", 0), profiles_count=data.get("profiles_count", 0), entity_types=data.get("entity_types", []), + profiles_generated=data.get("profiles_generated", False), config_generated=data.get("config_generated", False), config_reasoning=data.get("config_reasoning", ""), current_round=data.get("current_round", 0), @@ -274,6 +278,10 @@ class SimulationManager: try: state.status = SimulationStatus.PREPARING + state.error = None + state.profiles_generated = False + state.config_generated = False + state.config_reasoning = "" self._save_simulation_state(state) sim_dir = self._get_simulation_dir(simulation_id) @@ -308,7 +316,7 @@ class SimulationManager: state.status = SimulationStatus.FAILED state.error = "没有找到符合条件的实体,请检查图谱是否正确构建" self._save_simulation_state(state) - return state + raise ValueError(state.error) # ========== 阶段2: 生成Agent Profile ========== total_entities = len(filtered.entities) @@ -356,6 +364,8 @@ class SimulationManager: ) state.profiles_count = len(profiles) + state.profiles_generated = len(profiles) > 0 + self._save_simulation_state(state) # 保存Profile文件(注意:Twitter使用CSV格式,Reddit使用JSON格式) # Reddit 已经在生成过程中实时保存了,这里再保存一次确保完整性 diff --git a/backend/tests/test_simulation_prepare_failure.py b/backend/tests/test_simulation_prepare_failure.py new file mode 100644 index 00000000..48db179a --- /dev/null +++ b/backend/tests/test_simulation_prepare_failure.py @@ -0,0 +1,102 @@ +import json + +import pytest + +from app import create_app +from app.config import Config +from app.services import simulation_manager as simulation_manager_module +from app.services.simulation_manager import ( + SimulationManager, + SimulationState, + SimulationStatus, +) +from app.services.zep_entity_reader import FilteredEntities + + +def _write_failed_state(root, simulation_id="sim_failed"): + sim_dir = root / simulation_id + sim_dir.mkdir(parents=True) + (sim_dir / "state.json").write_text( + json.dumps( + { + "status": "failed", + "error": "no usable entities", + "entities_count": 0, + "profiles_generated": False, + "config_generated": False, + } + ), + encoding="utf-8", + ) + return simulation_id + + +def test_realtime_endpoints_expose_terminal_failure(tmp_path, monkeypatch): + simulation_id = _write_failed_state(tmp_path) + monkeypatch.setattr(Config, "OASIS_SIMULATION_DATA_DIR", str(tmp_path)) + + app = create_app() + app.config.update(TESTING=True) + client = app.test_client() + + config_response = client.get(f"/api/simulation/{simulation_id}/config/realtime") + profile_response = client.get(f"/api/simulation/{simulation_id}/profiles/realtime") + + assert config_response.status_code == 200 + expected_config_state = { + "status": "failed", + "error": "no usable entities", + "generation_stage": "failed", + "profiles_generated": False, + "config_generated": False, + "is_generating": False, + } + for key, expected in expected_config_state.items(): + assert config_response.json["data"][key] == expected + assert profile_response.status_code == 200 + assert profile_response.json["data"]["status"] == "failed" + assert profile_response.json["data"]["error"] == "no usable entities" + assert profile_response.json["data"]["is_generating"] is False + + +def test_zero_entities_persists_failed_state_and_raises(tmp_path, monkeypatch): + class EmptyReader: + def filter_defined_entities(self, **kwargs): + return FilteredEntities( + entities=[], + entity_types=set(), + total_count=3, + filtered_count=0, + ) + + monkeypatch.setattr(SimulationManager, "SIMULATION_DATA_DIR", str(tmp_path)) + monkeypatch.setattr(simulation_manager_module, "ZepEntityReader", EmptyReader) + + manager = SimulationManager() + state = SimulationState( + simulation_id="sim_empty", + project_id="project", + graph_id="graph", + status=SimulationStatus.READY, + profiles_generated=True, + config_generated=True, + config_reasoning="stale", + error="stale", + ) + manager._save_simulation_state(state) + + with pytest.raises(ValueError, match="没有找到符合条件的实体"): + manager.prepare_simulation( + simulation_id=state.simulation_id, + simulation_requirement="requirement", + document_text="document", + ) + + persisted = json.loads( + (tmp_path / state.simulation_id / "state.json").read_text(encoding="utf-8") + ) + assert persisted["status"] == "failed" + assert persisted["profiles_generated"] is False + assert persisted["config_generated"] is False + assert persisted["config_reasoning"] == "" + assert "没有找到符合条件的实体" in persisted["error"] diff --git a/frontend/src/components/Step2EnvSetup.vue b/frontend/src/components/Step2EnvSetup.vue index 3a962de8..4ed1474b 100644 --- a/frontend/src/components/Step2EnvSetup.vue +++ b/frontend/src/components/Step2EnvSetup.vue @@ -740,6 +740,14 @@ const addLog = (msg) => { emit('add-log', msg) } +const handlePrepareFailure = (message) => { + stopPolling() + stopProfilesPolling() + stopConfigPolling() + addLog(t('log.prepareFailedWithError', { error: message || t('common.unknownError') })) + emit('update-status', 'error') +} + // 处理开始模拟按钮点击 const handleStartSimulation = () => { // 构建传递给父组件的参数 @@ -898,9 +906,7 @@ const pollPrepareStatus = async () => { stopProfilesPolling() await loadPreparedData() } else if (data.status === 'failed') { - addLog(t('log.prepareFailedWithError', { error: data.error || t('common.unknownError') })) - stopPolling() - stopProfilesPolling() + handlePrepareFailure(data.error) } } } catch (err) { @@ -972,6 +978,11 @@ const fetchConfigRealtime = async () => { if (res.success && res.data) { const data = res.data + + if (data.status === 'failed' || data.error) { + handlePrepareFailure(data.error) + return + } // 输出配置生成阶段日志(避免重复) if (data.generation_stage && data.generation_stage !== lastLoggedConfigStage) { @@ -1032,29 +1043,36 @@ const loadPreparedData = async () => { try { const res = await getSimulationConfigRealtime(props.simulationId) if (res.success && res.data) { - if (res.data.config_generated && res.data.config) { - simulationConfig.value = res.data.config + const configState = res.data + + if (configState.status === 'failed' || configState.error) { + handlePrepareFailure(configState.error) + return + } + + if (configState.config_generated && configState.config) { + simulationConfig.value = configState.config addLog(t('log.configLoadSuccess')) // 显示详细配置摘要 - if (res.data.summary) { - addLog(t('log.configSummaryAgents', { count: res.data.summary.total_agents })) - addLog(t('log.configSummaryHours', { hours: res.data.summary.simulation_hours })) - addLog(t('log.configSummaryPostsAlt', { count: res.data.summary.initial_posts_count })) + if (configState.summary) { + addLog(t('log.configSummaryAgents', { count: configState.summary.total_agents })) + addLog(t('log.configSummaryHours', { hours: configState.summary.simulation_hours })) + addLog(t('log.configSummaryPostsAlt', { count: configState.summary.initial_posts_count })) } addLog(t('log.envSetupComplete')) phase.value = 4 emit('update-status', 'completed') - } else { - // 配置尚未生成,开始轮询 + } else if (configState.is_generating) { addLog(t('log.configGenerating')) startConfigPolling() + } else { + handlePrepareFailure(t('log.configNotGenerating')) } } } catch (err) { - addLog(t('log.loadConfigFailed', { error: err.message })) - emit('update-status', 'error') + handlePrepareFailure(t('log.loadConfigFailed', { error: err.message })) } } diff --git a/locales/en.json b/locales/en.json index c97a5b2c..02679955 100644 --- a/locales/en.json +++ b/locales/en.json @@ -521,6 +521,7 @@ "configLoadSuccess": "✓ Simulation config loaded", "configSummaryPostsAlt": " └─ Initial posts: {count}", "configGenerating": "Config generating, polling...", + "configNotGenerating": "Simulation config is missing and the backend is no longer generating it", "loadConfigFailed": "Failed to load config: {error}", "step2Init": "Step 2 environment setup initialized", "step3Init": "Step 3 simulation run initialized", diff --git a/locales/zh.json b/locales/zh.json index cde0f70e..c5937dcc 100644 --- a/locales/zh.json +++ b/locales/zh.json @@ -521,6 +521,7 @@ "configLoadSuccess": "✓ 模拟配置加载成功", "configSummaryPostsAlt": " └─ 初始帖子: {count}条", "configGenerating": "配置生成中,开始轮询等待...", + "configNotGenerating": "模拟配置尚未生成,且后端已不再生成配置", "loadConfigFailed": "加载配置失败: {error}", "step2Init": "Step2 环境搭建初始化", "step3Init": "Step3 模拟运行初始化",