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>
This commit is contained in:
董宏凯andClaude Opus 4.8 committed 2026-06-19 22:55:56 +08:00
1 parent 8ca16c74bc
commit 4a403c95c5
27 files changed
+1873 -61

No files matched your search

@@ -0,0 +1,131 @@
/**
* 圆桌会商诊断日志(管理员)API 客户端 + React Query hooks。
*
* 后端:`/api/roundtable-diagnostics`(见 app/gateway/routers/roundtable_diagnostics.py)。
* 全部 admin-gated。snake_case ⇄ camelCase 在此边界收敛。
*/
import { useQuery } from "@tanstack/react-query";
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
const BASE = () => `${getBackendBaseURL()}/api/roundtable-diagnostics`;
export interface DiagnosticItem {
id: string;
scope: string;
stage: string;
level: string;
event: string;
message: string | null;
detail: unknown;
jobId: string | null;
draftId: string | null;
taskId: string | null;
userId: string | null;
agentId: string | null;
agentName: string | null;
cycle: number | null;
createdAt: string | null;
}
export interface DiagnosticListResponse {
items: DiagnosticItem[];
total: number;
limit: number;
offset: number;
}
export interface DiagnosticFacets {
scope: string[];
stage: string[];
level: string[];
event: string[];
}
export interface DiagnosticFilters {
scope?: string;
stage?: string;
level?: string;
event?: string;
jobId?: string;
draftId?: string;
taskId?: string;
userId?: string;
q?: string;
since?: string;
until?: string;
limit?: number;
offset?: number;
}
async function readJson<T>(res: Response, ctx: string): Promise<T> {
if (!res.ok) {
const data = await res.json().catch(() => null as unknown);
const detail =
data && typeof data === "object" && "detail" in data
? String((data as { detail: unknown }).detail)
: `${ctx} failed (${res.status})`;
throw new Error(detail);
}
return (await res.json()) as T;
}
function buildQuery(f: DiagnosticFilters): string {
const p = new URLSearchParams();
const map: Record<string, string | number | undefined> = {
scope: f.scope,
stage: f.stage,
level: f.level,
event: f.event,
job_id: f.jobId,
draft_id: f.draftId,
task_id: f.taskId,
user_id: f.userId,
q: f.q,
since: f.since,
until: f.until,
limit: f.limit,
offset: f.offset,
};
for (const [k, v] of Object.entries(map)) {
if (v !== undefined && v !== null && String(v).length > 0) p.set(k, String(v));
}
const qs = p.toString();
return qs ? `?${qs}` : "";
}
export async function listDiagnostics(f: DiagnosticFilters): Promise<DiagnosticListResponse> {
const res = await apiFetch(`${BASE()}${buildQuery(f)}`, { method: "GET" });
return readJson<DiagnosticListResponse>(res, "list diagnostics");
}
export async function getDiagnosticFacets(): Promise<DiagnosticFacets> {
const res = await apiFetch(`${BASE()}/facets`, { method: "GET" });
return readJson<DiagnosticFacets>(res, "diagnostic facets");
}
// ── React Query hooks ──────────────────────────────────────────────────────
const KEY = "roundtable-diagnostics";
export function useRoundtableDiagnostics(filters: DiagnosticFilters, enabled = true) {
return useQuery({
queryKey: [KEY, filters],
queryFn: () => listDiagnostics(filters),
enabled,
refetchOnWindowFocus: false,
staleTime: 10_000,
});
}
export function useRoundtableDiagnosticFacets(enabled = true) {
return useQuery({
queryKey: [KEY, "facets"],
queryFn: getDiagnosticFacets,
enabled,
refetchOnWindowFocus: false,
staleTime: 60_000,
});
}