agent-ecosystem/src/main/services/team/taskLogs/diagnostics/BoardTaskLogDiagnosticsService.ts

505 lines
17 KiB
TypeScript

import { getTaskDisplayId, taskMatchesRef } from '@shared/utils/taskIdentity';
import { TeamTaskReader } from '../../TeamTaskReader';
import { BoardTaskActivityRecordSource } from '../activity/BoardTaskActivityRecordSource';
import { TeamTranscriptSourceLocator } from '../discovery/TeamTranscriptSourceLocator';
import { BoardTaskExactLogStrictParser } from '../exact/BoardTaskExactLogStrictParser';
import { BoardTaskLogStreamService } from '../stream/BoardTaskLogStreamService';
import { HistoricalBoardMcpRawProbe } from '../stream/HistoricalBoardMcpRawProbe';
import { TaskLogTranscriptCandidateSelector } from '../stream/TaskLogTranscriptCandidateSelector';
import type { BoardTaskActivityRecord } from '../activity/BoardTaskActivityRecord';
import type { ParsedMessage } from '@main/types';
import type { TaskWorkInterval, TeamTask } from '@shared/types';
const BOARD_MCP_TOOL_PREFIXES = ['mcp__agent-teams__', 'mcp__agent_teams__'] as const;
const MAX_EXAMPLES = 10;
export interface BoardTaskLogDiagnosticExample {
timestamp: string;
filePath: string;
messageUuid: string;
toolUseId?: string;
toolName: string;
isSidechain: boolean;
agentId?: string;
}
export interface BoardTaskLogDiagnosticsReport {
teamName: string;
requestedTaskRef: string;
task: {
taskId: string;
displayId: string;
subject: string;
status: TeamTask['status'];
owner?: string;
workIntervals: TaskWorkInterval[];
};
transcript: {
fileCount: number;
files: string[];
parsedFileCount: number;
candidateSelection?: {
mode: 'activity_records' | 'historical_raw_probe' | 'none';
candidateFileCount: number;
rawProbeScannedFileCount?: number;
rawProbeHitCount?: number;
};
};
explicitRecords: {
total: number;
execution: number;
lifecycle: number;
boardAction: number;
participants: string[];
toolNames: string[];
};
intervalToolResults: {
total: number;
boardMcp: number;
worker: {
total: number;
explicitLinked: number;
missingExplicit: number;
examples: BoardTaskLogDiagnosticExample[];
};
};
stream: {
participants: string[];
defaultFilter: string;
segmentCount: number;
visibleToolNames: string[];
emptyPayloadExamples: BoardTaskLogDiagnosticExample[];
};
diagnosis: string[];
}
function normalizeRequestedTaskRef(taskRef: string): string {
return taskRef.trim().replace(/^#/, '');
}
function isBoardMcpToolName(toolName: string | undefined): boolean {
if (!toolName) return false;
const normalized = toolName.trim().toLowerCase();
return BOARD_MCP_TOOL_PREFIXES.some((prefix) => normalized.startsWith(prefix));
}
function isWithinWorkIntervals(timestamp: Date, intervals: TaskWorkInterval[]): boolean {
if (!Number.isFinite(timestamp.getTime())) {
return false;
}
if (intervals.length === 0) {
return true;
}
const time = timestamp.getTime();
return intervals.some((interval) => {
const startedAt = Date.parse(interval.startedAt);
if (!Number.isFinite(startedAt) || time < startedAt) {
return false;
}
if (interval.completedAt === undefined) {
return true;
}
const completedAt = Date.parse(interval.completedAt);
const endMs = Number.isFinite(completedAt) ? Math.max(completedAt, startedAt) : startedAt;
return time <= endMs;
});
}
function pushUnique(values: string[], value: string | undefined): void {
if (!value) return;
if (!values.includes(value)) {
values.push(value);
}
}
function pushExample(
examples: BoardTaskLogDiagnosticExample[],
example: BoardTaskLogDiagnosticExample
): void {
if (examples.length < MAX_EXAMPLES) {
examples.push(example);
}
}
function buildParticipantLabel(record: BoardTaskActivityRecord): string {
if (record.actor.memberName) {
return record.actor.memberName;
}
if (!record.actor.isSidechain || record.actor.role === 'lead') {
return 'lead session';
}
if (record.actor.agentId) {
return `member ${record.actor.agentId.slice(0, 8)}`;
}
return `member session ${record.actor.sessionId.slice(0, 8)}`;
}
function extractVisibleToolNames(
stream: Awaited<ReturnType<BoardTaskLogStreamService['getTaskLogStream']>>
): string[] {
const toolNames: string[] = [];
for (const segment of stream.segments) {
for (const chunk of segment.chunks) {
for (const message of chunk.rawMessages) {
for (const toolCall of message.toolCalls) {
pushUnique(toolNames, toolCall.name);
}
}
}
}
return toolNames;
}
function buildStreamToolNameMap(
stream: Awaited<ReturnType<BoardTaskLogStreamService['getTaskLogStream']>>
): Map<string, string> {
const toolNameByUseId = new Map<string, string>();
for (const segment of stream.segments) {
for (const chunk of segment.chunks) {
for (const message of chunk.rawMessages) {
for (const toolCall of message.toolCalls) {
toolNameByUseId.set(toolCall.id, toolCall.name);
}
}
}
}
return toolNameByUseId;
}
function isEmptyToolPayload(value: unknown): boolean {
if (value == null) return true;
if (typeof value === 'string') {
return value.trim().length === 0;
}
if (Array.isArray(value)) {
return value.length === 0;
}
return false;
}
function asObjectRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function collectEmptyPayloadExamples(
stream: Awaited<ReturnType<BoardTaskLogStreamService['getTaskLogStream']>>
): BoardTaskLogDiagnosticExample[] {
const examples: BoardTaskLogDiagnosticExample[] = [];
const toolNameByUseId = buildStreamToolNameMap(stream);
for (const segment of stream.segments) {
for (const chunk of segment.chunks) {
for (const message of chunk.rawMessages) {
for (const toolResult of message.toolResults) {
if (!isEmptyToolPayload(toolResult.content)) {
continue;
}
pushExample(examples, {
timestamp: message.timestamp.toISOString(),
filePath: 'stream',
messageUuid: message.uuid,
toolUseId: toolResult.toolUseId,
toolName: toolNameByUseId.get(toolResult.toolUseId) ?? 'unknown tool',
isSidechain: message.isSidechain,
...(message.agentId ? { agentId: message.agentId } : {}),
});
}
const toolUseResult = asObjectRecord(message.toolUseResult);
if (!toolUseResult) {
continue;
}
const toolUseId =
typeof toolUseResult.toolUseId === 'string'
? toolUseResult.toolUseId
: message.sourceToolUseID;
const contentIsEmpty =
(!('content' in toolUseResult) || isEmptyToolPayload(toolUseResult.content)) &&
(!('message' in toolUseResult) || isEmptyToolPayload(toolUseResult.message));
if (!contentIsEmpty) {
continue;
}
pushExample(examples, {
timestamp: message.timestamp.toISOString(),
filePath: 'stream',
messageUuid: message.uuid,
...(toolUseId ? { toolUseId } : {}),
toolName: toolUseId ? (toolNameByUseId.get(toolUseId) ?? 'unknown tool') : 'unknown tool',
isSidechain: message.isSidechain,
...(message.agentId ? { agentId: message.agentId } : {}),
});
}
}
}
return examples;
}
function buildToolNameMap(parsedMessagesByFile: Map<string, ParsedMessage[]>): Map<string, string> {
const toolNameByUseId = new Map<string, string>();
for (const messages of parsedMessagesByFile.values()) {
for (const message of messages) {
for (const toolCall of message.toolCalls) {
toolNameByUseId.set(toolCall.id, toolCall.name);
}
}
}
return toolNameByUseId;
}
export class BoardTaskLogDiagnosticsService {
constructor(
private readonly taskReader: TeamTaskReader = new TeamTaskReader(),
private readonly transcriptSourceLocator: TeamTranscriptSourceLocator = new TeamTranscriptSourceLocator(),
private readonly recordSource: BoardTaskActivityRecordSource = new BoardTaskActivityRecordSource(),
private readonly strictParser: BoardTaskExactLogStrictParser = new BoardTaskExactLogStrictParser(),
private readonly streamService: BoardTaskLogStreamService = new BoardTaskLogStreamService(),
private readonly transcriptCandidateSelector: TaskLogTranscriptCandidateSelector = new TaskLogTranscriptCandidateSelector(),
private readonly historicalRawProbe: HistoricalBoardMcpRawProbe = new HistoricalBoardMcpRawProbe()
) {}
private async getTranscriptContext(teamName: string): Promise<{
projectDir?: string;
transcriptFiles: string[];
}> {
const locator = this.transcriptSourceLocator as {
getContext?: (
teamName: string
) => Promise<{ projectDir: string; transcriptFiles: string[] } | null>;
listTranscriptFiles?: (teamName: string) => Promise<string[]>;
};
const context = await locator.getContext?.(teamName);
if (context) {
return {
projectDir: context.projectDir,
transcriptFiles: context.transcriptFiles,
};
}
return {
transcriptFiles: (await locator.listTranscriptFiles?.(teamName)) ?? [],
};
}
private async parseDiagnosticCandidateFiles(args: {
task: TeamTask;
records: BoardTaskActivityRecord[];
projectDir?: string;
transcriptFiles: string[];
}): Promise<{
parsedMessagesByFile: Map<string, ParsedMessage[]>;
candidateSelection: NonNullable<
BoardTaskLogDiagnosticsReport['transcript']['candidateSelection']
>;
}> {
if (args.transcriptFiles.length === 0) {
return {
parsedMessagesByFile: new Map(),
candidateSelection: {
mode: 'none',
candidateFileCount: 0,
},
};
}
if (args.records.length > 0) {
const selection = this.transcriptCandidateSelector.selectInferredNativeTranscriptFiles({
records: args.records,
transcriptFiles: args.transcriptFiles,
projectDir: args.projectDir,
});
return {
parsedMessagesByFile:
selection.filePaths.length > 0
? await this.strictParser.parseFiles(selection.filePaths)
: new Map(),
candidateSelection: {
mode: 'activity_records',
candidateFileCount: selection.filePaths.length,
},
};
}
const rawProbe = await this.historicalRawProbe.findCandidateFiles({
task: args.task,
transcriptFiles: args.transcriptFiles,
});
return {
parsedMessagesByFile:
rawProbe.filePaths.length > 0
? await this.strictParser.parseFiles(rawProbe.filePaths)
: new Map(),
candidateSelection: {
mode: 'historical_raw_probe',
candidateFileCount: rawProbe.filePaths.length,
rawProbeScannedFileCount: rawProbe.scannedFileCount,
rawProbeHitCount: rawProbe.hitCount,
},
};
}
async diagnose(teamName: string, taskRef: string): Promise<BoardTaskLogDiagnosticsReport> {
const normalizedRef = normalizeRequestedTaskRef(taskRef);
const [activeTasks, deletedTasks, transcriptContext] = await Promise.all([
this.taskReader.getTasks(teamName),
this.taskReader.getDeletedTasks(teamName),
this.getTranscriptContext(teamName),
]);
const tasks = [...activeTasks, ...deletedTasks];
const task = tasks.find((candidate) => taskMatchesRef(candidate, normalizedRef));
if (!task) {
throw new Error(`Task "${taskRef}" was not found in team "${teamName}"`);
}
const records = await this.recordSource.getTaskRecords(teamName, task.id);
const { parsedMessagesByFile, candidateSelection } = await this.parseDiagnosticCandidateFiles({
task,
records,
projectDir: transcriptContext.projectDir,
transcriptFiles: transcriptContext.transcriptFiles,
});
const stream = await this.streamService.getTaskLogStream(teamName, task.id);
const toolNameByUseId = buildToolNameMap(parsedMessagesByFile);
const explicitExecutionKeys = new Set(
records
.filter((record) => record.linkKind === 'execution')
.map((record) => `${record.source.messageUuid}:${record.source.toolUseId ?? ''}`)
);
const workIntervals = Array.isArray(task.workIntervals) ? task.workIntervals : [];
const explicitParticipants: string[] = [];
const explicitToolNames: string[] = [];
for (const record of records) {
pushUnique(explicitParticipants, buildParticipantLabel(record));
pushUnique(explicitToolNames, record.action?.canonicalToolName);
}
let intervalToolResultTotal = 0;
let boardMcpToolResultTotal = 0;
let workerToolResultTotal = 0;
let explicitLinkedWorkerResultTotal = 0;
let missingExplicitWorkerResultTotal = 0;
const missingExplicitWorkerExamples: BoardTaskLogDiagnosticExample[] = [];
for (const [filePath, messages] of parsedMessagesByFile.entries()) {
for (const message of messages) {
if (message.type !== 'user' || message.toolResults.length === 0) {
continue;
}
if (!isWithinWorkIntervals(message.timestamp, workIntervals)) {
continue;
}
for (const toolResult of message.toolResults) {
intervalToolResultTotal += 1;
const toolName = toolNameByUseId.get(toolResult.toolUseId) ?? 'unknown tool';
if (isBoardMcpToolName(toolName)) {
boardMcpToolResultTotal += 1;
continue;
}
workerToolResultTotal += 1;
const explicitKey = `${message.uuid}:${toolResult.toolUseId}`;
if (explicitExecutionKeys.has(explicitKey)) {
explicitLinkedWorkerResultTotal += 1;
continue;
}
missingExplicitWorkerResultTotal += 1;
pushExample(missingExplicitWorkerExamples, {
timestamp: message.timestamp.toISOString(),
filePath,
messageUuid: message.uuid,
toolUseId: toolResult.toolUseId,
toolName,
isSidechain: message.isSidechain,
...(message.agentId ? { agentId: message.agentId } : {}),
});
}
}
}
const diagnosis: string[] = [];
if (transcriptContext.transcriptFiles.length === 0) {
diagnosis.push('No transcript files were found for this team.');
}
if (records.length === 0) {
diagnosis.push('No explicit task-linked activity records were found for this task.');
}
if (missingExplicitWorkerResultTotal > 0) {
diagnosis.push(
`Only board MCP actions are explicit for part of this task history. Found ${missingExplicitWorkerResultTotal} worker tool result(s) inside task work intervals without boardTaskLinks, so Task Log Stream cannot safely include them.`
);
}
if (
missingExplicitWorkerResultTotal > 0 &&
extractVisibleToolNames(stream).every((toolName) => isBoardMcpToolName(toolName))
) {
diagnosis.push(
'Current stream visibility matches the data gap: the visible tools are MCP board actions, while worker tools exist in transcript but are unlinked.'
);
}
const emptyPayloadExamples = collectEmptyPayloadExamples(stream);
if (emptyPayloadExamples.length > 0) {
diagnosis.push(
`Found ${emptyPayloadExamples.length} tool result payload(s) with empty rendered content in the current stream. This explains empty success/output blocks.`
);
}
if (diagnosis.length === 0) {
diagnosis.push('No obvious task-log data gap was detected by diagnostics.');
}
return {
teamName,
requestedTaskRef: taskRef,
task: {
taskId: task.id,
displayId: getTaskDisplayId(task),
subject: task.subject,
status: task.status,
...(task.owner ? { owner: task.owner } : {}),
workIntervals,
},
transcript: {
fileCount: transcriptContext.transcriptFiles.length,
files: transcriptContext.transcriptFiles,
parsedFileCount: parsedMessagesByFile.size,
candidateSelection,
},
explicitRecords: {
total: records.length,
execution: records.filter((record) => record.linkKind === 'execution').length,
lifecycle: records.filter((record) => record.linkKind === 'lifecycle').length,
boardAction: records.filter((record) => record.linkKind === 'board_action').length,
participants: explicitParticipants,
toolNames: explicitToolNames,
},
intervalToolResults: {
total: intervalToolResultTotal,
boardMcp: boardMcpToolResultTotal,
worker: {
total: workerToolResultTotal,
explicitLinked: explicitLinkedWorkerResultTotal,
missingExplicit: missingExplicitWorkerResultTotal,
examples: missingExplicitWorkerExamples,
},
},
stream: {
participants: stream.participants.map((participant) => participant.label),
defaultFilter: stream.defaultFilter,
segmentCount: stream.segments.length,
visibleToolNames: extractVisibleToolNames(stream),
emptyPayloadExamples,
},
diagnosis,
};
}
}