Files
tutti/packages/agent/gui/agent-gui/agentGuiNode/controller/agentGuiConversationRailDiagnostics.ts
agent-radar 2ed3415fb7
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

567 lines
17 KiB
TypeScript

import type { AgentConversationRailRuntimePort } from "../../../agentConversationRailContracts";
import type { ConversationRailQueryState } from "./agentConversationRailQueryModel";
import type { CachedConversationRailQuery } from "./agentGuiConversationRailQueryCache";
export const CONVERSATION_RAIL_SLOW_DIAGNOSTIC_THRESHOLD_MS = 250;
export type ConversationRailRefreshReason =
| "attach"
| "manual"
| "membership_change"
| "scope_change";
export type ConversationRailFilterKind = "all" | "agentTarget";
export interface ConversationRailDiagnosticContext {
nodeId: string | null;
runtimeOrigin: string;
workspaceId: string;
}
export interface ConversationRailFirstPagesDiagnostic {
agentTargetId: string | null;
controllerApplyMs: number;
durationMs: number;
errorKind?: string;
event:
| "agent_gui.conversation_rail.first_pages_slow"
| "agent_gui.conversation_rail.first_pages_failed";
requestId: number;
requestMs: number;
refreshReason: ConversationRailRefreshReason;
returnedSessionIds: readonly string[];
returnedSessionCount: number;
nodeId: string | null;
runtimeOrigin: string;
sectionCount: number;
status: "ready" | "error";
workspaceId: string;
}
export interface ConversationRailProviderSwitchDiagnostic {
cacheStatus: "fresh" | "miss" | "stale";
controllerApplyMs: number;
durationMs: number;
errorKind?: string;
event:
| "agent_gui.provider_switch.completed"
| "agent_gui.provider_switch.failed";
fromAgentTargetId: string | null;
nodeId: string | null;
requestId: number | null;
requestMs: number;
returnedSessionIds: readonly string[];
returnedSessionCount: number;
runtimeOrigin: string;
sectionCount: number;
status: "ready" | "error";
toAgentTargetId: string | null;
workspaceId: string;
}
export interface ConversationRailScopeChangeDiagnostic {
activeConversationId: string | null;
cacheStatus: "fresh" | "miss" | "stale" | "unknown";
controllerApplyMs: number;
durationMs: number;
errorKind?: string;
event:
| "agent_gui.conversation_rail.scope_change.started"
| "agent_gui.conversation_rail.scope_change.completed"
| "agent_gui.conversation_rail.scope_change.failed"
| "agent_gui.conversation_rail.scope_change.superseded";
fromAgentTargetId: string | null;
fromFilterKind: ConversationRailFilterKind | null;
nodeId: string | null;
preservedSectionCount: number;
preservedSessionIds: readonly string[];
requestId: number | null;
requestMs: number;
retainedPreviousSections: boolean;
returnedSessionIds: readonly string[];
returnedSessionCount: number;
runtimeOrigin: string;
sectionCount: number;
status: "pending" | "ready" | "error" | "superseded";
toAgentTargetId: string | null;
toFilterKind: ConversationRailFilterKind;
workspaceId: string;
}
export type ConversationRailDiagnosticLogger = (
payload:
| ConversationRailFirstPagesDiagnostic
| ConversationRailProviderSwitchDiagnostic
| ConversationRailScopeChangeDiagnostic
) => void;
interface PendingProviderSwitchDiagnostic {
activeConversationId: string | null;
cacheStatus: "fresh" | "miss" | "stale" | "unknown";
fromAgentTargetId: string | null;
fromFilterKind: ConversationRailFilterKind | null;
preservedSectionCount: number;
preservedSessionIds: readonly string[];
retainedPreviousSections: boolean;
scopeKey: string;
startedAtMs: number;
toAgentTargetId: string | null;
toFilterKind: ConversationRailFilterKind;
}
export class ConversationRailProviderSwitchDiagnosticTracker {
private pending: PendingProviderSwitchDiagnostic | null = null;
constructor(
private readonly diagnosticLogger: ConversationRailDiagnosticLogger,
private readonly now: () => number,
private readonly context: ConversationRailDiagnosticContext,
private readonly slowThresholdMs: number
) {}
configure(input: {
activeConversationId: string | null;
attached: boolean;
nextFilterKind: ConversationRailFilterKind;
nextAgentTargetId: string;
nextScopeKey: string;
preservedSectionCount: number;
preservedSessionIds: readonly string[];
previousFilterKind: ConversationRailFilterKind | null;
previousAgentTargetId: string;
previousScopeKey: string | null;
retainedPreviousSections: boolean;
}): void {
if (
input.nextScopeKey !== input.previousScopeKey &&
this.pending?.scopeKey !== input.nextScopeKey
) {
if (this.pending) {
emitConversationRailScopeChangeDiagnostic({
...this.pending,
...this.context,
controllerApplyMs: 0,
diagnosticLogger: this.diagnosticLogger,
durationMs: Math.max(0, this.now() - this.pending.startedAtMs),
requestId: null,
requestMs: 0,
returnedSessionCount: 0,
returnedSessionIds: [],
sectionCount: 0,
status: "superseded"
});
}
this.pending = null;
}
if (
input.attached &&
input.nextScopeKey !== input.previousScopeKey &&
input.previousScopeKey !== null
) {
this.pending = {
activeConversationId: input.activeConversationId,
cacheStatus: "unknown",
fromAgentTargetId: input.previousAgentTargetId || null,
fromFilterKind: input.previousFilterKind,
preservedSectionCount: input.preservedSectionCount,
preservedSessionIds: input.preservedSessionIds,
retainedPreviousSections: input.retainedPreviousSections,
scopeKey: input.nextScopeKey,
startedAtMs: this.now(),
toAgentTargetId: input.nextAgentTargetId || null,
toFilterKind: input.nextFilterKind
};
emitConversationRailScopeChangeDiagnostic({
...this.pending,
...this.context,
controllerApplyMs: 0,
diagnosticLogger: this.diagnosticLogger,
durationMs: 0,
requestId: null,
requestMs: 0,
returnedSessionCount: 0,
returnedSessionIds: [],
sectionCount: 0,
status: "pending"
});
}
}
hasPending(scopeKey: string): boolean {
return this.pending?.scopeKey === scopeKey;
}
setCacheStatus(
scopeKey: string,
cacheStatus: PendingProviderSwitchDiagnostic["cacheStatus"]
): void {
if (this.pending?.scopeKey === scopeKey) {
this.pending.cacheStatus = cacheStatus;
}
}
complete(
scopeKey: string,
result: Omit<
Parameters<typeof emitConversationRailProviderSwitchDiagnostic>[0],
| "diagnosticLogger"
| "durationMs"
| "fromAgentTargetId"
| "nodeId"
| "runtimeOrigin"
| "toAgentTargetId"
| "workspaceId"
>
): void {
const pending = this.pending;
if (!pending || pending.scopeKey !== scopeKey) return;
this.pending = null;
emitConversationRailScopeChangeDiagnostic({
...pending,
...result,
...this.context,
diagnosticLogger: this.diagnosticLogger,
durationMs: Math.max(0, this.now() - pending.startedAtMs),
status: result.status
});
if (pending.fromAgentTargetId !== pending.toAgentTargetId) {
emitConversationRailProviderSwitchDiagnostic({
...result,
...this.context,
diagnosticLogger: this.diagnosticLogger,
durationMs: Math.max(0, this.now() - pending.startedAtMs),
fromAgentTargetId: pending.fromAgentTargetId,
toAgentTargetId: pending.toAgentTargetId
});
}
}
completeFirstPages(
scopeKey: string,
input: {
agentTargetId: string | null;
cacheStatus: "miss" | "stale";
completedAt: number;
query: CachedConversationRailQuery;
refreshReason: ConversationRailRefreshReason;
requestId: number;
requestResolvedAt: number;
requestStartedAt: number;
}
): void {
const controllerApplyMs = Math.max(
0,
input.completedAt - input.requestResolvedAt
);
const requestMs = Math.max(
0,
input.requestResolvedAt - input.requestStartedAt
);
const returnedSessionIds = conversationRailQuerySessionIds(
input.query.queryState
);
this.complete(scopeKey, {
cacheStatus: input.cacheStatus,
controllerApplyMs,
requestId: input.requestId,
requestMs,
returnedSessionIds,
returnedSessionCount: input.query.returnedSessionCount,
sectionCount: input.query.sectionCount,
status: "ready"
});
emitConversationRailFirstPagesDiagnostic({
agentTargetId: input.agentTargetId,
controllerApplyMs,
diagnosticLogger: this.diagnosticLogger,
diagnosticSlowThresholdMs: this.slowThresholdMs,
durationMs: Math.max(0, input.completedAt - input.requestStartedAt),
requestId: input.requestId,
requestMs,
refreshReason: input.refreshReason,
returnedSessionIds,
returnedSessionCount: input.query.returnedSessionCount,
...this.context,
sectionCount: input.query.sectionCount,
status: "ready"
});
}
completeCachedFirstPages(
scopeKey: string,
input: {
controllerApplyMs: number;
query: CachedConversationRailQuery;
}
): void {
this.complete(scopeKey, {
cacheStatus: "fresh",
controllerApplyMs: input.controllerApplyMs,
requestId: null,
requestMs: 0,
returnedSessionIds: conversationRailQuerySessionIds(
input.query.queryState
),
returnedSessionCount: input.query.returnedSessionCount,
sectionCount: input.query.sectionCount,
status: "ready"
});
}
failFirstPages(
scopeKey: string,
input: {
agentTargetId: string | null;
cacheStatus: "miss" | "stale";
error: unknown;
failedAt: number;
refreshReason: ConversationRailRefreshReason;
requestId: number;
requestStartedAt: number;
}
): void {
const requestMs = Math.max(0, input.failedAt - input.requestStartedAt);
this.complete(scopeKey, {
cacheStatus: input.cacheStatus,
controllerApplyMs: 0,
error: input.error,
requestId: input.requestId,
requestMs,
returnedSessionIds: [],
returnedSessionCount: 0,
sectionCount: 0,
status: "error"
});
emitConversationRailFirstPagesDiagnostic({
agentTargetId: input.agentTargetId,
controllerApplyMs: 0,
diagnosticLogger: this.diagnosticLogger,
diagnosticSlowThresholdMs: this.slowThresholdMs,
durationMs: requestMs,
error: input.error,
requestId: input.requestId,
requestMs,
refreshReason: input.refreshReason,
returnedSessionIds: [],
returnedSessionCount: 0,
...this.context,
sectionCount: 0,
status: "error"
});
}
}
export function emitConversationRailScopeChangeDiagnostic(input: {
activeConversationId: string | null;
cacheStatus: ConversationRailScopeChangeDiagnostic["cacheStatus"];
controllerApplyMs: number;
diagnosticLogger: ConversationRailDiagnosticLogger;
durationMs: number;
error?: unknown;
fromAgentTargetId: string | null;
fromFilterKind: ConversationRailFilterKind | null;
nodeId: string | null;
preservedSectionCount: number;
preservedSessionIds: readonly string[];
requestId: number | null;
requestMs: number;
retainedPreviousSections: boolean;
returnedSessionIds: readonly string[];
returnedSessionCount: number;
runtimeOrigin: string;
sectionCount: number;
status: ConversationRailScopeChangeDiagnostic["status"];
toAgentTargetId: string | null;
toFilterKind: ConversationRailFilterKind;
workspaceId: string;
}): void {
const event: ConversationRailScopeChangeDiagnostic["event"] =
input.status === "pending"
? "agent_gui.conversation_rail.scope_change.started"
: input.status === "error"
? "agent_gui.conversation_rail.scope_change.failed"
: input.status === "superseded"
? "agent_gui.conversation_rail.scope_change.superseded"
: "agent_gui.conversation_rail.scope_change.completed";
const payload: ConversationRailScopeChangeDiagnostic = {
activeConversationId: input.activeConversationId,
cacheStatus: input.cacheStatus,
controllerApplyMs: input.controllerApplyMs,
durationMs: input.durationMs,
...(input.status === "error"
? { errorKind: conversationRailErrorKind(input.error) }
: {}),
event,
fromAgentTargetId: input.fromAgentTargetId,
fromFilterKind: input.fromFilterKind,
nodeId: input.nodeId,
preservedSectionCount: input.preservedSectionCount,
preservedSessionIds: input.preservedSessionIds,
requestId: input.requestId,
requestMs: input.requestMs,
retainedPreviousSections: input.retainedPreviousSections,
returnedSessionIds: input.returnedSessionIds,
returnedSessionCount: input.returnedSessionCount,
runtimeOrigin: input.runtimeOrigin,
sectionCount: input.sectionCount,
status: input.status,
toAgentTargetId: input.toAgentTargetId,
toFilterKind: input.toFilterKind,
workspaceId: input.workspaceId
};
try {
input.diagnosticLogger(payload);
} catch (error) {
ignoreConversationRailDiagnosticFailure(error);
}
}
export function emitConversationRailProviderSwitchDiagnostic(input: {
cacheStatus: ConversationRailProviderSwitchDiagnostic["cacheStatus"];
controllerApplyMs: number;
diagnosticLogger: ConversationRailDiagnosticLogger;
durationMs: number;
error?: unknown;
fromAgentTargetId: string | null;
nodeId: string | null;
requestId: number | null;
requestMs: number;
returnedSessionIds: readonly string[];
returnedSessionCount: number;
runtimeOrigin: string;
sectionCount: number;
status: "ready" | "error";
toAgentTargetId: string | null;
workspaceId: string;
}): void {
const payload: ConversationRailProviderSwitchDiagnostic = {
cacheStatus: input.cacheStatus,
controllerApplyMs: input.controllerApplyMs,
durationMs: input.durationMs,
...(input.status === "error"
? { errorKind: conversationRailErrorKind(input.error) }
: {}),
event:
input.status === "error"
? "agent_gui.provider_switch.failed"
: "agent_gui.provider_switch.completed",
fromAgentTargetId: input.fromAgentTargetId,
nodeId: input.nodeId,
requestId: input.requestId,
requestMs: input.requestMs,
returnedSessionIds: input.returnedSessionIds,
returnedSessionCount: input.returnedSessionCount,
runtimeOrigin: input.runtimeOrigin,
sectionCount: input.sectionCount,
status: input.status,
toAgentTargetId: input.toAgentTargetId,
workspaceId: input.workspaceId
};
try {
input.diagnosticLogger(payload);
} catch (error) {
ignoreConversationRailDiagnosticFailure(error);
}
}
export function emitConversationRailFirstPagesDiagnostic(input: {
agentTargetId: string | null;
controllerApplyMs: number;
diagnosticLogger: ConversationRailDiagnosticLogger;
diagnosticSlowThresholdMs: number;
durationMs: number;
error?: unknown;
requestId: number;
requestMs: number;
refreshReason: ConversationRailRefreshReason;
returnedSessionIds: readonly string[];
returnedSessionCount: number;
nodeId: string | null;
runtimeOrigin: string;
sectionCount: number;
status: "ready" | "error";
workspaceId: string;
}): void {
if (
input.status === "ready" &&
input.durationMs < input.diagnosticSlowThresholdMs
) {
return;
}
const payload: ConversationRailFirstPagesDiagnostic = {
agentTargetId: input.agentTargetId,
controllerApplyMs: input.controllerApplyMs,
durationMs: input.durationMs,
...(input.status === "error"
? { errorKind: conversationRailErrorKind(input.error) }
: {}),
event:
input.status === "error"
? "agent_gui.conversation_rail.first_pages_failed"
: "agent_gui.conversation_rail.first_pages_slow",
requestId: input.requestId,
requestMs: input.requestMs,
refreshReason: input.refreshReason,
returnedSessionIds: input.returnedSessionIds,
returnedSessionCount: input.returnedSessionCount,
nodeId: input.nodeId,
runtimeOrigin: input.runtimeOrigin,
sectionCount: input.sectionCount,
status: input.status,
workspaceId: input.workspaceId
};
try {
input.diagnosticLogger(payload);
} catch (error) {
// Diagnostics must never affect rail state or interaction locking.
ignoreConversationRailDiagnosticFailure(error);
}
}
export function createConversationRailDiagnosticLogger(
runtime: Pick<AgentConversationRailRuntimePort, "reportDiagnostic">
): ConversationRailDiagnosticLogger {
return (payload) => {
const reportDiagnostic = runtime.reportDiagnostic;
if (!reportDiagnostic) return;
try {
void Promise.resolve(
reportDiagnostic.call(runtime, {
details: { ...payload },
event: payload.event,
level: payload.status === "error" ? "warn" : "info",
source: "agent-gui",
workspaceId: payload.workspaceId
})
).catch(ignoreConversationRailDiagnosticFailure);
} catch (error) {
// Best-effort diagnostics only; avoid console fallback noise.
ignoreConversationRailDiagnosticFailure(error);
}
};
}
function ignoreConversationRailDiagnosticFailure(error: unknown): void {
void error;
}
function conversationRailErrorKind(error: unknown): string {
if (error instanceof Error) {
return error.name || "Error";
}
if (typeof error === "string") {
return "string";
}
return error === null ? "null" : typeof error;
}
export function conversationRailQuerySessionIds(
queryState: ConversationRailQueryState
): string[] {
return [
...new Set(
(queryState.sections ?? []).flatMap((section) => section.sessionIds)
)
];
}