feat(recovery): persist active_task_id to project.json for browser-refresh reconnection

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ubuntu 2026-04-26 14:54:44 +00:00
parent 656a3d7d5c
commit 117eabf607
8 changed files with 57 additions and 10 deletions

View File

@ -489,6 +489,7 @@ def build_graph():
# Update project status # Update project status
project.status = ProjectStatus.GRAPH_BUILDING project.status = ProjectStatus.GRAPH_BUILDING
project.graph_build_task_id = task_id project.graph_build_task_id = task_id
project.active_task_id = task_id
ProjectManager.save_project(project) ProjectManager.save_project(project)
# Capture locale before spawning background thread # Capture locale before spawning background thread
@ -592,6 +593,7 @@ def build_graph():
# Update project status # Update project status
project.status = ProjectStatus.GRAPH_COMPLETED project.status = ProjectStatus.GRAPH_COMPLETED
project.active_task_id = None
ProjectManager.save_project(project) ProjectManager.save_project(project)
node_count = graph_data.get("node_count", 0) node_count = graph_data.get("node_count", 0)
@ -620,6 +622,7 @@ def build_graph():
project.status = ProjectStatus.FAILED project.status = ProjectStatus.FAILED
project.error = str(e) project.error = str(e)
project.active_task_id = None
ProjectManager.save_project(project) ProjectManager.save_project(project)
task_manager.update_task( task_manager.update_task(

View File

@ -52,6 +52,9 @@ class Project:
# Error info # Error info
error: Optional[str] = None error: Optional[str] = None
# Persisted so the frontend can reconnect after a page refresh
active_task_id: Optional[str] = None
def to_dict(self) -> Dict[str, Any]: def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary""" """Convert to dictionary"""
return { return {
@ -69,7 +72,8 @@ class Project:
"simulation_requirement": self.simulation_requirement, "simulation_requirement": self.simulation_requirement,
"chunk_size": self.chunk_size, "chunk_size": self.chunk_size,
"chunk_overlap": self.chunk_overlap, "chunk_overlap": self.chunk_overlap,
"error": self.error "error": self.error,
"active_task_id": self.active_task_id,
} }
@classmethod @classmethod
@ -94,7 +98,8 @@ class Project:
simulation_requirement=data.get('simulation_requirement'), simulation_requirement=data.get('simulation_requirement'),
chunk_size=data.get('chunk_size', 500), chunk_size=data.get('chunk_size', 500),
chunk_overlap=data.get('chunk_overlap', 50), chunk_overlap=data.get('chunk_overlap', 50),
error=data.get('error') error=data.get('error'),
active_task_id=data.get('active_task_id'),
) )

View File

@ -0,0 +1,31 @@
def test_project_serializes_active_task_id():
"""active_task_id is included in Project.to_dict()."""
from app.models.project import Project, ProjectStatus
p = Project(
project_id="proj-1", name="Test",
status=ProjectStatus.GRAPH_BUILDING,
created_at="2026-01-01", updated_at="2026-01-01",
active_task_id="task-abc-123",
)
assert p.to_dict()["active_task_id"] == "task-abc-123"
def test_project_deserializes_active_task_id():
"""Project.from_dict() restores active_task_id from JSON."""
from app.models.project import Project
data = {
"project_id": "proj-1", "name": "Test", "status": "graph_building",
"created_at": "2026-01-01", "updated_at": "2026-01-01",
"active_task_id": "task-abc-123",
}
assert Project.from_dict(data).active_task_id == "task-abc-123"
def test_project_active_task_id_defaults_none():
"""active_task_id defaults to None for projects without it (backward compat)."""
from app.models.project import Project
data = {
"project_id": "proj-1", "name": "Test", "status": "created",
"created_at": "2026-01-01", "updated_at": "2026-01-01",
}
assert Project.from_dict(data).active_task_id is None

View File

@ -257,10 +257,14 @@ const loadProject = async () => {
if (res.data.status === 'ontology_generated' && !res.data.graph_id) { if (res.data.status === 'ontology_generated' && !res.data.graph_id) {
await startBuildGraph() await startBuildGraph()
} else if (res.data.status === 'graph_building' && res.data.graph_build_task_id) { } else if (res.data.status === 'graph_building') {
currentPhase.value = 1 const taskId = res.data.active_task_id || res.data.graph_build_task_id
startPollingTask(res.data.graph_build_task_id) if (taskId) {
startGraphPolling() currentPhase.value = 1
addLog(t('log.reconnectingToTask', { taskId }))
startPollingTask(taskId)
startGraphPolling()
}
} else if (res.data.status === 'graph_completed' && res.data.graph_id) { } else if (res.data.status === 'graph_completed' && res.data.graph_id) {
currentPhase.value = 2 currentPhase.value = 2
await loadGraph(res.data.graph_id) await loadGraph(res.data.graph_id)

View File

@ -596,7 +596,8 @@
"getReportInfoFailed": "Error en obtenir la informació de l'informe: {error}", "getReportInfoFailed": "Error en obtenir la informació de l'informe: {error}",
"enterStep": "Entrant al pas {step}: {name}", "enterStep": "Entrant al pas {step}: {name}",
"returnToStep": "Tornant al pas {step}: {name}", "returnToStep": "Tornant al pas {step}: {name}",
"customSimRounds": "Rondes de simulació personalitzades: {rounds} rondes" "customSimRounds": "Rondes de simulació personalitzades: {rounds} rondes",
"reconnectingToTask": "Reconnectant a la tasca activa {taskId}…"
}, },
"report": { "report": {
"taskStarted": "Tasca de generació de l'informe iniciada", "taskStarted": "Tasca de generació de l'informe iniciada",

View File

@ -597,7 +597,8 @@
"getReportInfoFailed": "Failed to get report info: {error}", "getReportInfoFailed": "Failed to get report info: {error}",
"enterStep": "Entering Step {step}: {name}", "enterStep": "Entering Step {step}: {name}",
"returnToStep": "Returning to Step {step}: {name}", "returnToStep": "Returning to Step {step}: {name}",
"customSimRounds": "Custom simulation rounds: {rounds} rounds" "customSimRounds": "Custom simulation rounds: {rounds} rounds",
"reconnectingToTask": "Reconnecting to active task {taskId}…"
}, },
"report": { "report": {
"taskStarted": "Report generation task started", "taskStarted": "Report generation task started",

View File

@ -596,7 +596,8 @@
"getReportInfoFailed": "Error al obtener información del informe: {error}", "getReportInfoFailed": "Error al obtener información del informe: {error}",
"enterStep": "Entrando al Paso {step}: {name}", "enterStep": "Entrando al Paso {step}: {name}",
"returnToStep": "Volviendo al Paso {step}: {name}", "returnToStep": "Volviendo al Paso {step}: {name}",
"customSimRounds": "Rondas de simulación personalizadas: {rounds} rondas" "customSimRounds": "Rondas de simulación personalizadas: {rounds} rondas",
"reconnectingToTask": "Reconectando a la tarea activa {taskId}…"
}, },
"report": { "report": {
"taskStarted": "Tarea de generación de informe iniciada", "taskStarted": "Tarea de generación de informe iniciada",

View File

@ -597,7 +597,8 @@
"getReportInfoFailed": "获取报告信息失败: {error}", "getReportInfoFailed": "获取报告信息失败: {error}",
"enterStep": "进入 Step {step}: {name}", "enterStep": "进入 Step {step}: {name}",
"returnToStep": "返回 Step {step}: {name}", "returnToStep": "返回 Step {step}: {name}",
"customSimRounds": "自定义模拟轮数: {rounds} 轮" "customSimRounds": "自定义模拟轮数: {rounds} 轮",
"reconnectingToTask": "重新连接到活动任务 {taskId}…"
}, },
"report": { "report": {
"taskStarted": "报告生成任务开始", "taskStarted": "报告生成任务开始",