chore(debug): add connection heartbeat diagnostics
This commit is contained in:
parent
8de6275912
commit
971f29b059
@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import time
|
import time
|
||||||
import re
|
import re
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from flask import Blueprint, jsonify, request, send_file, session
|
from flask import Blueprint, jsonify, request, send_file, session
|
||||||
|
|
||||||
@ -20,9 +21,12 @@ from modules.host_workspace_manager import (
|
|||||||
resolve_host_workspace,
|
resolve_host_workspace,
|
||||||
)
|
)
|
||||||
from utils.host_workspace_debug import write_host_workspace_debug
|
from utils.host_workspace_debug import write_host_workspace_debug
|
||||||
|
from .utils_common import log_conn_diag
|
||||||
from . import state
|
from . import state
|
||||||
|
|
||||||
status_bp = Blueprint('status', __name__)
|
status_bp = Blueprint('status', __name__)
|
||||||
|
STATUS_DIAG_VERBOSE = os.environ.get("STATUS_DIAG_VERBOSE", "0") not in {"0", "false", "False"}
|
||||||
|
STATUS_DIAG_SLOW_MS = int(os.environ.get("STATUS_DIAG_SLOW_MS", "1200") or 1200)
|
||||||
|
|
||||||
|
|
||||||
def _parse_android_version_from_gradle(project_root: Path) -> tuple[int, str]:
|
def _parse_android_version_from_gradle(project_root: Path) -> tuple[int, str]:
|
||||||
@ -70,9 +74,33 @@ def _is_host_mode_request() -> bool:
|
|||||||
@with_terminal
|
@with_terminal
|
||||||
def get_status(terminal, workspace, username):
|
def get_status(terminal, workspace, username):
|
||||||
"""获取系统状态(包含对话、容器、版本等信息)"""
|
"""获取系统状态(包含对话、容器、版本等信息)"""
|
||||||
status = terminal.get_status()
|
started_at = time.perf_counter()
|
||||||
|
heartbeat_id = request.headers.get("X-Connection-Heartbeat", "").strip()
|
||||||
|
diag_requested = STATUS_DIAG_VERBOSE or request.args.get("diag") == "1"
|
||||||
|
timings = {
|
||||||
|
"terminal_ms": 0.0,
|
||||||
|
"terminals_ms": 0.0,
|
||||||
|
"conversation_meta_ms": 0.0,
|
||||||
|
"container_ms": 0.0,
|
||||||
|
"policy_ms": 0.0,
|
||||||
|
}
|
||||||
|
phase_started_at = time.perf_counter()
|
||||||
|
try:
|
||||||
|
status = terminal.get_status()
|
||||||
|
except Exception as exc:
|
||||||
|
timings["terminal_ms"] = (time.perf_counter() - phase_started_at) * 1000
|
||||||
|
total_ms = (time.perf_counter() - started_at) * 1000
|
||||||
|
log_conn_diag(
|
||||||
|
f"status hb={heartbeat_id or '-'} user={username} phase=terminal.get_status "
|
||||||
|
f"total_ms={total_ms:.1f} terminal_ms={timings['terminal_ms']:.1f} error={exc}"
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
timings["terminal_ms"] = (time.perf_counter() - phase_started_at) * 1000
|
||||||
|
phase_started_at = time.perf_counter()
|
||||||
if terminal.terminal_manager:
|
if terminal.terminal_manager:
|
||||||
status['terminals'] = terminal.terminal_manager.list_terminals()
|
status['terminals'] = terminal.terminal_manager.list_terminals()
|
||||||
|
timings["terminals_ms"] = (time.perf_counter() - phase_started_at) * 1000
|
||||||
|
phase_started_at = time.perf_counter()
|
||||||
try:
|
try:
|
||||||
current_conv = terminal.context_manager.current_conversation_id
|
current_conv = terminal.context_manager.current_conversation_id
|
||||||
status.setdefault('conversation', {})['current_id'] = current_conv
|
status.setdefault('conversation', {})['current_id'] = current_conv
|
||||||
@ -92,13 +120,18 @@ def get_status(terminal, workspace, username):
|
|||||||
"job_id": meta.get("compression_job_id"),
|
"job_id": meta.get("compression_job_id"),
|
||||||
}
|
}
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print(f"[Status] 获取当前对话信息失败: {exc}")
|
log_conn_diag(f"status conversation-meta-failed user={username} error={exc}")
|
||||||
|
timings["conversation_meta_ms"] = (time.perf_counter() - phase_started_at) * 1000
|
||||||
status['project_path'] = str(workspace.project_path)
|
status['project_path'] = str(workspace.project_path)
|
||||||
|
phase_started_at = time.perf_counter()
|
||||||
try:
|
try:
|
||||||
status['container'] = container_manager.get_container_status(username)
|
status['container'] = container_manager.get_container_status(username)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
status['container'] = {"success": False, "error": str(exc)}
|
status['container'] = {"success": False, "error": str(exc)}
|
||||||
|
log_conn_diag(f"status container-status-failed user={username} error={exc}")
|
||||||
|
timings["container_ms"] = (time.perf_counter() - phase_started_at) * 1000
|
||||||
status['version'] = AGENT_VERSION
|
status['version'] = AGENT_VERSION
|
||||||
|
phase_started_at = time.perf_counter()
|
||||||
try:
|
try:
|
||||||
policy = resolve_admin_policy(user_manager.get_user(username))
|
policy = resolve_admin_policy(user_manager.get_user(username))
|
||||||
status['admin_policy'] = {
|
status['admin_policy'] = {
|
||||||
@ -109,6 +142,20 @@ def get_status(terminal, workspace, username):
|
|||||||
}
|
}
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
timings["policy_ms"] = (time.perf_counter() - phase_started_at) * 1000
|
||||||
|
total_ms = (time.perf_counter() - started_at) * 1000
|
||||||
|
is_slow = total_ms >= STATUS_DIAG_SLOW_MS
|
||||||
|
if diag_requested or is_slow:
|
||||||
|
conversation = status.get("conversation") or {}
|
||||||
|
log_conn_diag(
|
||||||
|
f"status hb={heartbeat_id or '-'} user={username} host_mode={bool(session.get('host_mode'))} "
|
||||||
|
f"workspace_id={session.get('workspace_id') or '-'} "
|
||||||
|
f"conversation_id={conversation.get('current_id') or '-'} "
|
||||||
|
f"total_ms={total_ms:.1f} slow={is_slow} "
|
||||||
|
f"terminal_ms={timings['terminal_ms']:.1f} terminals_ms={timings['terminals_ms']:.1f} "
|
||||||
|
f"conversation_meta_ms={timings['conversation_meta_ms']:.1f} "
|
||||||
|
f"container_ms={timings['container_ms']:.1f} policy_ms={timings['policy_ms']:.1f}"
|
||||||
|
)
|
||||||
return jsonify(status)
|
return jsonify(status)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -144,6 +144,7 @@ DEBUG_LOG_FILE = Path(LOGS_DIR).expanduser().resolve() / "debug_stream.log"
|
|||||||
CHUNK_BACKEND_LOG_FILE = Path(LOGS_DIR).expanduser().resolve() / "chunk_backend.log"
|
CHUNK_BACKEND_LOG_FILE = Path(LOGS_DIR).expanduser().resolve() / "chunk_backend.log"
|
||||||
CHUNK_FRONTEND_LOG_FILE = Path(LOGS_DIR).expanduser().resolve() / "chunk_frontend.log"
|
CHUNK_FRONTEND_LOG_FILE = Path(LOGS_DIR).expanduser().resolve() / "chunk_frontend.log"
|
||||||
STREAMING_DEBUG_LOG_FILE = Path(LOGS_DIR).expanduser().resolve() / "streaming_debug.log"
|
STREAMING_DEBUG_LOG_FILE = Path(LOGS_DIR).expanduser().resolve() / "streaming_debug.log"
|
||||||
|
CONN_DIAG_LOG_FILE = Path(LOGS_DIR).expanduser().resolve() / "conn_diag.log"
|
||||||
|
|
||||||
|
|
||||||
def _write_log(file_path: Path, message: str) -> None:
|
def _write_log(file_path: Path, message: str) -> None:
|
||||||
@ -158,6 +159,11 @@ def debug_log(message):
|
|||||||
_write_log(DEBUG_LOG_FILE, message)
|
_write_log(DEBUG_LOG_FILE, message)
|
||||||
|
|
||||||
|
|
||||||
|
def log_conn_diag(message: str):
|
||||||
|
"""连接诊断日志:统一关键词并直接落盘到独立文件。"""
|
||||||
|
_write_log(CONN_DIAG_LOG_FILE, f"[CONN_DIAG] {message}")
|
||||||
|
|
||||||
|
|
||||||
def log_backend_chunk(conversation_id: str, iteration: int, chunk_index: int, elapsed: float, char_len: int, content_preview: str):
|
def log_backend_chunk(conversation_id: str, iteration: int, chunk_index: int, elapsed: float, char_len: int, content_preview: str):
|
||||||
preview = content_preview.replace('\n', '\\n')
|
preview = content_preview.replace('\n', '\\n')
|
||||||
_write_log(
|
_write_log(
|
||||||
@ -192,4 +198,6 @@ __all__ = [
|
|||||||
"CHUNK_BACKEND_LOG_FILE",
|
"CHUNK_BACKEND_LOG_FILE",
|
||||||
"CHUNK_FRONTEND_LOG_FILE",
|
"CHUNK_FRONTEND_LOG_FILE",
|
||||||
"STREAMING_DEBUG_LOG_FILE",
|
"STREAMING_DEBUG_LOG_FILE",
|
||||||
|
"CONN_DIAG_LOG_FILE",
|
||||||
|
"log_conn_diag",
|
||||||
]
|
]
|
||||||
|
|||||||
@ -60,6 +60,61 @@ function uiBounceTrace(
|
|||||||
console.log('[SCROLL_BOUNCE_TRACE_UI]', event, payload);
|
console.log('[SCROLL_BOUNCE_TRACE_UI]', event, payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isConnectionDiagEnabled() {
|
||||||
|
if (typeof window === 'undefined') return true;
|
||||||
|
try {
|
||||||
|
const explicit = (window as any).__CONN_DIAG__;
|
||||||
|
if (explicit === false || explicit === '0') return false;
|
||||||
|
if (explicit === true || explicit === '1') return true;
|
||||||
|
const localFlag = window.localStorage?.getItem('connDiag');
|
||||||
|
if (localFlag === '0' || localFlag === 'false') return false;
|
||||||
|
if (localFlag === '1' || localFlag === 'true') return true;
|
||||||
|
// 默认常开:无需手动启动
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushConnectionDiagRecord(record: Record<string, any>) {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
try {
|
||||||
|
const w = window as any;
|
||||||
|
if (!Array.isArray(w.__CONN_DIAG_LOGS__)) {
|
||||||
|
w.__CONN_DIAG_LOGS__ = [];
|
||||||
|
}
|
||||||
|
w.__CONN_DIAG_LOGS__.push(record);
|
||||||
|
const max = 400;
|
||||||
|
if (w.__CONN_DIAG_LOGS__.length > max) {
|
||||||
|
w.__CONN_DIAG_LOGS__.splice(0, w.__CONN_DIAG_LOGS__.length - max);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectionDiag(
|
||||||
|
level: 'log' | 'warn' | 'error',
|
||||||
|
event: string,
|
||||||
|
payload: Record<string, any> = {},
|
||||||
|
options: { force?: boolean } = {}
|
||||||
|
) {
|
||||||
|
const force = !!options.force;
|
||||||
|
const enabled = isConnectionDiagEnabled();
|
||||||
|
if (!enabled && !force) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const record = {
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
event,
|
||||||
|
...payload
|
||||||
|
};
|
||||||
|
pushConnectionDiagRecord(record);
|
||||||
|
const logger =
|
||||||
|
level === 'error' ? console.error : level === 'warn' ? console.warn : console.log;
|
||||||
|
logger('[CONN_DIAG]', event, record);
|
||||||
|
}
|
||||||
|
|
||||||
function parseSubAgentDoneLabel(rawContent: any): string | null {
|
function parseSubAgentDoneLabel(rawContent: any): string | null {
|
||||||
const content = (rawContent || '').toString().trim();
|
const content = (rawContent || '').toString().trim();
|
||||||
if (!content) {
|
if (!content) {
|
||||||
@ -1970,23 +2025,105 @@ export const uiMethods = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async checkConnectionHealth() {
|
async checkConnectionHealth() {
|
||||||
|
const seq = Number(this.connectionHeartbeatSeq || 0) + 1;
|
||||||
|
this.connectionHeartbeatSeq = seq;
|
||||||
|
const requestId = `${Date.now()}-${seq}`;
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const diagEnabled = isConnectionDiagEnabled();
|
||||||
|
const statusUrl = diagEnabled ? '/api/status?diag=1' : '/api/status';
|
||||||
|
const wasConnected = !!this.isConnected;
|
||||||
|
let responseStatus: number | null = null;
|
||||||
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
|
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
|
||||||
const timeoutId = controller ? window.setTimeout(() => controller.abort(), 5000) : null;
|
const timeoutId = controller ? window.setTimeout(() => controller.abort(), 5000) : null;
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/status', {
|
const response = await fetch(statusUrl, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
cache: 'no-store',
|
cache: 'no-store',
|
||||||
|
headers: {
|
||||||
|
'X-Connection-Heartbeat': requestId
|
||||||
|
},
|
||||||
signal: controller?.signal
|
signal: controller?.signal
|
||||||
});
|
});
|
||||||
|
responseStatus = response.status;
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`HTTP ${response.status}`);
|
throw new Error(`HTTP ${response.status}`);
|
||||||
}
|
}
|
||||||
|
const failCountBeforeRecover = this.connectionHeartbeatFailCount || 0;
|
||||||
this.isConnected = true;
|
this.isConnected = true;
|
||||||
this.connectionHeartbeatFailCount = 0;
|
this.connectionHeartbeatFailCount = 0;
|
||||||
|
this.connectionHeartbeatLastLatencyMs = Date.now() - startedAt;
|
||||||
|
this.connectionHeartbeatLastStatusCode = responseStatus;
|
||||||
|
this.connectionHeartbeatLastError = '';
|
||||||
|
if (!wasConnected) {
|
||||||
|
this.connectionHeartbeatLastChangeAt = Date.now();
|
||||||
|
connectionDiag(
|
||||||
|
'warn',
|
||||||
|
'health-recovered',
|
||||||
|
{
|
||||||
|
requestId,
|
||||||
|
seq,
|
||||||
|
elapsedMs: this.connectionHeartbeatLastLatencyMs,
|
||||||
|
failCountBeforeRecover,
|
||||||
|
status: responseStatus,
|
||||||
|
diagEnabled,
|
||||||
|
conversationId: this.currentConversationId || null,
|
||||||
|
taskInProgress: !!this.taskInProgress,
|
||||||
|
streamingMessage: !!this.streamingMessage,
|
||||||
|
visibility: document?.visibilityState || 'unknown',
|
||||||
|
online: typeof navigator !== 'undefined' ? navigator.onLine : null
|
||||||
|
},
|
||||||
|
{ force: true }
|
||||||
|
);
|
||||||
|
} else if (
|
||||||
|
isConnectionDiagEnabled() &&
|
||||||
|
(this.connectionHeartbeatLastLatencyMs >= 700 || seq <= 3 || seq % 60 === 0)
|
||||||
|
) {
|
||||||
|
connectionDiag('log', 'health-ok', {
|
||||||
|
requestId,
|
||||||
|
seq,
|
||||||
|
elapsedMs: this.connectionHeartbeatLastLatencyMs,
|
||||||
|
status: responseStatus,
|
||||||
|
visibility: document?.visibilityState || 'unknown'
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.connectionHeartbeatFailCount = (this.connectionHeartbeatFailCount || 0) + 1;
|
const nextFailCount = (this.connectionHeartbeatFailCount || 0) + 1;
|
||||||
|
this.connectionHeartbeatFailCount = nextFailCount;
|
||||||
|
this.connectionHeartbeatLastLatencyMs = Date.now() - startedAt;
|
||||||
|
this.connectionHeartbeatLastStatusCode = responseStatus;
|
||||||
|
const errName = error?.name || 'Error';
|
||||||
|
const errMessage = error?.message || String(error);
|
||||||
|
this.connectionHeartbeatLastError = `${errName}: ${errMessage}`;
|
||||||
// 一次失败就先置灰,避免“服务已断但常亮绿色”的误导
|
// 一次失败就先置灰,避免“服务已断但常亮绿色”的误导
|
||||||
this.isConnected = false;
|
this.isConnected = false;
|
||||||
|
if (wasConnected) {
|
||||||
|
this.connectionHeartbeatLastChangeAt = Date.now();
|
||||||
|
}
|
||||||
|
const shouldLogFail = wasConnected || nextFailCount <= 3 || nextFailCount % 10 === 0;
|
||||||
|
connectionDiag(
|
||||||
|
shouldLogFail ? 'warn' : 'log',
|
||||||
|
'health-failed',
|
||||||
|
{
|
||||||
|
requestId,
|
||||||
|
seq,
|
||||||
|
elapsedMs: this.connectionHeartbeatLastLatencyMs,
|
||||||
|
failCount: nextFailCount,
|
||||||
|
status: responseStatus,
|
||||||
|
diagEnabled,
|
||||||
|
wasConnected,
|
||||||
|
nowConnected: !!this.isConnected,
|
||||||
|
errorName: errName,
|
||||||
|
errorMessage: errMessage,
|
||||||
|
timeout: errName === 'AbortError',
|
||||||
|
visibility: document?.visibilityState || 'unknown',
|
||||||
|
online: typeof navigator !== 'undefined' ? navigator.onLine : null,
|
||||||
|
conversationId: this.currentConversationId || null,
|
||||||
|
taskInProgress: !!this.taskInProgress,
|
||||||
|
streamingMessage: !!this.streamingMessage,
|
||||||
|
hasPendingTools: typeof this.hasPendingToolActions === 'function' ? this.hasPendingToolActions() : null
|
||||||
|
},
|
||||||
|
{ force: shouldLogFail }
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
if (timeoutId) {
|
if (timeoutId) {
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
@ -2000,6 +2137,15 @@ export const uiMethods = {
|
|||||||
}
|
}
|
||||||
this.connectionHeartbeatActive = true;
|
this.connectionHeartbeatActive = true;
|
||||||
this.connectionHeartbeatFailCount = 0;
|
this.connectionHeartbeatFailCount = 0;
|
||||||
|
connectionDiag(
|
||||||
|
'log',
|
||||||
|
'heartbeat-start',
|
||||||
|
{
|
||||||
|
connectedIntervalMs: this.connectionHeartbeatIntervalMs,
|
||||||
|
disconnectedIntervalMs: this.connectionHeartbeatDisconnectedIntervalMs
|
||||||
|
},
|
||||||
|
{ force: true }
|
||||||
|
);
|
||||||
const runHeartbeat = async () => {
|
const runHeartbeat = async () => {
|
||||||
if (!this.connectionHeartbeatActive) {
|
if (!this.connectionHeartbeatActive) {
|
||||||
return;
|
return;
|
||||||
@ -2018,6 +2164,14 @@ export const uiMethods = {
|
|||||||
? this.connectionHeartbeatDisconnectedIntervalMs
|
? this.connectionHeartbeatDisconnectedIntervalMs
|
||||||
: 1000;
|
: 1000;
|
||||||
const nextInterval = this.isConnected ? connectedInterval : disconnectedInterval;
|
const nextInterval = this.isConnected ? connectedInterval : disconnectedInterval;
|
||||||
|
if (isConnectionDiagEnabled() && (!this.isConnected || (this.connectionHeartbeatSeq || 0) <= 3)) {
|
||||||
|
connectionDiag('log', 'heartbeat-next', {
|
||||||
|
seq: this.connectionHeartbeatSeq,
|
||||||
|
isConnected: !!this.isConnected,
|
||||||
|
failCount: this.connectionHeartbeatFailCount,
|
||||||
|
nextInterval
|
||||||
|
});
|
||||||
|
}
|
||||||
this.connectionHeartbeatTimer = window.setTimeout(() => {
|
this.connectionHeartbeatTimer = window.setTimeout(() => {
|
||||||
runHeartbeat();
|
runHeartbeat();
|
||||||
}, nextInterval);
|
}, nextInterval);
|
||||||
@ -2033,6 +2187,7 @@ export const uiMethods = {
|
|||||||
clearTimeout(this.connectionHeartbeatTimer);
|
clearTimeout(this.connectionHeartbeatTimer);
|
||||||
this.connectionHeartbeatTimer = null;
|
this.connectionHeartbeatTimer = null;
|
||||||
}
|
}
|
||||||
|
connectionDiag('log', 'heartbeat-stop', {}, { force: true });
|
||||||
},
|
},
|
||||||
|
|
||||||
openRealtimeTerminal() {
|
openRealtimeTerminal() {
|
||||||
|
|||||||
@ -139,6 +139,11 @@ export function dataState() {
|
|||||||
connectionHeartbeatTimer: null,
|
connectionHeartbeatTimer: null,
|
||||||
connectionHeartbeatActive: false,
|
connectionHeartbeatActive: false,
|
||||||
connectionHeartbeatFailCount: 0,
|
connectionHeartbeatFailCount: 0,
|
||||||
|
connectionHeartbeatSeq: 0,
|
||||||
|
connectionHeartbeatLastLatencyMs: 0,
|
||||||
|
connectionHeartbeatLastError: '',
|
||||||
|
connectionHeartbeatLastStatusCode: null,
|
||||||
|
connectionHeartbeatLastChangeAt: 0,
|
||||||
connectionHeartbeatIntervalMs: 8000,
|
connectionHeartbeatIntervalMs: 8000,
|
||||||
connectionHeartbeatDisconnectedIntervalMs: 1000,
|
connectionHeartbeatDisconnectedIntervalMs: 1000,
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user