Files
deerflow-code/offline-backend-20260512/backend/app/gateway/routers/roundtable_diagnostics.py
T
董宏凯andClaude Opus 4.8 4a403c95c5 feat(roundtable): 会商诊断日志 + 后台挂起卡死兜底(idle 超时)
- 新增会商全流程诊断日志:新表 roundtable_diagnostics + 仓储 + admin API
  (/api/roundtable-diagnostics) + 管理员诊断页(会商页右上角设置按钮入口),
  覆盖第一/二/三步及后台执行器/进程内网关各处报错与关键事件,支持按
  范围/阶段/级别/事件/关键词筛选 + 分页。诊断写入全程 best-effort,绝不打断会商。
- 修复后台挂起「执行完第一个席位后永久卡死」:进程内网关每轮 agent run 改为
  idle(无活动)超时(默认 20min)——只要还在持续产出 token/工具事件就一直等,
  仅当连续无任何输出才判卡死并取消+报错+落 seat_turn_timeout 诊断;慢但活着的
  生成绝不打断。可经 ROUNDTABLE_SEAT_IDLE_TIMEOUT_SECONDS 调整。
- 一并提交进行中的会商 seat-skill 注入 / 业务链条编辑器相关改动。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 22:55:56 +08:00

174 lines
5.5 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.
"""圆桌会商诊断日志查询 API(管理员)。
会商全流程(第一/二/三步 + 后台执行器 + 进程内网关)各处的报错与关键事件落在
``roundtable_diagnostics`` 表,本路由暴露**管理员**筛选查询 + facets + 清理。
Routes(prefix ``/api/roundtable-diagnostics``,全部 admin-gated)::
GET "" 筛选 + 分页列出(scope/stage/level/event/job_id/draft_id/task_id/user_id/q/since/until)
GET "/facets" 各筛选维度的 distinct 取值(下拉用)
POST "/admin/cleanup" 按保留天数清理旧日志
"""
from __future__ import annotations
import time
from datetime import UTC, datetime, timedelta
from typing import Any
from fastapi import APIRouter, HTTPException, Query, Request
from pydantic import BaseModel, Field
router = APIRouter(prefix="/api/roundtable-diagnostics", tags=["roundtable-diagnostics"])
def _get_store(request: Request):
store = getattr(request.app.state, "roundtable_diagnostic_store", None)
if store is None:
raise HTTPException(status_code=503, detail="Roundtable diagnostic store not available")
return store
async def _require_admin(request: Request) -> None:
"""放行 admin / auth-disabled,其余 403(与 roundtable_jobs / task_buttons 同套语义)。"""
from app.gateway.deps import get_optional_user_from_request
user = await get_optional_user_from_request(request)
if user is None:
return
if getattr(user, "system_role", None) != "admin":
raise HTTPException(status_code=403, detail="会商诊断日志仅限管理员查看")
class DiagnosticItem(BaseModel):
id: str
scope: str
stage: str
level: str
event: str
message: str | None = None
detail: Any = None
jobId: str | None = None
draftId: str | None = None
taskId: str | None = None
userId: str | None = None
agentId: str | None = None
agentName: str | None = None
cycle: int | None = None
createdAt: str | None = None
class DiagnosticListResponse(BaseModel):
items: list[DiagnosticItem]
total: int
limit: int
offset: int
class FacetsResponse(BaseModel):
scope: list[str] = Field(default_factory=list)
stage: list[str] = Field(default_factory=list)
level: list[str] = Field(default_factory=list)
event: list[str] = Field(default_factory=list)
class CleanupRequest(BaseModel):
retention_days: int = Field(default=30, ge=0)
batch_size: int = Field(default=1000, ge=1, le=10000)
class CleanupResponse(BaseModel):
deleted: int
elapsed_seconds: float
def _to_item(row: dict[str, Any]) -> DiagnosticItem:
return DiagnosticItem(
id=row["id"],
scope=row.get("scope") or "",
stage=row.get("stage") or "",
level=row.get("level") or "",
event=row.get("event") or "",
message=row.get("message"),
detail=row.get("detail"),
jobId=row.get("job_id"),
draftId=row.get("draft_id"),
taskId=row.get("task_id"),
userId=row.get("user_id"),
agentId=row.get("agent_id"),
agentName=row.get("agent_name"),
cycle=row.get("cycle"),
createdAt=row.get("created_at"),
)
def _parse_dt(raw: str | None) -> datetime | None:
if not raw:
return None
try:
# 容忍前端传的 ``...Z`` 后缀(datetime.fromisoformat 在 3.11+ 接受,旧版不接受)。
return datetime.fromisoformat(raw.replace("Z", "+00:00"))
except Exception:
return None
@router.get("", response_model=DiagnosticListResponse)
async def list_diagnostics(
request: Request,
scope: str | None = None,
stage: str | None = None,
level: str | None = None,
event: str | None = None,
job_id: str | None = None,
draft_id: str | None = None,
task_id: str | None = None,
user_id: str | None = None,
q: str | None = None,
since: str | None = None,
until: str | None = None,
limit: int = Query(default=100, ge=1, le=500),
offset: int = Query(default=0, ge=0),
) -> DiagnosticListResponse:
await _require_admin(request)
store = _get_store(request)
items, total = await store.list_logs(
scope=scope or None,
stage=stage or None,
level=level or None,
event=event or None,
job_id=job_id or None,
draft_id=draft_id or None,
task_id=task_id or None,
user_id=user_id or None,
q=q or None,
since=_parse_dt(since),
until=_parse_dt(until),
limit=limit,
offset=offset,
)
return DiagnosticListResponse(items=[_to_item(r) for r in items], total=total, limit=limit, offset=offset)
@router.get("/facets", response_model=FacetsResponse)
async def get_facets(request: Request) -> FacetsResponse:
await _require_admin(request)
store = _get_store(request)
facets = await store.facets()
return FacetsResponse(**facets)
@router.post("/admin/cleanup", response_model=CleanupResponse)
async def cleanup(request: Request, body: CleanupRequest) -> CleanupResponse:
await _require_admin(request)
store = _get_store(request)
cutoff = datetime.now(UTC) - timedelta(days=body.retention_days)
t0 = time.perf_counter()
deleted = 0
while True:
ids = await store.list_older_than(cutoff, limit=body.batch_size)
if not ids:
break
deleted += await store.delete_by_ids(ids)
if len(ids) < body.batch_size:
break
return CleanupResponse(deleted=deleted, elapsed_seconds=round(time.perf_counter() - t0, 3))