Files
董宏凯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

207 lines
7.2 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.
"""SQLAlchemy-backed 圆桌会商诊断日志 store。
写入(``record``)best-effort、全局共享;读取(``list_logs`` / ``count`` / ``facets``)
带多维筛选,供管理员诊断页用。``detail`` 以 JSON 序列化进 ``PortableLongText``。
"""
from __future__ import annotations
import json
from datetime import datetime
from typing import Any
from uuid import uuid4
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.roundtable_diagnostics.model import RoundtableDiagnosticRow
def _dumps(value: Any) -> str | None:
if value is None:
return None
if isinstance(value, str):
return value
try:
return json.dumps(value, ensure_ascii=False, default=str)
except Exception:
return str(value)
def _loads(value: Any) -> Any:
if not isinstance(value, str) or not value:
return value or None
try:
return json.loads(value)
except Exception:
return value # 非 JSON 文本(如纯字符串 detail)原样返回
class RoundtableDiagnosticRepository:
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory
@staticmethod
def _to_dict(row: RoundtableDiagnosticRow) -> dict[str, Any]:
return {
"id": row.id,
"scope": row.scope,
"stage": row.stage,
"level": row.level,
"event": row.event,
"message": row.message,
"detail": _loads(row.detail),
"job_id": row.job_id,
"draft_id": row.draft_id,
"task_id": row.task_id,
"user_id": row.user_id,
"agent_id": row.agent_id,
"agent_name": row.agent_name,
"cycle": row.cycle,
"created_at": row.created_at.isoformat() if isinstance(row.created_at, datetime) else row.created_at,
}
async def record(
self,
*,
scope: str = "background",
stage: str = "job",
level: str = "info",
event: str = "",
message: str | None = None,
detail: Any = None,
job_id: str | None = None,
draft_id: str | None = None,
task_id: str | None = None,
user_id: str | None = None,
agent_id: str | None = None,
agent_name: str | None = None,
cycle: int | None = None,
) -> dict[str, Any]:
"""插入一条诊断事件,返回序列化 dict。"""
row = RoundtableDiagnosticRow(
id=uuid4().hex,
scope=(scope or "background")[:16],
stage=(stage or "job")[:32],
level=(level or "info")[:16],
event=(event or "")[:64],
message=message,
detail=_dumps(detail),
job_id=job_id,
draft_id=draft_id,
task_id=task_id,
user_id=user_id,
agent_id=(agent_id or None) if agent_id is None else str(agent_id)[:128],
agent_name=(agent_name or None) if agent_name is None else str(agent_name)[:255],
cycle=cycle,
)
async with self._sf() as session:
session.add(row)
await session.commit()
await session.refresh(row)
return self._to_dict(row)
def _apply_filters(
self,
stmt,
*,
scope: str | None,
stage: str | None,
level: str | None,
event: str | None,
job_id: str | None,
draft_id: str | None,
task_id: str | None,
user_id: str | None,
q: str | None,
since: datetime | None,
until: datetime | None,
):
R = RoundtableDiagnosticRow
if scope:
stmt = stmt.where(R.scope == scope)
if stage:
stmt = stmt.where(R.stage == stage)
if level:
stmt = stmt.where(R.level == level)
if event:
stmt = stmt.where(R.event == event)
if job_id:
stmt = stmt.where(R.job_id == job_id)
if draft_id:
stmt = stmt.where(R.draft_id == draft_id)
if task_id:
stmt = stmt.where(R.task_id == task_id)
if user_id:
stmt = stmt.where(R.user_id == user_id)
if q:
like = f"%{q}%"
stmt = stmt.where(R.message.ilike(like))
if since is not None:
stmt = stmt.where(R.created_at >= since)
if until is not None:
stmt = stmt.where(R.created_at <= until)
return stmt
async def list_logs(
self,
*,
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: datetime | None = None,
until: datetime | None = None,
limit: int = 100,
offset: int = 0,
) -> tuple[list[dict[str, Any]], int]:
"""筛选 + 分页(newest-first)。返回 ``(items, total)``。"""
R = RoundtableDiagnosticRow
base = self._apply_filters(
select(R),
scope=scope, stage=stage, level=level, event=event,
job_id=job_id, draft_id=draft_id, task_id=task_id, user_id=user_id,
q=q, since=since, until=until,
)
count_stmt = self._apply_filters(
select(func.count()).select_from(R),
scope=scope, stage=stage, level=level, event=event,
job_id=job_id, draft_id=draft_id, task_id=task_id, user_id=user_id,
q=q, since=since, until=until,
)
page_stmt = base.order_by(R.created_at.desc(), R.id.desc()).limit(limit).offset(offset)
async with self._sf() as session:
total = int((await session.execute(count_stmt)).scalar() or 0)
rows = (await session.execute(page_stmt)).scalars().all()
return [self._to_dict(r) for r in rows], total
async def facets(self) -> dict[str, list[str]]:
"""返回各筛选维度的 distinct 取值(供前端下拉),供筛选 UI 用。"""
R = RoundtableDiagnosticRow
out: dict[str, list[str]] = {}
async with self._sf() as session:
for key, col in (("scope", R.scope), ("stage", R.stage), ("level", R.level), ("event", R.event)):
rows = (await session.execute(select(col).distinct())).scalars().all()
out[key] = sorted([str(v) for v in rows if v])
return out
async def list_older_than(self, cutoff: datetime, *, limit: int = 1000) -> list[str]:
R = RoundtableDiagnosticRow
stmt = select(R.id).where(R.created_at < cutoff).limit(limit)
async with self._sf() as session:
return [str(v) for v in (await session.execute(stmt)).scalars().all()]
async def delete_by_ids(self, ids: list[str]) -> int:
if not ids:
return 0
R = RoundtableDiagnosticRow
async with self._sf() as session:
result = await session.execute(delete(R).where(R.id.in_(ids)))
await session.commit()
return int(result.rowcount or 0)