agent-ecosystem/src/main/services/team/stallMonitor/OpenCodeTaskStallEvidenceSource.ts
2026-04-28 16:08:05 +03:00

506 lines
14 KiB
TypeScript

import { ClaudeMultimodelBridgeService } from '../../runtime/ClaudeMultimodelBridgeService';
import { canonicalizeAgentTeamsToolName } from '../agentTeamsToolNames';
import { ClaudeBinaryResolver } from '../ClaudeBinaryResolver';
import type {
OpenCodeRuntimeTranscriptLogMessage,
OpenCodeRuntimeTranscriptLogToolCall,
} from '../../runtime/ClaudeMultimodelBridgeService';
import type { BoardTaskActivityRecord } from '../taskLogs/activity/BoardTaskActivityRecord';
import type { TeamTaskStallExactRow } from './TeamTaskStallTypes';
import type { ParsedMessage } from '@main/types';
import type { TeamProviderId, TeamTask } from '@shared/types';
const OPENCODE_STALL_TRANSCRIPT_LIMIT = 500;
const TASK_STALL_MARKER_TOOL_NAMES = new Set<string>([
'task_start',
'task_add_comment',
'task_set_status',
'task_complete',
'review_start',
'review_request',
'review_approve',
'review_request_changes',
]);
const TASK_REFERENCE_KEYS = new Set<string>([
'taskid',
'task_id',
'targetid',
'targettaskid',
'target_task_id',
'canonicalid',
'canonical_id',
'displayid',
'display_id',
]);
const TEAM_REFERENCE_KEYS = new Set<string>(['team', 'teamid', 'team_id', 'teamname', 'team_name']);
interface BinaryResolverLike {
resolve(): Promise<string | null>;
}
interface RuntimeBridgeLike {
getOpenCodeTranscript(
binaryPath: string,
params: {
teamId: string;
memberName: string;
limit?: number;
}
): Promise<Awaited<ReturnType<ClaudeMultimodelBridgeService['getOpenCodeTranscript']>>>;
}
export interface OpenCodeTaskStallEvidence {
recordsByTaskId: Map<string, BoardTaskActivityRecord[]>;
exactRowsByFilePath: Map<string, TeamTaskStallExactRow[]>;
}
function emptyEvidence(): OpenCodeTaskStallEvidence {
return {
recordsByTaskId: new Map(),
exactRowsByFilePath: new Map(),
};
}
function normalizeMemberNameKey(name: string | undefined): string | null {
const normalized = name?.trim().toLowerCase();
return normalized ? normalized : null;
}
function normalizeTaskRef(value: unknown): string | null {
if (typeof value !== 'string' && typeof value !== 'number') {
return null;
}
const normalized = String(value).trim().replace(/^#/, '').toLowerCase();
return normalized.length > 0 ? normalized : null;
}
function buildTaskRefSet(task: TeamTask): Set<string> {
return new Set(
[task.id, task.displayId]
.map(normalizeTaskRef)
.filter((value): value is string => value !== null)
);
}
function collectNormalizedRefs(value: unknown, depth = 0): Set<string> {
const refs = new Set<string>();
if (depth > 4 || value === null || value === undefined) {
return refs;
}
const normalized = normalizeTaskRef(value);
if (normalized) {
refs.add(normalized);
}
if (Array.isArray(value)) {
for (const item of value) {
for (const ref of collectNormalizedRefs(item, depth + 1)) {
refs.add(ref);
}
}
return refs;
}
if (typeof value === 'object') {
for (const nestedValue of Object.values(value as Record<string, unknown>)) {
for (const ref of collectNormalizedRefs(nestedValue, depth + 1)) {
refs.add(ref);
}
}
}
return refs;
}
function collectExplicitRefsForKeys(value: unknown, keys: Set<string>, depth = 0): Set<string> {
const refs = new Set<string>();
if (depth > 4 || value === null || value === undefined) {
return refs;
}
if (Array.isArray(value)) {
for (const item of value) {
for (const ref of collectExplicitRefsForKeys(item, keys, depth + 1)) {
refs.add(ref);
}
}
return refs;
}
if (typeof value !== 'object') {
return refs;
}
for (const [key, nestedValue] of Object.entries(value as Record<string, unknown>)) {
if (keys.has(key.toLowerCase())) {
for (const ref of collectNormalizedRefs(nestedValue)) {
refs.add(ref);
}
continue;
}
for (const ref of collectExplicitRefsForKeys(nestedValue, keys, depth + 1)) {
refs.add(ref);
}
}
return refs;
}
function refsIntersect(left: Set<string>, right: Set<string>): boolean {
for (const value of left) {
if (right.has(value)) {
return true;
}
}
return false;
}
function valueReferencesTask(value: unknown, taskRefs: Set<string>, depth = 0): boolean {
if (depth > 4 || value === null || value === undefined || taskRefs.size === 0) {
return false;
}
const normalized = normalizeTaskRef(value);
if (normalized && taskRefs.has(normalized)) {
return true;
}
if (Array.isArray(value)) {
return value.some((item) => valueReferencesTask(item, taskRefs, depth + 1));
}
if (typeof value === 'object') {
return Object.entries(value as Record<string, unknown>).some(([key, nestedValue]) => {
const normalizedKey = key.toLowerCase();
if (TASK_REFERENCE_KEYS.has(normalizedKey)) {
return valueReferencesTask(nestedValue, taskRefs, depth + 1);
}
return depth < 2 && valueReferencesTask(nestedValue, taskRefs, depth + 1);
});
}
return false;
}
function markerInputReferencesTaskInTeam(
input: unknown,
teamName: string,
taskRefs: Set<string>
): boolean {
const normalizedTeamName = normalizeTaskRef(teamName);
const explicitTeamRefs = collectExplicitRefsForKeys(input, TEAM_REFERENCE_KEYS);
if (
normalizedTeamName &&
explicitTeamRefs.size > 0 &&
!explicitTeamRefs.has(normalizedTeamName)
) {
return false;
}
const explicitTaskRefs = collectExplicitRefsForKeys(input, TASK_REFERENCE_KEYS);
if (explicitTaskRefs.size > 0) {
return refsIntersect(explicitTaskRefs, taskRefs);
}
return valueReferencesTask(input, taskRefs);
}
function buildSyntheticFilePath(teamName: string, owner: string): string {
return `opencode-runtime:${teamName}:${normalizeMemberNameKey(owner) ?? owner}`;
}
function toParsedMessage(message: OpenCodeRuntimeTranscriptLogMessage): ParsedMessage | null {
const timestamp = new Date(message.timestamp);
if (Number.isNaN(timestamp.getTime())) {
return null;
}
return {
uuid: message.uuid,
parentUuid: message.parentUuid,
type: message.type,
timestamp,
role: message.role,
content: typeof message.content === 'string' ? message.content : [],
model: message.model,
agentName: message.agentName,
isSidechain: true,
isMeta: message.isMeta,
sessionId: message.sessionId,
toolCalls: message.toolCalls.map((toolCall) => ({
id: toolCall.id,
name: toolCall.name,
input: toolCall.input,
isTask: toolCall.isTask,
...(toolCall.taskDescription ? { taskDescription: toolCall.taskDescription } : {}),
...(toolCall.taskSubagentType ? { taskSubagentType: toolCall.taskSubagentType } : {}),
})),
toolResults: message.toolResults.map((toolResult) => ({
toolUseId: toolResult.toolUseId,
content: toolResult.content,
isError: toolResult.isError,
})),
...(message.sourceToolUseID ? { sourceToolUseID: message.sourceToolUseID } : {}),
...(message.sourceToolAssistantUUID
? { sourceToolAssistantUUID: message.sourceToolAssistantUUID }
: {}),
...(message.subtype ? { subtype: message.subtype } : {}),
...(message.level ? { level: message.level } : {}),
};
}
function toExactRow(
message: OpenCodeRuntimeTranscriptLogMessage,
filePath: string,
sourceOrder: number
): TeamTaskStallExactRow | null {
const parsedMessage = toParsedMessage(message);
if (!parsedMessage) {
return null;
}
return {
filePath,
sourceOrder,
messageUuid: parsedMessage.uuid,
timestamp: parsedMessage.timestamp.toISOString(),
parsedMessage,
...(message.sourceToolUseID ? { sourceToolUseId: message.sourceToolUseID } : {}),
...(message.sourceToolAssistantUUID
? { sourceToolAssistantUuid: message.sourceToolAssistantUUID }
: {}),
...(message.subtype === 'turn_duration' || message.subtype === 'init'
? { systemSubtype: message.subtype }
: {}),
toolUseIds: parsedMessage.toolCalls.map((toolCall) => toolCall.id),
toolResultIds: parsedMessage.toolResults.map((toolResult) => toolResult.toolUseId),
};
}
function buildTaskRef(task: TeamTask, teamName: string): BoardTaskActivityRecord['task'] {
return {
locator: {
ref: task.id,
refKind: 'canonical',
canonicalId: task.id,
},
resolution: 'resolved',
taskRef: {
taskId: task.id,
displayId: task.displayId ?? task.id.slice(0, 8),
teamName,
},
};
}
function buildActionCategory(
toolName: string
): NonNullable<BoardTaskActivityRecord['action']>['category'] {
switch (toolName) {
case 'task_add_comment':
return 'comment';
case 'review_start':
case 'review_request':
case 'review_approve':
case 'review_request_changes':
return 'review';
case 'task_set_owner':
return 'assignment';
default:
return 'status';
}
}
function extractCommentId(input: Record<string, unknown>): string | undefined {
const commentId = input.commentId ?? input.comment_id;
return typeof commentId === 'string' && commentId.trim().length > 0
? commentId.trim()
: undefined;
}
function buildRecord(args: {
teamName: string;
task: TeamTask;
owner: string;
sessionId: string;
message: OpenCodeRuntimeTranscriptLogMessage;
toolCall: OpenCodeRuntimeTranscriptLogToolCall;
sourceOrder: number;
filePath: string;
canonicalToolName: string;
}): BoardTaskActivityRecord {
const taskRef = buildTaskRef(args.task, args.teamName);
const commentId = extractCommentId(args.toolCall.input);
return {
id: `opencode-stall:${args.teamName}:${args.task.id}:${args.message.uuid}:${args.toolCall.id}`,
timestamp: new Date(args.message.timestamp).toISOString(),
task: taskRef,
linkKind: 'board_action',
targetRole: 'subject',
actor: {
memberName: args.owner,
role: 'member',
sessionId: args.sessionId,
isSidechain: true,
},
actorContext: {
relation: 'same_task',
activeTask: taskRef,
activePhase: args.task.reviewState === 'review' ? 'review' : 'work',
},
action: {
canonicalToolName: args.canonicalToolName,
toolUseId: args.toolCall.id,
category: buildActionCategory(args.canonicalToolName),
...(commentId ? { details: { commentId } } : {}),
},
source: {
messageUuid: args.message.uuid,
filePath: args.filePath,
toolUseId: args.toolCall.id,
sourceOrder: args.sourceOrder,
},
};
}
function collectTaskRecords(args: {
teamName: string;
task: TeamTask;
owner: string;
sessionId: string;
filePath: string;
messages: OpenCodeRuntimeTranscriptLogMessage[];
}): BoardTaskActivityRecord[] {
const taskRefs = buildTaskRefSet(args.task);
if (taskRefs.size === 0) {
return [];
}
const records: BoardTaskActivityRecord[] = [];
for (let index = 0; index < args.messages.length; index += 1) {
const message = args.messages[index];
if (!message) {
continue;
}
for (const toolCall of message.toolCalls) {
const canonicalToolName = canonicalizeAgentTeamsToolName(toolCall.name ?? '')
.trim()
.toLowerCase();
if (!TASK_STALL_MARKER_TOOL_NAMES.has(canonicalToolName)) {
continue;
}
if (!markerInputReferencesTaskInTeam(toolCall.input, args.teamName, taskRefs)) {
continue;
}
records.push(
buildRecord({
teamName: args.teamName,
task: args.task,
owner: args.owner,
sessionId: args.sessionId,
message,
toolCall,
sourceOrder: index + 1,
filePath: args.filePath,
canonicalToolName,
})
);
}
}
return records;
}
function groupOpenCodeTasksByOwner(
tasks: TeamTask[],
providerByMemberName: Map<string, TeamProviderId>
): Map<string, TeamTask[]> {
const grouped = new Map<string, TeamTask[]>();
for (const task of tasks) {
const owner = task.owner?.trim();
if (!owner) {
continue;
}
const provider = providerByMemberName.get(normalizeMemberNameKey(owner) ?? '');
if (provider !== 'opencode') {
continue;
}
const existing = grouped.get(owner) ?? [];
existing.push(task);
grouped.set(owner, existing);
}
return grouped;
}
export class OpenCodeTaskStallEvidenceSource {
constructor(
private readonly runtimeBridge: RuntimeBridgeLike = new ClaudeMultimodelBridgeService(),
private readonly binaryResolver: BinaryResolverLike = ClaudeBinaryResolver
) {}
async readEvidence(args: {
teamName: string;
tasks: TeamTask[];
providerByMemberName: Map<string, TeamProviderId>;
}): Promise<OpenCodeTaskStallEvidence> {
const tasksByOwner = groupOpenCodeTasksByOwner(args.tasks, args.providerByMemberName);
if (tasksByOwner.size === 0) {
return emptyEvidence();
}
const binaryPath = await this.binaryResolver.resolve();
if (!binaryPath) {
return emptyEvidence();
}
const evidence = emptyEvidence();
for (const [owner, tasks] of tasksByOwner.entries()) {
const transcript = await this.runtimeBridge
.getOpenCodeTranscript(binaryPath, {
teamId: args.teamName,
memberName: owner,
limit: OPENCODE_STALL_TRANSCRIPT_LIMIT,
})
.catch(() => null);
const messages = transcript?.logProjection?.messages ?? [];
if (messages.length === 0) {
continue;
}
const filePath = buildSyntheticFilePath(args.teamName, owner);
const exactRows = messages
.map((message, index) => toExactRow(message, filePath, index + 1))
.filter((row): row is TeamTaskStallExactRow => row !== null);
if (exactRows.length > 0) {
evidence.exactRowsByFilePath.set(filePath, exactRows);
}
const sessionId = transcript?.sessionId ?? messages[0]?.sessionId ?? filePath;
for (const task of tasks) {
const records = collectTaskRecords({
teamName: args.teamName,
task,
owner,
sessionId,
filePath,
messages,
});
if (records.length > 0) {
evidence.recordsByTaskId.set(task.id, records);
}
}
}
return evidence;
}
}