fix: stop Step2 polling after preparation failure

This commit is contained in:
666ghj 2026-07-22 01:32:30 +08:00
parent 7970d74a86
commit 05bea4a330
6 changed files with 173 additions and 21 deletions

View File

@ -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
}

View File

@ -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 已经在生成过程中实时保存了,这里再保存一次确保完整性

View File

@ -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"]

View File

@ -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 }))
}
}

View File

@ -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",

View File

@ -521,6 +521,7 @@
"configLoadSuccess": "✓ 模拟配置加载成功",
"configSummaryPostsAlt": " └─ 初始帖子: {count}条",
"configGenerating": "配置生成中,开始轮询等待...",
"configNotGenerating": "模拟配置尚未生成,且后端已不再生成配置",
"loadConfigFailed": "加载配置失败: {error}",
"step2Init": "Step2 环境搭建初始化",
"step3Init": "Step3 模拟运行初始化",