Files
aiagent/backend/app/services/project_supervisor.py
renjianbo f2e65a8fbb feat: virtual company module, team projects, PWA dishes app, and startup scripts overhaul
- Add company module (3-tier org, CEO planning, parallel departments)
- Add company orchestrator, knowledge extractor, presets, scheduler
- Add company API endpoints, models, and frontend views
- Add 今天吃啥 PWA app (69 dishes, real images, offline support)
- Add team_projects output directory structure
- Add unified manage.ps1 for service lifecycle
- Add Windows startup guide v1.0
- Add TTS troubleshooting doc
- Update frontend (AgentChat UX overhaul, new views)
- Update backend (voice engine fix, multi-tenant, RBAC)
- Remove deprecated startup scripts and old docs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-10 22:37:56 +08:00

253 lines
8.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Project Supervisor — 监控所有公司项目的执行状态,检测僵尸项目并自动恢复。
僵尸项目定义:
- status="in_progress" 且 updated_at 超过 ZOMBIE_TIMEOUT_SECONDS 未更新
- SSE 连接断开后Agent 仍在后台运行但结果无法写回
职责:
1. 周期性扫描默认60秒
2. 检测僵尸项目并标记为 "zombie"
3. 提供恢复接口,允许用户重试僵尸项目
4. 发送告警通知
"""
from __future__ import annotations
import asyncio
import logging
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional
from sqlalchemy.orm import Session
from app.models.company import Company, CompanyProject
logger = logging.getLogger(__name__)
# 默认僵尸超时15 分钟900 秒)
ZOMBIE_TIMEOUT_SECONDS = 900
# 扫描间隔60 秒
SCAN_INTERVAL_SECONDS = 60
class ProjectSupervisor:
"""项目监管器 — 检测并处理僵尸项目。"""
def __init__(self, db: Session):
self.db = db
self._last_scan: Optional[datetime] = None
self._zombie_cache: Dict[str, datetime] = {} # project_id -> detected_at
def scan_zombie_projects(self, timeout_seconds: int = ZOMBIE_TIMEOUT_SECONDS) -> List[CompanyProject]:
"""扫描所有僵尸项目。
Args:
timeout_seconds: 超过此秒数未更新的 in_progress 项目视为僵尸
Returns:
检测到的僵尸项目列表
"""
cutoff = datetime.now() - timedelta(seconds=timeout_seconds)
zombies = (
self.db.query(CompanyProject)
.filter(
CompanyProject.status == "in_progress",
CompanyProject.updated_at < cutoff,
)
.all()
)
self._last_scan = datetime.now()
return zombies
def mark_as_zombie(self, project_id: str, reason: str = "") -> bool:
"""将项目标记为僵尸状态。
Args:
project_id: 项目ID
reason: 标记原因
Returns:
是否成功
"""
project = self.db.query(CompanyProject).filter(CompanyProject.id == project_id).first()
if not project:
return False
old_status = project.status
project.status = "zombie"
# 将原因存入 description 字段
suffix = f"\n[ZOMBIE] {reason}" if reason else "\n[ZOMBIE] SSE disconnected or execution stalled"
if suffix not in (project.description or ""):
project.description = (project.description or "") + suffix
project.updated_at = datetime.now()
self.db.commit()
logger.warning(
"Project %s marked as zombie (was: %s). Reason: %s",
project_id[:8], old_status, reason or "timeout"
)
self._zombie_cache[project_id] = datetime.now()
return True
def auto_detect_and_mark(self, timeout_seconds: int = ZOMBIE_TIMEOUT_SECONDS) -> Dict[str, Any]:
"""自动检测并标记僵尸项目。
Returns:
扫描结果摘要
"""
zombies = self.scan_zombie_projects(timeout_seconds)
result = {
"scanned_at": datetime.now().isoformat(),
"zombie_count": len(zombies),
"marked": 0,
"projects": [],
}
for p in zombies:
idle_minutes = round((datetime.now() - p.updated_at).total_seconds() / 60, 1)
reason = f"Project stuck in_progress for {idle_minutes} minutes without updates (last update: {p.updated_at.isoformat()})"
if self.mark_as_zombie(p.id, reason):
result["marked"] += 1
result["projects"].append({
"project_id": p.id,
"name": p.name,
"company_id": p.company_id,
"idle_minutes": idle_minutes,
"previous_status": "in_progress",
})
if result["marked"] > 0:
logger.warning(
"Supervisor scan: found %d zombies, marked %d",
result["zombie_count"], result["marked"]
)
return result
def recover_project(self, project_id: str) -> Dict[str, Any]:
"""尝试恢复僵尸项目(将其重置为 failed 以便重试)。
Args:
project_id: 项目ID
Returns:
恢复结果
"""
project = self.db.query(CompanyProject).filter(CompanyProject.id == project_id).first()
if not project:
return {"success": False, "error": "Project not found"}
if project.status != "zombie":
return {"success": False, "error": f"Project is not zombie (current: {project.status})"}
old_status = project.status
project.status = "failed"
project.description = (project.description or "") + f"\n[RECOVERED] Manually recovered from zombie state at {datetime.now().isoformat()}"
project.updated_at = datetime.now()
project.completed_at = datetime.now()
self.db.commit()
logger.info("Project %s recovered from zombie to failed (ready for retry)", project_id[:8])
if project_id in self._zombie_cache:
del self._zombie_cache[project_id]
return {
"success": True,
"project_id": project_id,
"previous_status": old_status,
"current_status": "failed",
"message": "Project recovered. You can now re-execute it.",
}
def get_zombie_summary(self) -> Dict[str, Any]:
"""获取所有僵尸项目的摘要信息。
Returns:
{zombie_count, projects: [{project_id, name, company_id, idle_minutes, detected_at}]}
"""
zombies = (
self.db.query(CompanyProject)
.filter(CompanyProject.status == "zombie")
.all()
)
projects = []
for p in zombies:
idle_seconds = (datetime.now() - p.updated_at).total_seconds()
projects.append({
"project_id": p.id,
"name": p.name,
"company_id": p.company_id,
"status": p.status,
"idle_minutes": round(idle_seconds / 60, 1),
"last_updated": p.updated_at.isoformat() if p.updated_at else None,
"created_at": p.created_at.isoformat() if p.created_at else None,
})
return {
"zombie_count": len(zombies),
"projects": projects,
"scanned_at": (self._last_scan.isoformat() if self._last_scan else None),
}
def get_all_in_progress(self) -> List[Dict[str, Any]]:
"""获取所有 in_progress 项目(含正常和即将成为僵尸的)。
Returns:
项目列表,包含 idle_minutes 字段
"""
projects = (
self.db.query(CompanyProject)
.filter(CompanyProject.status == "in_progress")
.all()
)
result = []
for p in projects:
idle_seconds = (datetime.now() - p.updated_at).total_seconds()
result.append({
"project_id": p.id,
"name": p.name,
"company_id": p.company_id,
"status": p.status,
"idle_minutes": round(idle_seconds / 60, 1),
"is_at_risk": idle_seconds > ZOMBIE_TIMEOUT_SECONDS * 0.5, # 50% 阈值 = 预警
"will_become_zombie_in_minutes": max(0, round((ZOMBIE_TIMEOUT_SECONDS - idle_seconds) / 60, 1)),
"last_updated": p.updated_at.isoformat() if p.updated_at else None,
})
return result
async def run_supervisor_loop(db_session_factory) -> None:
"""监管器主循环 — 集成到现有调度器中。
Args:
db_session_factory: 返回新 Session 的可调用对象
"""
logger.info("Project Supervisor loop started (scan interval: %ds, zombie timeout: %ds)",
SCAN_INTERVAL_SECONDS, ZOMBIE_TIMEOUT_SECONDS)
while True:
try:
await asyncio.sleep(SCAN_INTERVAL_SECONDS)
db = db_session_factory()
try:
supervisor = ProjectSupervisor(db)
result = supervisor.auto_detect_and_mark()
if result["zombie_count"] > 0:
logger.warning(
"Supervisor: %d zombie projects detected, %d marked",
result["zombie_count"], result["marked"]
)
finally:
db.close()
except asyncio.CancelledError:
logger.info("Project Supervisor loop cancelled")
break
except Exception as e:
logger.error("Project Supervisor loop error: %s", e, exc_info=True)