fix: improve opencode runtime task logs
This commit is contained in:
parent
376480b84f
commit
531a10e34f
13 changed files with 757 additions and 57 deletions
|
|
@ -16072,6 +16072,11 @@ export class TeamProvisioningService {
|
|||
hardFailureReason: evidenceEntry.hardFailureReason,
|
||||
pendingPermissionRequestIds: evidenceEntry.pendingPermissionRequestIds,
|
||||
runtimePid: evidenceEntry.runtimePid,
|
||||
sessionId: evidenceEntry.sessionId,
|
||||
livenessKind: evidenceEntry.livenessKind,
|
||||
pidSource: evidenceEntry.pidSource,
|
||||
runtimeDiagnostic: evidenceEntry.runtimeDiagnostic,
|
||||
runtimeDiagnosticSeverity: evidenceEntry.runtimeDiagnosticSeverity,
|
||||
diagnostics: evidenceEntry.diagnostics,
|
||||
}
|
||||
: finishedWithoutRuntimeEvidence
|
||||
|
|
@ -16638,6 +16643,12 @@ export class TeamProvisioningService {
|
|||
hardFailureReason?: string;
|
||||
pendingPermissionRequestIds?: string[];
|
||||
runtimePid?: number;
|
||||
sessionId?: string;
|
||||
runtimeSessionId?: string;
|
||||
livenessKind?: TeamAgentRuntimeLivenessKind;
|
||||
pidSource?: TeamAgentRuntimePidSource;
|
||||
runtimeDiagnostic?: string;
|
||||
runtimeDiagnosticSeverity?: TeamAgentRuntimeDiagnosticSeverity;
|
||||
diagnostics?: string[];
|
||||
};
|
||||
pendingReason?: string;
|
||||
|
|
@ -16685,6 +16696,11 @@ export class TeamProvisioningService {
|
|||
hardFailureReason: runtimeEvidence.hardFailureReason,
|
||||
pendingPermissionRequestIds: runtimeEvidence.pendingPermissionRequestIds,
|
||||
runtimePid: runtimeEvidence.runtimePid,
|
||||
sessionId: runtimeEvidence.sessionId,
|
||||
livenessKind: runtimeEvidence.livenessKind,
|
||||
pidSource: runtimeEvidence.pidSource,
|
||||
runtimeDiagnostic: runtimeEvidence.runtimeDiagnostic,
|
||||
runtimeDiagnosticSeverity: runtimeEvidence.runtimeDiagnosticSeverity,
|
||||
diagnostics: runtimeEvidence.diagnostics,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -185,6 +185,8 @@ export function resolveTeamMemberRuntimeLiveness(
|
|||
): ResolvedTeamMemberRuntimeLiveness {
|
||||
const tracked = input.trackedSpawnStatus;
|
||||
const runtimeSessionId = input.runtimeSessionId ?? input.persistedRuntimeSessionId;
|
||||
const hasConfirmedBootstrap =
|
||||
tracked?.bootstrapConfirmed === true || tracked?.launchState === 'confirmed_alive';
|
||||
const diagnostics: string[] = [];
|
||||
if (!input.processTableAvailable) {
|
||||
diagnostics.push('process table unavailable');
|
||||
|
|
@ -230,15 +232,38 @@ export function resolveTeamMemberRuntimeLiveness(
|
|||
if (runtimePidRow && input.providerId === 'opencode') {
|
||||
const processCommand = sanitizeProcessCommandForDiagnostics(runtimePidRow.command);
|
||||
if (isOpenCodeRuntimeProcess(runtimePidRow.command)) {
|
||||
if (hasConfirmedBootstrap) {
|
||||
return result({
|
||||
alive: true,
|
||||
livenessKind: 'runtime_process',
|
||||
pidSource: 'opencode_bridge',
|
||||
pid: runtimePidRow.pid,
|
||||
runtimeSessionId,
|
||||
processCommand,
|
||||
runtimeLastSeenAt: tracked?.lastHeartbeatAt ?? tracked?.updatedAt,
|
||||
runtimeDiagnostic: 'OpenCode runtime process detected after bootstrap confirmation',
|
||||
diagnostics: [
|
||||
...diagnostics,
|
||||
'matched OpenCode runtime pid and process identity',
|
||||
'bootstrap confirmed',
|
||||
],
|
||||
});
|
||||
}
|
||||
return result({
|
||||
alive: true,
|
||||
livenessKind: 'runtime_process',
|
||||
alive: false,
|
||||
livenessKind: 'runtime_process_candidate',
|
||||
pidSource: 'opencode_bridge',
|
||||
pid: runtimePidRow.pid,
|
||||
runtimeSessionId,
|
||||
processCommand,
|
||||
runtimeDiagnostic: 'OpenCode runtime process detected',
|
||||
diagnostics: [...diagnostics, 'matched OpenCode runtime pid and process identity'],
|
||||
runtimeDiagnostic:
|
||||
'OpenCode runtime process detected, but teammate bootstrap is not confirmed',
|
||||
runtimeDiagnosticSeverity: 'warning',
|
||||
diagnostics: [
|
||||
...diagnostics,
|
||||
'matched OpenCode runtime pid and process identity',
|
||||
'waiting for teammate bootstrap confirmation',
|
||||
],
|
||||
});
|
||||
}
|
||||
return result({
|
||||
|
|
@ -257,7 +282,7 @@ export function resolveTeamMemberRuntimeLiveness(
|
|||
});
|
||||
}
|
||||
|
||||
if (tracked?.bootstrapConfirmed === true || tracked?.launchState === 'confirmed_alive') {
|
||||
if (hasConfirmedBootstrap) {
|
||||
return result({
|
||||
alive: true,
|
||||
livenessKind: 'confirmed_bootstrap',
|
||||
|
|
|
|||
|
|
@ -556,6 +556,14 @@ function mapBridgeMemberToRuntimeEvidence(
|
|||
: runtimeMaterialized || sessionId
|
||||
? 'OpenCode session exists without verified runtime pid'
|
||||
: undefined;
|
||||
const runtimeDiagnosticSeverity = failed
|
||||
? 'error'
|
||||
: pendingRuntimeObserved ||
|
||||
launchState === 'permission_blocked' ||
|
||||
runtimeMaterialized ||
|
||||
sessionId
|
||||
? 'warning'
|
||||
: undefined;
|
||||
return {
|
||||
memberName,
|
||||
providerId: 'opencode',
|
||||
|
|
@ -585,6 +593,7 @@ function mapBridgeMemberToRuntimeEvidence(
|
|||
livenessKind,
|
||||
...(hasRuntimePid ? { pidSource: 'opencode_bridge' as const } : {}),
|
||||
...(runtimeDiagnostic ? { runtimeDiagnostic } : {}),
|
||||
...(runtimeDiagnosticSeverity ? { runtimeDiagnosticSeverity } : {}),
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import type {
|
|||
PersistedTeamLaunchPhase,
|
||||
PersistedTeamLaunchSnapshot,
|
||||
TeamAgentRuntimeBackendType,
|
||||
TeamAgentRuntimeDiagnosticSeverity,
|
||||
TeamAgentRuntimeLivenessKind,
|
||||
TeamAgentRuntimePidSource,
|
||||
TeamLaunchAggregateState,
|
||||
|
|
@ -78,6 +79,7 @@ export interface TeamRuntimeMemberLaunchEvidence {
|
|||
livenessKind?: TeamAgentRuntimeLivenessKind;
|
||||
pidSource?: TeamAgentRuntimePidSource;
|
||||
runtimeDiagnostic?: string;
|
||||
runtimeDiagnosticSeverity?: TeamAgentRuntimeDiagnosticSeverity;
|
||||
diagnostics: string[];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -44,13 +44,16 @@ function isOpenCodeDeliveryAccepted(delivery: OpenCodeTaskStallDelivery): boolea
|
|||
if (delivery.queuedBehindMessageId) {
|
||||
return false;
|
||||
}
|
||||
if (delivery.responsePending === true) {
|
||||
return false;
|
||||
}
|
||||
if (delivery.accepted === true) {
|
||||
return true;
|
||||
}
|
||||
if (delivery.delivered === true && delivery.responsePending !== true) {
|
||||
if (delivery.delivered === true) {
|
||||
return true;
|
||||
}
|
||||
return Boolean(delivery.responsePending === true && delivery.ledgerRecordId);
|
||||
return false;
|
||||
}
|
||||
|
||||
export class TeamTaskStallNotifier {
|
||||
|
|
|
|||
|
|
@ -36,6 +36,15 @@ const WINDOW_GRACE_AFTER_MS = 15_000;
|
|||
const ATTRIBUTION_WINDOW_GRACE_MS = 1_000;
|
||||
const TASK_MARKER_CONTEXT_BEFORE_MESSAGES = 1;
|
||||
const TASK_MARKER_CONTEXT_MAX_MS = 5 * 60_000;
|
||||
const NATIVE_TOOL_CONTEXT_BEFORE_MS = 5 * 60_000;
|
||||
const NATIVE_TOOL_CONTEXT_AFTER_MS = 5 * 60_000;
|
||||
|
||||
const AGENT_TEAMS_TOOL_PREFIXES = [
|
||||
'mcp__agent-teams__',
|
||||
'mcp__agent_teams__',
|
||||
'agent-teams_',
|
||||
'agent_teams_',
|
||||
] as const;
|
||||
|
||||
const TASK_LOG_MARKER_TOOL_NAMES = new Set<string>([
|
||||
'task_start',
|
||||
|
|
@ -52,6 +61,22 @@ const TASK_LOG_MARKER_TOOL_NAMES = new Set<string>([
|
|||
'review_request_changes',
|
||||
]);
|
||||
|
||||
const BOARD_MCP_TOOL_NAMES = new Set<string>([
|
||||
...TASK_LOG_MARKER_TOOL_NAMES,
|
||||
'runtime_bootstrap_checkin',
|
||||
'member_briefing',
|
||||
'message_send',
|
||||
'cross_team_send',
|
||||
'task_create',
|
||||
'task_create_from_message',
|
||||
'task_get',
|
||||
'task_get_comment',
|
||||
'task_list',
|
||||
'task_update',
|
||||
'task_delete',
|
||||
'process_list',
|
||||
]);
|
||||
|
||||
const TERMINAL_TASK_MARKER_TOOL_NAMES = new Set<string>([
|
||||
'task_complete',
|
||||
'review_approve',
|
||||
|
|
@ -104,6 +129,13 @@ interface TaskMarkerProjection {
|
|||
messages: ParsedMessage[];
|
||||
markerMatchCount: number;
|
||||
markerSpanCount: number;
|
||||
boardMcpToolCount: number;
|
||||
nativeToolCount: number;
|
||||
}
|
||||
|
||||
interface ProjectionToolCounts {
|
||||
boardMcpToolCount: number;
|
||||
nativeToolCount: number;
|
||||
}
|
||||
|
||||
type HeuristicFallbackReason =
|
||||
|
|
@ -284,6 +316,44 @@ function refsIntersect(left: Set<string>, right: Set<string>): boolean {
|
|||
return false;
|
||||
}
|
||||
|
||||
function isBoardMcpToolName(rawName: string): boolean {
|
||||
const normalizedRawName = rawName
|
||||
.trim()
|
||||
.replace(/^proxy_/, '')
|
||||
.toLowerCase();
|
||||
const canonicalName = canonicalizeAgentTeamsToolName(rawName).trim().toLowerCase();
|
||||
return (
|
||||
AGENT_TEAMS_TOOL_PREFIXES.some((prefix) => normalizedRawName.startsWith(prefix)) ||
|
||||
BOARD_MCP_TOOL_NAMES.has(canonicalName)
|
||||
);
|
||||
}
|
||||
|
||||
function isNativeOpenCodeToolName(rawName: string): boolean {
|
||||
const normalizedName = rawName.trim();
|
||||
return normalizedName.length > 0 && !isBoardMcpToolName(normalizedName);
|
||||
}
|
||||
|
||||
function messageHasNativeOpenCodeToolCall(message: ParsedMessage): boolean {
|
||||
return message.toolCalls.some((toolCall) => isNativeOpenCodeToolName(toolCall.name ?? ''));
|
||||
}
|
||||
|
||||
function countProjectionToolCalls(messages: ParsedMessage[]): ProjectionToolCounts {
|
||||
let boardMcpToolCount = 0;
|
||||
let nativeToolCount = 0;
|
||||
|
||||
for (const message of messages) {
|
||||
for (const toolCall of message.toolCalls) {
|
||||
if (isNativeOpenCodeToolName(toolCall.name ?? '')) {
|
||||
nativeToolCount += 1;
|
||||
} else if (isBoardMcpToolName(toolCall.name ?? '')) {
|
||||
boardMcpToolCount += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { boardMcpToolCount, nativeToolCount };
|
||||
}
|
||||
|
||||
function markerInputReferencesTaskInTeam(
|
||||
input: unknown,
|
||||
teamName: string,
|
||||
|
|
@ -504,11 +574,16 @@ function resolveMarkerSpanStart(messages: ParsedMessage[], markerIndex: number):
|
|||
function findLastMessageIndexInWindow(
|
||||
messages: ParsedMessage[],
|
||||
startIndex: number,
|
||||
window: TimeWindow
|
||||
window: TimeWindow,
|
||||
maxEndMs = Number.POSITIVE_INFINITY
|
||||
): number {
|
||||
let endIndex = startIndex;
|
||||
for (let index = startIndex + 1; index < messages.length; index += 1) {
|
||||
if (!isWithinSingleTimeWindow(messages[index].timestamp, window)) {
|
||||
const message = messages[index];
|
||||
if (!message || message.timestamp.getTime() > maxEndMs) {
|
||||
break;
|
||||
}
|
||||
if (!isWithinSingleTimeWindow(message.timestamp, window)) {
|
||||
break;
|
||||
}
|
||||
endIndex = index;
|
||||
|
|
@ -562,7 +637,11 @@ function buildMarkerSpan(
|
|||
lastMarker.windowIndex === null ? undefined : (windows[lastMarker.windowIndex] ?? undefined);
|
||||
|
||||
if (!isTerminalTaskMarkerMatch(lastMarker) && window) {
|
||||
endIndex = findLastMessageIndexInWindow(messages, lastMarker.index, window);
|
||||
const maxEndMs =
|
||||
window.endMs === null
|
||||
? messages[lastMarker.index].timestamp.getTime() + TASK_MARKER_CONTEXT_MAX_MS
|
||||
: Number.POSITIVE_INFINITY;
|
||||
endIndex = findLastMessageIndexInWindow(messages, lastMarker.index, window, maxEndMs);
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
@ -571,6 +650,107 @@ function buildMarkerSpan(
|
|||
};
|
||||
}
|
||||
|
||||
function clampWindowToTaskWindow(
|
||||
window: TimeWindow,
|
||||
taskWindow: TimeWindow | undefined
|
||||
): TimeWindow {
|
||||
if (!taskWindow) {
|
||||
return window;
|
||||
}
|
||||
|
||||
const taskEndMs = taskWindow.endMs ?? Date.now();
|
||||
return {
|
||||
startMs: Math.max(window.startMs, taskWindow.startMs),
|
||||
endMs: Math.min(window.endMs ?? taskEndMs, taskEndMs),
|
||||
};
|
||||
}
|
||||
|
||||
function buildNativeToolWindowForMarkerGroup(
|
||||
messages: ParsedMessage[],
|
||||
markerGroup: TaskMarkerMatch[],
|
||||
span: { startIndex: number; endIndex: number },
|
||||
taskWindows: TimeWindow[]
|
||||
): TimeWindow | null {
|
||||
const firstMarker = markerGroup[0];
|
||||
const lastMarker = markerGroup[markerGroup.length - 1];
|
||||
if (!firstMarker || !lastMarker) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const groupHasStartMarker = markerGroup.some((match) =>
|
||||
match.markerCalls.some((markerCall) => markerCall.toolName === 'task_start')
|
||||
);
|
||||
const spanStartMessage = messages[span.startIndex];
|
||||
const lastMarkerMessage = messages[lastMarker.index];
|
||||
if (!spanStartMessage || !lastMarkerMessage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const startMs = groupHasStartMarker
|
||||
? spanStartMessage.timestamp.getTime()
|
||||
: lastMarkerMessage.timestamp.getTime() - NATIVE_TOOL_CONTEXT_BEFORE_MS;
|
||||
const taskWindow =
|
||||
lastMarker.windowIndex === null
|
||||
? undefined
|
||||
: (taskWindows[lastMarker.windowIndex] ?? undefined);
|
||||
const endMs = isTerminalTaskMarkerMatch(lastMarker)
|
||||
? Math.max(
|
||||
messages[span.endIndex]?.timestamp.getTime() ?? lastMarkerMessage.timestamp.getTime(),
|
||||
lastMarkerMessage.timestamp.getTime()
|
||||
)
|
||||
: (taskWindow?.endMs ?? lastMarkerMessage.timestamp.getTime() + NATIVE_TOOL_CONTEXT_AFTER_MS);
|
||||
const clamped = clampWindowToTaskWindow({ startMs, endMs }, taskWindow);
|
||||
return clamped.startMs <= (clamped.endMs ?? Date.now()) ? clamped : null;
|
||||
}
|
||||
|
||||
function addNativeToolIndexesInWindows(
|
||||
includedIndexes: Set<number>,
|
||||
messages: ParsedMessage[],
|
||||
windows: TimeWindow[]
|
||||
): void {
|
||||
if (windows.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (let index = 0; index < messages.length; index += 1) {
|
||||
const message = messages[index];
|
||||
if (!messageHasNativeOpenCodeToolCall(message)) {
|
||||
continue;
|
||||
}
|
||||
if (isWithinTimeWindows(message.timestamp, windows)) {
|
||||
includedIndexes.add(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function addToolResultIndexesForIncludedAssistants(
|
||||
includedIndexes: Set<number>,
|
||||
messages: ParsedMessage[]
|
||||
): void {
|
||||
const includedAssistantUuids = new Set<string>();
|
||||
for (const index of includedIndexes) {
|
||||
const message = messages[index];
|
||||
if (message?.type === 'assistant') {
|
||||
includedAssistantUuids.add(message.uuid);
|
||||
}
|
||||
}
|
||||
|
||||
if (includedAssistantUuids.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (let index = 0; index < messages.length; index += 1) {
|
||||
const message = messages[index];
|
||||
if (
|
||||
message?.isMeta &&
|
||||
message.sourceToolAssistantUUID &&
|
||||
includedAssistantUuids.has(message.sourceToolAssistantUUID)
|
||||
) {
|
||||
includedIndexes.add(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildTaskMarkerProjection(
|
||||
projectedMessages: OpenCodeRuntimeTranscriptLogMessage[],
|
||||
teamName: string,
|
||||
|
|
@ -595,15 +775,36 @@ function buildTaskMarkerProjection(
|
|||
return null;
|
||||
}
|
||||
|
||||
const spans = groupMarkerMatches(markerMatches, taskWindows)
|
||||
.map((group) => buildMarkerSpan(parsedMessages, group, taskWindows))
|
||||
.filter((span): span is { startIndex: number; endIndex: number } => span !== null);
|
||||
const markerGroups = groupMarkerMatches(markerMatches, taskWindows);
|
||||
const spansWithGroups = markerGroups
|
||||
.map((group) => {
|
||||
const span = buildMarkerSpan(parsedMessages, group, taskWindows);
|
||||
return span ? { group, span } : null;
|
||||
})
|
||||
.filter(
|
||||
(
|
||||
item
|
||||
): item is { group: TaskMarkerMatch[]; span: { startIndex: number; endIndex: number } } =>
|
||||
item !== null
|
||||
);
|
||||
const includedIndexes = new Set<number>();
|
||||
for (const span of spans) {
|
||||
const nativeToolWindows: TimeWindow[] = [];
|
||||
for (const { group, span } of spansWithGroups) {
|
||||
for (let index = span.startIndex; index <= span.endIndex; index += 1) {
|
||||
includedIndexes.add(index);
|
||||
}
|
||||
const nativeToolWindow = buildNativeToolWindowForMarkerGroup(
|
||||
parsedMessages,
|
||||
group,
|
||||
span,
|
||||
taskWindows
|
||||
);
|
||||
if (nativeToolWindow) {
|
||||
nativeToolWindows.push(nativeToolWindow);
|
||||
}
|
||||
}
|
||||
addNativeToolIndexesInWindows(includedIndexes, parsedMessages, nativeToolWindows);
|
||||
addToolResultIndexesForIncludedAssistants(includedIndexes, parsedMessages);
|
||||
|
||||
const messages = [...includedIndexes]
|
||||
.sort((left, right) => left - right)
|
||||
|
|
@ -618,7 +819,8 @@ function buildTaskMarkerProjection(
|
|||
? {
|
||||
messages,
|
||||
markerMatchCount,
|
||||
markerSpanCount: spans.length,
|
||||
markerSpanCount: spansWithGroups.length,
|
||||
...countProjectionToolCalls(messages),
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
|
@ -989,6 +1191,12 @@ export class OpenCodeTaskLogStreamSource {
|
|||
.filter((message): message is ParsedMessage => message !== null)
|
||||
.filter((message) => isWithinTimeWindows(message.timestamp, timeWindows))
|
||||
.sort((left, right) => left.timestamp.getTime() - right.timestamp.getTime());
|
||||
const toolCounts = markerProjection
|
||||
? {
|
||||
boardMcpToolCount: markerProjection.boardMcpToolCount,
|
||||
nativeToolCount: markerProjection.nativeToolCount,
|
||||
}
|
||||
: countProjectionToolCalls(filteredMessages);
|
||||
|
||||
if (filteredMessages.length === 0) {
|
||||
return null;
|
||||
|
|
@ -1017,7 +1225,7 @@ export class OpenCodeTaskLogStreamSource {
|
|||
};
|
||||
|
||||
logger.debug(
|
||||
`[${teamName}/${task.id}] using OpenCode runtime fallback for task log stream (${filteredMessages.length} messages, owner=${ownerName})`
|
||||
`[${teamName}/${task.id}] using OpenCode runtime fallback for task log stream (${filteredMessages.length} messages, owner=${ownerName}, boardMcpTools=${toolCounts.boardMcpToolCount}, nativeTools=${toolCounts.nativeToolCount})`
|
||||
);
|
||||
|
||||
return {
|
||||
|
|
@ -1030,6 +1238,7 @@ export class OpenCodeTaskLogStreamSource {
|
|||
mode: 'heuristic',
|
||||
attributionRecordCount: projectionContext.attributionRecordCount,
|
||||
projectedMessageCount: filteredMessages.length,
|
||||
...toolCounts,
|
||||
fallbackReason: projectionReason,
|
||||
...(markerProjection
|
||||
? {
|
||||
|
|
@ -1122,6 +1331,8 @@ export class OpenCodeTaskLogStreamSource {
|
|||
const participants: BoardTaskLogParticipant[] = [];
|
||||
const segments: BoardTaskLogSegment[] = [];
|
||||
let projectedMessageCount = 0;
|
||||
let boardMcpToolCount = 0;
|
||||
let nativeToolCount = 0;
|
||||
for (const member of members.sort((left, right) => {
|
||||
const leftStart = left.messages[0]?.timestamp.getTime() ?? 0;
|
||||
const rightStart = right.messages[0]?.timestamp.getTime() ?? 0;
|
||||
|
|
@ -1142,7 +1353,10 @@ export class OpenCodeTaskLogStreamSource {
|
|||
}
|
||||
|
||||
const participant = buildParticipant(member.memberName);
|
||||
const memberToolCounts = countProjectionToolCalls(member.messages);
|
||||
projectedMessageCount += member.messages.length;
|
||||
boardMcpToolCount += memberToolCounts.boardMcpToolCount;
|
||||
nativeToolCount += memberToolCounts.nativeToolCount;
|
||||
participants.push(participant);
|
||||
segments.push({
|
||||
id: `opencode-attributed:${teamName}:${task.id}:${normalizeMemberName(member.memberName)}`,
|
||||
|
|
@ -1159,7 +1373,7 @@ export class OpenCodeTaskLogStreamSource {
|
|||
}
|
||||
|
||||
logger.debug(
|
||||
`[${teamName}/${task.id}] using OpenCode task-log attribution (${segments.length} segment(s), ${attributionRecords.length} record(s))`
|
||||
`[${teamName}/${task.id}] using OpenCode task-log attribution (${segments.length} segment(s), ${attributionRecords.length} record(s), boardMcpTools=${boardMcpToolCount}, nativeTools=${nativeToolCount})`
|
||||
);
|
||||
|
||||
return {
|
||||
|
|
@ -1172,6 +1386,8 @@ export class OpenCodeTaskLogStreamSource {
|
|||
mode: 'attribution',
|
||||
attributionRecordCount: attributionRecords.length,
|
||||
projectedMessageCount,
|
||||
boardMcpToolCount,
|
||||
nativeToolCount,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -353,6 +353,8 @@ export interface BoardTaskLogStreamRuntimeProjection {
|
|||
mode: 'attribution' | 'heuristic';
|
||||
attributionRecordCount: number;
|
||||
projectedMessageCount: number;
|
||||
boardMcpToolCount?: number;
|
||||
nativeToolCount?: number;
|
||||
fallbackReason?:
|
||||
| 'no_attribution_records'
|
||||
| 'attribution_no_projected_messages'
|
||||
|
|
|
|||
|
|
@ -32,6 +32,21 @@ const RELAY_WORKS_10_TASK: TeamTask = {
|
|||
],
|
||||
};
|
||||
|
||||
const RELAY_WORKS_10_COORDINATION_TASK: TeamTask = {
|
||||
id: 'b5534868-0901-4c9e-9296-2b6e2059a08f',
|
||||
displayId: 'b5534868',
|
||||
subject: 'Split calculator implementation work',
|
||||
owner: 'jack',
|
||||
status: 'in_progress',
|
||||
createdAt: '2026-04-24T20:28:58.000Z',
|
||||
updatedAt: '2026-04-24T20:31:21.876Z',
|
||||
workIntervals: [
|
||||
{
|
||||
startedAt: '2026-04-24T20:28:58.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
async function loadFixtureTranscript(): Promise<
|
||||
NonNullable<OpenCodeRuntimeTranscriptResponse['transcript']>
|
||||
> {
|
||||
|
|
@ -171,6 +186,43 @@ describe('OpenCodeTaskLogStreamSource real OpenCode fixture e2e', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('includes real native OpenCode read/bash tools from a task-scoped runtime projection', async () => {
|
||||
const transcript = await loadFixtureTranscript();
|
||||
const { source } = createSource({
|
||||
transcript,
|
||||
activeTasks: [RELAY_WORKS_10_COORDINATION_TASK],
|
||||
});
|
||||
|
||||
const response = await source.getTaskLogStream(
|
||||
'relay-works-10',
|
||||
RELAY_WORKS_10_COORDINATION_TASK.id
|
||||
);
|
||||
|
||||
expect(response).not.toBeNull();
|
||||
expect(response?.source).toBe('opencode_runtime_fallback');
|
||||
expect(response?.runtimeProjection).toMatchObject({
|
||||
provider: 'opencode',
|
||||
mode: 'heuristic',
|
||||
fallbackReason: 'task_tool_markers',
|
||||
});
|
||||
expect(response?.runtimeProjection?.boardMcpToolCount).toBeGreaterThan(0);
|
||||
expect(response?.runtimeProjection?.nativeToolCount).toBeGreaterThanOrEqual(2);
|
||||
|
||||
const rawMessages = flattenRawMessages(response as BoardTaskLogStreamResponse);
|
||||
const toolNames = rawMessages.flatMap((message) =>
|
||||
message.toolCalls.map((toolCall) => toolCall.name)
|
||||
);
|
||||
const serialized = rawMessages.map(serializeContent).join('\n');
|
||||
|
||||
expect(toolNames).toEqual(expect.arrayContaining(['read', 'bash']));
|
||||
expect(toolNames).toEqual(
|
||||
expect.arrayContaining(['agent-teams_task_start', 'agent-teams_task_add_comment'])
|
||||
);
|
||||
expect(serialized).toContain('package.json');
|
||||
expect(serialized).toContain('Разбил работу на мелкие задачи');
|
||||
expect(toolNames).not.toContain('SendMessage');
|
||||
});
|
||||
|
||||
it('uses real attribution UUID bounds before heuristic fallback', async () => {
|
||||
const transcript = await loadFixtureTranscript();
|
||||
const { source, bridge, attributionStore } = createSource({
|
||||
|
|
@ -196,6 +248,8 @@ describe('OpenCodeTaskLogStreamSource real OpenCode fixture e2e', () => {
|
|||
mode: 'attribution',
|
||||
attributionRecordCount: 1,
|
||||
projectedMessageCount: 10,
|
||||
boardMcpToolCount: 4,
|
||||
nativeToolCount: 0,
|
||||
});
|
||||
expect(response?.defaultFilter).toBe('member:jack');
|
||||
expect(response?.segments).toHaveLength(1);
|
||||
|
|
@ -353,7 +407,9 @@ describe('OpenCodeTaskLogStreamSource real OpenCode fixture e2e', () => {
|
|||
const assistantToolIds = new Set(
|
||||
projectedMessages.flatMap((message) => message.toolCalls.map((toolCall) => toolCall.id))
|
||||
);
|
||||
const toolResultMessages = projectedMessages.filter((message) => message.toolResults.length > 0);
|
||||
const toolResultMessages = projectedMessages.filter(
|
||||
(message) => message.toolResults.length > 0
|
||||
);
|
||||
|
||||
expect(projectedMessages).toHaveLength(101);
|
||||
expect(toolResultMessages.length).toBeGreaterThan(20);
|
||||
|
|
|
|||
|
|
@ -233,6 +233,8 @@ describe('OpenCodeTaskLogStreamSource', () => {
|
|||
mode: 'heuristic',
|
||||
attributionRecordCount: 0,
|
||||
projectedMessageCount: 2,
|
||||
boardMcpToolCount: 0,
|
||||
nativeToolCount: 1,
|
||||
fallbackReason: 'no_attribution_records',
|
||||
});
|
||||
expect(first?.participants).toEqual([
|
||||
|
|
@ -254,7 +256,9 @@ describe('OpenCodeTaskLogStreamSource', () => {
|
|||
expect(chunkBuilder.buildBundleChunks).toHaveBeenCalledTimes(1);
|
||||
expect(chunkBuilder.buildBundleChunks.mock.calls[0]?.[0]).toHaveLength(2);
|
||||
expect(
|
||||
chunkBuilder.buildBundleChunks.mock.calls[0]?.[0].map((message: { uuid: string }) => message.uuid)
|
||||
chunkBuilder.buildBundleChunks.mock.calls[0]?.[0].map(
|
||||
(message: { uuid: string }) => message.uuid
|
||||
)
|
||||
).toEqual(['assistant-1', 'assistant-1::tool_results']);
|
||||
expect(bridge.getOpenCodeTranscript).toHaveBeenCalledTimes(1);
|
||||
expect(second).toEqual(first);
|
||||
|
|
@ -529,12 +533,16 @@ describe('OpenCodeTaskLogStreamSource', () => {
|
|||
mode: 'heuristic',
|
||||
attributionRecordCount: 0,
|
||||
projectedMessageCount: 6,
|
||||
boardMcpToolCount: 2,
|
||||
nativeToolCount: 0,
|
||||
fallbackReason: 'task_tool_markers',
|
||||
markerMatchCount: 2,
|
||||
markerSpanCount: 1,
|
||||
});
|
||||
expect(
|
||||
chunkBuilder.buildBundleChunks.mock.calls[0]?.[0].map((message: { uuid: string }) => message.uuid)
|
||||
chunkBuilder.buildBundleChunks.mock.calls[0]?.[0].map(
|
||||
(message: { uuid: string }) => message.uuid
|
||||
)
|
||||
).toEqual([
|
||||
'user-task-prompt',
|
||||
'assistant-start',
|
||||
|
|
@ -545,6 +553,226 @@ describe('OpenCodeTaskLogStreamSource', () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it('keeps native OpenCode tools near task markers in the task stream', async () => {
|
||||
const bridge = {
|
||||
getOpenCodeTranscript: vi.fn(async () => ({
|
||||
sessionId: 'session-opencode',
|
||||
logProjection: {
|
||||
messages: [
|
||||
taskMarkerLogMessage({
|
||||
uuid: 'native-before-task',
|
||||
timestamp: '2026-04-21T09:50:00.000Z',
|
||||
toolName: 'read',
|
||||
input: { filePath: '/tmp/unrelated.ts' },
|
||||
}),
|
||||
textLogMessage({
|
||||
uuid: 'user-task-prompt',
|
||||
type: 'user',
|
||||
role: 'user',
|
||||
timestamp: '2026-04-21T10:01:00.000Z',
|
||||
content: [{ type: 'text', text: 'Start task-a now' }],
|
||||
}),
|
||||
taskMarkerLogMessage({
|
||||
uuid: 'assistant-start',
|
||||
parentUuid: 'user-task-prompt',
|
||||
timestamp: '2026-04-21T10:02:00.000Z',
|
||||
toolName: 'mcp__agent-teams__task_start',
|
||||
input: { teamName: 'team-a', taskId: 'task-a' },
|
||||
}),
|
||||
toolResultLogMessage({
|
||||
uuid: 'assistant-start::tool_results',
|
||||
parentUuid: 'assistant-start',
|
||||
timestamp: '2026-04-21T10:02:01.000Z',
|
||||
sourceToolAssistantUUID: 'assistant-start',
|
||||
}),
|
||||
taskMarkerLogMessage({
|
||||
uuid: 'native-read',
|
||||
parentUuid: 'assistant-start::tool_results',
|
||||
timestamp: '2026-04-21T10:03:00.000Z',
|
||||
toolName: 'read',
|
||||
input: { filePath: '/tmp/app.ts' },
|
||||
}),
|
||||
toolResultLogMessage({
|
||||
uuid: 'native-read::tool_results',
|
||||
parentUuid: 'native-read',
|
||||
timestamp: '2026-04-21T10:03:01.000Z',
|
||||
sourceToolAssistantUUID: 'native-read',
|
||||
}),
|
||||
taskMarkerLogMessage({
|
||||
uuid: 'native-bash',
|
||||
parentUuid: 'native-read::tool_results',
|
||||
timestamp: '2026-04-21T10:04:00.000Z',
|
||||
toolName: 'bash',
|
||||
input: { command: 'pnpm test' },
|
||||
}),
|
||||
toolResultLogMessage({
|
||||
uuid: 'native-bash::tool_results',
|
||||
parentUuid: 'native-bash',
|
||||
timestamp: '2026-04-21T10:04:01.000Z',
|
||||
sourceToolAssistantUUID: 'native-bash',
|
||||
}),
|
||||
taskMarkerLogMessage({
|
||||
uuid: 'assistant-comment',
|
||||
parentUuid: 'native-bash::tool_results',
|
||||
timestamp: '2026-04-21T10:05:00.000Z',
|
||||
toolName: 'mcp__agent-teams__task_add_comment',
|
||||
input: { teamName: 'team-a', taskId: 'task-a', text: 'Tests passed' },
|
||||
}),
|
||||
toolResultLogMessage({
|
||||
uuid: 'assistant-comment::tool_results',
|
||||
parentUuid: 'assistant-comment',
|
||||
timestamp: '2026-04-21T10:05:01.000Z',
|
||||
sourceToolAssistantUUID: 'assistant-comment',
|
||||
}),
|
||||
taskMarkerLogMessage({
|
||||
uuid: 'native-after-task',
|
||||
timestamp: '2026-04-21T10:20:00.000Z',
|
||||
toolName: 'bash',
|
||||
input: { command: 'echo unrelated' },
|
||||
}),
|
||||
],
|
||||
},
|
||||
})),
|
||||
};
|
||||
const chunkBuilder = {
|
||||
buildBundleChunks: vi.fn((messages) => [
|
||||
{
|
||||
id: 'chunk-native-tools',
|
||||
kind: 'assistant',
|
||||
messages,
|
||||
},
|
||||
]),
|
||||
};
|
||||
const source = new OpenCodeTaskLogStreamSource(
|
||||
bridge as never,
|
||||
{ resolve: async () => '/tmp/claude' },
|
||||
{
|
||||
getTasks: async () => [
|
||||
createTask({
|
||||
workIntervals: [
|
||||
{
|
||||
startedAt: '2026-04-21T10:00:00.000Z',
|
||||
completedAt: '2026-04-21T10:06:00.000Z',
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
getDeletedTasks: async () => [],
|
||||
} as never,
|
||||
chunkBuilder as never,
|
||||
{ readTaskRecords: vi.fn(async () => []) }
|
||||
);
|
||||
|
||||
const response = await source.getTaskLogStream('team-a', 'task-a');
|
||||
|
||||
expect(response?.runtimeProjection).toEqual({
|
||||
provider: 'opencode',
|
||||
mode: 'heuristic',
|
||||
attributionRecordCount: 0,
|
||||
projectedMessageCount: 9,
|
||||
boardMcpToolCount: 2,
|
||||
nativeToolCount: 2,
|
||||
fallbackReason: 'task_tool_markers',
|
||||
markerMatchCount: 2,
|
||||
markerSpanCount: 1,
|
||||
});
|
||||
expect(
|
||||
chunkBuilder.buildBundleChunks.mock.calls[0]?.[0].map(
|
||||
(message: { uuid: string }) => message.uuid
|
||||
)
|
||||
).toEqual([
|
||||
'user-task-prompt',
|
||||
'assistant-start',
|
||||
'assistant-start::tool_results',
|
||||
'native-read',
|
||||
'native-read::tool_results',
|
||||
'native-bash',
|
||||
'native-bash::tool_results',
|
||||
'assistant-comment',
|
||||
'assistant-comment::tool_results',
|
||||
]);
|
||||
});
|
||||
|
||||
it('can include native OpenCode work shortly before a comment-only task marker', async () => {
|
||||
const bridge = {
|
||||
getOpenCodeTranscript: vi.fn(async () => ({
|
||||
sessionId: 'session-opencode',
|
||||
logProjection: {
|
||||
messages: [
|
||||
taskMarkerLogMessage({
|
||||
uuid: 'native-read',
|
||||
timestamp: '2026-04-21T10:02:00.000Z',
|
||||
toolName: 'read',
|
||||
input: { filePath: '/tmp/app.ts' },
|
||||
}),
|
||||
toolResultLogMessage({
|
||||
uuid: 'native-read::tool_results',
|
||||
parentUuid: 'native-read',
|
||||
timestamp: '2026-04-21T10:02:01.000Z',
|
||||
sourceToolAssistantUUID: 'native-read',
|
||||
}),
|
||||
taskMarkerLogMessage({
|
||||
uuid: 'assistant-comment',
|
||||
parentUuid: 'native-read::tool_results',
|
||||
timestamp: '2026-04-21T10:04:00.000Z',
|
||||
toolName: 'mcp__agent-teams__task_add_comment',
|
||||
input: { teamName: 'team-a', taskId: 'task-a', text: 'Found the issue' },
|
||||
}),
|
||||
toolResultLogMessage({
|
||||
uuid: 'assistant-comment::tool_results',
|
||||
parentUuid: 'assistant-comment',
|
||||
timestamp: '2026-04-21T10:04:01.000Z',
|
||||
sourceToolAssistantUUID: 'assistant-comment',
|
||||
}),
|
||||
],
|
||||
},
|
||||
})),
|
||||
};
|
||||
const chunkBuilder = {
|
||||
buildBundleChunks: vi.fn((messages) => [
|
||||
{
|
||||
id: 'chunk-comment-only-native',
|
||||
kind: 'assistant',
|
||||
messages,
|
||||
},
|
||||
]),
|
||||
};
|
||||
const source = new OpenCodeTaskLogStreamSource(
|
||||
bridge as never,
|
||||
{ resolve: async () => '/tmp/claude' },
|
||||
{
|
||||
getTasks: async () => [createTask()],
|
||||
getDeletedTasks: async () => [],
|
||||
} as never,
|
||||
chunkBuilder as never,
|
||||
{ readTaskRecords: vi.fn(async () => []) }
|
||||
);
|
||||
|
||||
const response = await source.getTaskLogStream('team-a', 'task-a');
|
||||
|
||||
expect(response?.runtimeProjection).toEqual({
|
||||
provider: 'opencode',
|
||||
mode: 'heuristic',
|
||||
attributionRecordCount: 0,
|
||||
projectedMessageCount: 4,
|
||||
boardMcpToolCount: 1,
|
||||
nativeToolCount: 1,
|
||||
fallbackReason: 'task_tool_markers',
|
||||
markerMatchCount: 1,
|
||||
markerSpanCount: 1,
|
||||
});
|
||||
expect(
|
||||
chunkBuilder.buildBundleChunks.mock.calls[0]?.[0].map(
|
||||
(message: { uuid: string }) => message.uuid
|
||||
)
|
||||
).toEqual([
|
||||
'native-read',
|
||||
'native-read::tool_results',
|
||||
'assistant-comment',
|
||||
'assistant-comment::tool_results',
|
||||
]);
|
||||
});
|
||||
|
||||
it('ignores OpenCode task markers that explicitly belong to another team', async () => {
|
||||
const bridge = {
|
||||
getOpenCodeTranscript: vi.fn(async () => ({
|
||||
|
|
@ -620,12 +848,16 @@ describe('OpenCodeTaskLogStreamSource', () => {
|
|||
mode: 'heuristic',
|
||||
attributionRecordCount: 0,
|
||||
projectedMessageCount: 3,
|
||||
boardMcpToolCount: 1,
|
||||
nativeToolCount: 0,
|
||||
fallbackReason: 'task_tool_markers',
|
||||
markerMatchCount: 1,
|
||||
markerSpanCount: 1,
|
||||
});
|
||||
expect(
|
||||
chunkBuilder.buildBundleChunks.mock.calls[0]?.[0].map((message: { uuid: string }) => message.uuid)
|
||||
chunkBuilder.buildBundleChunks.mock.calls[0]?.[0].map(
|
||||
(message: { uuid: string }) => message.uuid
|
||||
)
|
||||
).toEqual(['team-a-prompt', 'team-a-start', 'team-a-start::tool_results']);
|
||||
});
|
||||
|
||||
|
|
@ -743,12 +975,16 @@ describe('OpenCodeTaskLogStreamSource', () => {
|
|||
mode: 'heuristic',
|
||||
attributionRecordCount: 0,
|
||||
projectedMessageCount: 10,
|
||||
boardMcpToolCount: 3,
|
||||
nativeToolCount: 0,
|
||||
fallbackReason: 'task_tool_markers',
|
||||
markerMatchCount: 3,
|
||||
markerSpanCount: 2,
|
||||
});
|
||||
expect(
|
||||
chunkBuilder.buildBundleChunks.mock.calls[0]?.[0].map((message: { uuid: string }) => message.uuid)
|
||||
chunkBuilder.buildBundleChunks.mock.calls[0]?.[0].map(
|
||||
(message: { uuid: string }) => message.uuid
|
||||
)
|
||||
).toEqual([
|
||||
'cycle-1-prompt',
|
||||
'cycle-1-start',
|
||||
|
|
@ -810,10 +1046,14 @@ describe('OpenCodeTaskLogStreamSource', () => {
|
|||
mode: 'heuristic',
|
||||
attributionRecordCount: 0,
|
||||
projectedMessageCount: 1,
|
||||
boardMcpToolCount: 0,
|
||||
nativeToolCount: 0,
|
||||
fallbackReason: 'no_attribution_records',
|
||||
});
|
||||
expect(
|
||||
chunkBuilder.buildBundleChunks.mock.calls[0]?.[0].map((message: { uuid: string }) => message.uuid)
|
||||
chunkBuilder.buildBundleChunks.mock.calls[0]?.[0].map(
|
||||
(message: { uuid: string }) => message.uuid
|
||||
)
|
||||
).toEqual(['current-window-work']);
|
||||
});
|
||||
|
||||
|
|
@ -866,12 +1106,16 @@ describe('OpenCodeTaskLogStreamSource', () => {
|
|||
mode: 'heuristic',
|
||||
attributionRecordCount: 0,
|
||||
projectedMessageCount: 2,
|
||||
boardMcpToolCount: 1,
|
||||
nativeToolCount: 0,
|
||||
fallbackReason: 'task_tool_markers',
|
||||
markerMatchCount: 1,
|
||||
markerSpanCount: 1,
|
||||
});
|
||||
expect(
|
||||
chunkBuilder.buildBundleChunks.mock.calls[0]?.[0].map((message: { uuid: string }) => message.uuid)
|
||||
chunkBuilder.buildBundleChunks.mock.calls[0]?.[0].map(
|
||||
(message: { uuid: string }) => message.uuid
|
||||
)
|
||||
).toEqual(['display-ref-start', 'display-ref-start::tool_results']);
|
||||
});
|
||||
|
||||
|
|
@ -955,6 +1199,8 @@ describe('OpenCodeTaskLogStreamSource', () => {
|
|||
mode: 'attribution',
|
||||
attributionRecordCount: 1,
|
||||
projectedMessageCount: 1,
|
||||
boardMcpToolCount: 0,
|
||||
nativeToolCount: 0,
|
||||
});
|
||||
expect(response?.participants).toEqual([
|
||||
{
|
||||
|
|
@ -973,7 +1219,9 @@ describe('OpenCodeTaskLogStreamSource', () => {
|
|||
isSidechain: true,
|
||||
});
|
||||
expect(
|
||||
chunkBuilder.buildBundleChunks.mock.calls[0]?.[0].map((message: { uuid: string }) => message.uuid)
|
||||
chunkBuilder.buildBundleChunks.mock.calls[0]?.[0].map(
|
||||
(message: { uuid: string }) => message.uuid
|
||||
)
|
||||
).toEqual(['bob-inside']);
|
||||
expect(bridge.getOpenCodeTranscript).toHaveBeenCalledWith('/tmp/claude', {
|
||||
teamId: 'team-a',
|
||||
|
|
@ -1065,11 +1313,15 @@ describe('OpenCodeTaskLogStreamSource', () => {
|
|||
mode: 'heuristic',
|
||||
attributionRecordCount: 1,
|
||||
projectedMessageCount: 1,
|
||||
boardMcpToolCount: 0,
|
||||
nativeToolCount: 0,
|
||||
fallbackReason: 'attribution_no_projected_messages',
|
||||
});
|
||||
expect(response?.participants[0]?.label).toBe('alice');
|
||||
expect(
|
||||
chunkBuilder.buildBundleChunks.mock.calls.at(-1)?.[0].map((message: { uuid: string }) => message.uuid)
|
||||
chunkBuilder.buildBundleChunks.mock.calls
|
||||
.at(-1)?.[0]
|
||||
.map((message: { uuid: string }) => message.uuid)
|
||||
).toEqual(['alice-inside']);
|
||||
expect(bridge.getOpenCodeTranscript).toHaveBeenNthCalledWith(1, '/tmp/claude', {
|
||||
teamId: 'team-a',
|
||||
|
|
@ -1111,9 +1363,7 @@ describe('OpenCodeTaskLogStreamSource', () => {
|
|||
uuid: isBob ? 'bob-new-attribution' : 'alice-old-heuristic',
|
||||
parentUuid: undefined,
|
||||
type: 'assistant',
|
||||
timestamp: isBob
|
||||
? '2026-04-21T12:05:00.000Z'
|
||||
: '2026-04-21T10:05:00.000Z',
|
||||
timestamp: isBob ? '2026-04-21T12:05:00.000Z' : '2026-04-21T10:05:00.000Z',
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: isBob ? 'new attribution' : 'old heuristic' }],
|
||||
isMeta: false,
|
||||
|
|
@ -1168,7 +1418,9 @@ describe('OpenCodeTaskLogStreamSource', () => {
|
|||
limit: 500,
|
||||
});
|
||||
expect(
|
||||
chunkBuilder.buildBundleChunks.mock.calls.at(-1)?.[0].map((message: { uuid: string }) => message.uuid)
|
||||
chunkBuilder.buildBundleChunks.mock.calls
|
||||
.at(-1)?.[0]
|
||||
.map((message: { uuid: string }) => message.uuid)
|
||||
).toEqual(['bob-new-attribution']);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1222,7 +1222,7 @@ describe('TeamProvisioningService', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('shows RSS for OpenCode secondary lanes through the shared runtime host without exposing a member pid', async () => {
|
||||
it('shows RSS for OpenCode secondary lane host pids without treating pre-bootstrap runtime as alive', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
(svc as any).configReader = {
|
||||
getConfig: vi.fn(async () => ({
|
||||
|
|
@ -1325,15 +1325,16 @@ describe('TeamProvisioningService', () => {
|
|||
expect(pidusage).toHaveBeenCalledWith(333, { maxage: 0 });
|
||||
expect(snapshot.members.bob).toMatchObject({
|
||||
memberName: 'bob',
|
||||
alive: true,
|
||||
alive: false,
|
||||
restartable: false,
|
||||
pid: 333,
|
||||
runtimeModel: 'opencode/minimax-m2.5-free',
|
||||
rssBytes: 456_000_000,
|
||||
livenessKind: 'runtime_process_candidate',
|
||||
});
|
||||
});
|
||||
|
||||
it('shows RSS for persisted OpenCode secondary lane runtime pids after the launch run is gone', async () => {
|
||||
it('shows RSS for persisted OpenCode secondary lane host pids without treating historical bootstrap as live', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
(svc as any).configReader = {
|
||||
getConfig: vi.fn(async () => ({
|
||||
|
|
@ -1399,12 +1400,14 @@ describe('TeamProvisioningService', () => {
|
|||
expect(pidusage).toHaveBeenCalledWith([333], { maxage: 0 });
|
||||
expect(snapshot.members.bob).toMatchObject({
|
||||
memberName: 'bob',
|
||||
alive: true,
|
||||
alive: false,
|
||||
restartable: false,
|
||||
pid: 333,
|
||||
providerId: 'opencode',
|
||||
runtimeModel: 'opencode/minimax-m2.5-free',
|
||||
rssBytes: 456_000_000,
|
||||
historicalBootstrapConfirmed: true,
|
||||
livenessKind: 'runtime_process_candidate',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -9892,7 +9895,7 @@ describe('TeamProvisioningService', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('clears stale OpenCode bridge launch failure when the runtime process is verified alive', async () => {
|
||||
it('does not clear OpenCode bridge launch failure from process-only liveness', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
(svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn(
|
||||
async () =>
|
||||
|
|
@ -9900,12 +9903,13 @@ describe('TeamProvisioningService', () => {
|
|||
[
|
||||
'bob',
|
||||
{
|
||||
alive: true,
|
||||
alive: false,
|
||||
model: 'openrouter/google/gemini-2.5-flash',
|
||||
livenessKind: 'runtime_process',
|
||||
livenessKind: 'runtime_process_candidate',
|
||||
providerId: 'opencode',
|
||||
runtimeDiagnostic: 'OpenCode runtime process detected',
|
||||
runtimeDiagnosticSeverity: 'info',
|
||||
runtimeDiagnostic:
|
||||
'OpenCode runtime process detected, but teammate bootstrap is not confirmed',
|
||||
runtimeDiagnosticSeverity: 'warning',
|
||||
},
|
||||
],
|
||||
])
|
||||
|
|
@ -9922,17 +9926,18 @@ describe('TeamProvisioningService', () => {
|
|||
});
|
||||
|
||||
expect(result.bob).toMatchObject({
|
||||
status: 'online',
|
||||
launchState: 'runtime_pending_bootstrap',
|
||||
runtimeAlive: true,
|
||||
hardFailure: false,
|
||||
hardFailureReason: undefined,
|
||||
error: undefined,
|
||||
status: 'error',
|
||||
launchState: 'failed_to_start',
|
||||
runtimeAlive: false,
|
||||
hardFailure: true,
|
||||
hardFailureReason: 'OpenCode bridge reported member launch failure',
|
||||
error: 'OpenCode bridge reported member launch failure',
|
||||
runtimeModel: 'openrouter/google/gemini-2.5-flash',
|
||||
livenessKind: 'runtime_process',
|
||||
runtimeDiagnostic: 'OpenCode runtime process detected',
|
||||
runtimeDiagnosticSeverity: 'info',
|
||||
livenessSource: 'process',
|
||||
livenessKind: 'runtime_process_candidate',
|
||||
runtimeDiagnostic:
|
||||
'OpenCode runtime process detected, but teammate bootstrap is not confirmed',
|
||||
runtimeDiagnosticSeverity: 'warning',
|
||||
livenessSource: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ describe('resolveTeamMemberRuntimeLiveness', () => {
|
|||
expect(result.pid).toBe(301);
|
||||
});
|
||||
|
||||
it('promotes a live OpenCode runtime pid only when process identity matches', () => {
|
||||
it('keeps a live OpenCode runtime pid as candidate until bootstrap is confirmed', () => {
|
||||
const result = resolveTeamMemberRuntimeLiveness({
|
||||
teamName: 'demo',
|
||||
memberName: 'bob',
|
||||
|
|
@ -116,6 +116,36 @@ describe('resolveTeamMemberRuntimeLiveness', () => {
|
|||
nowIso: NOW,
|
||||
});
|
||||
|
||||
expect(result.alive).toBe(false);
|
||||
expect(result.livenessKind).toBe('runtime_process_candidate');
|
||||
expect(result.pidSource).toBe('opencode_bridge');
|
||||
expect(result.pid).toBe(404);
|
||||
expect(result.runtimeDiagnostic).toBe(
|
||||
'OpenCode runtime process detected, but teammate bootstrap is not confirmed'
|
||||
);
|
||||
});
|
||||
|
||||
it('promotes a live OpenCode runtime pid after bootstrap confirmation', () => {
|
||||
const result = resolveTeamMemberRuntimeLiveness({
|
||||
teamName: 'demo',
|
||||
memberName: 'bob',
|
||||
providerId: 'opencode',
|
||||
persistedRuntimePid: 404,
|
||||
persistedRuntimeSessionId: 'session-bob',
|
||||
trackedSpawnStatus: {
|
||||
status: 'online',
|
||||
launchState: 'confirmed_alive',
|
||||
agentToolAccepted: true,
|
||||
runtimeAlive: true,
|
||||
bootstrapConfirmed: true,
|
||||
hardFailure: false,
|
||||
updatedAt: NOW,
|
||||
},
|
||||
processRows: [{ pid: 404, ppid: 1, command: 'opencode runtime host' }],
|
||||
processTableAvailable: true,
|
||||
nowIso: NOW,
|
||||
});
|
||||
|
||||
expect(result.alive).toBe(true);
|
||||
expect(result.livenessKind).toBe('runtime_process');
|
||||
expect(result.pidSource).toBe('opencode_bridge');
|
||||
|
|
|
|||
|
|
@ -170,6 +170,31 @@ describe('TeamTaskStallNotifier', () => {
|
|||
await expect(notifier.notifyOpenCodeOwners('demo', [createAlert()])).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('does not mark response-pending delivery as remediated even after runtime acceptance', async () => {
|
||||
const relay = vi.fn(async () => ({
|
||||
relayed: 1,
|
||||
attempted: 1,
|
||||
delivered: 1,
|
||||
failed: 0,
|
||||
lastDelivery: {
|
||||
delivered: true,
|
||||
accepted: true,
|
||||
responsePending: true,
|
||||
ledgerRecordId: 'active-ledger-record',
|
||||
reason: 'opencode_delivery_response_pending',
|
||||
},
|
||||
}));
|
||||
const notifier = new TeamTaskStallNotifier(
|
||||
{ sendSystemNotificationToLead: vi.fn(async () => undefined) } as never,
|
||||
{ relayOpenCodeMemberInboxMessages: relay } as never,
|
||||
{ getMessagesFor: vi.fn(async () => []) } as never,
|
||||
{ sendMessage: vi.fn(async () => ({ deliveredToInbox: true, messageId: 'msg' })) } as never
|
||||
);
|
||||
|
||||
await expect(notifier.notifyOpenCodeOwners('demo', [createAlert()])).resolves.toEqual([]);
|
||||
expect(relay).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not deliver runtime nudge when inbox write fails', async () => {
|
||||
const relay = vi.fn(async () => ({ lastDelivery: { delivered: true } }));
|
||||
const notifier = new TeamTaskStallNotifier(
|
||||
|
|
|
|||
|
|
@ -13,15 +13,15 @@ import type { BoardTaskLogStreamResponse, TeamTask } from '../../../../../src/sh
|
|||
|
||||
const TEAM_NAME = 'relay-works-10';
|
||||
const TASK_ID = '0b3a0624-5d66-4067-848e-5a74a1720c0d';
|
||||
const COORDINATION_TASK_ID = 'b5534868-0901-4c9e-9296-2b6e2059a08f';
|
||||
const FIXTURE_PATH = path.resolve(
|
||||
process.cwd(),
|
||||
'test/fixtures/team/opencode/relay-works-10-jack-projection-transcript.json'
|
||||
);
|
||||
|
||||
const apiState = {
|
||||
getTaskLogStream: vi.fn<
|
||||
(teamName: string, taskId: string) => Promise<BoardTaskLogStreamResponse>
|
||||
>(),
|
||||
getTaskLogStream:
|
||||
vi.fn<(teamName: string, taskId: string) => Promise<BoardTaskLogStreamResponse>>(),
|
||||
onTeamChange: vi.fn<(callback: (event: unknown, data: unknown) => void) => () => void>(),
|
||||
};
|
||||
|
||||
|
|
@ -54,17 +54,36 @@ const RELAY_WORKS_10_TASK: TeamTask = {
|
|||
],
|
||||
};
|
||||
|
||||
const RELAY_WORKS_10_COORDINATION_TASK: TeamTask = {
|
||||
id: COORDINATION_TASK_ID,
|
||||
displayId: 'b5534868',
|
||||
subject: 'Split calculator implementation work',
|
||||
owner: 'jack',
|
||||
status: 'in_progress',
|
||||
createdAt: '2026-04-24T20:28:58.000Z',
|
||||
updatedAt: '2026-04-24T20:31:21.876Z',
|
||||
workIntervals: [
|
||||
{
|
||||
startedAt: '2026-04-24T20:28:58.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
async function loadFixtureTranscript(): Promise<
|
||||
NonNullable<OpenCodeRuntimeTranscriptResponse['transcript']>
|
||||
> {
|
||||
const parsed = JSON.parse(await readFile(FIXTURE_PATH, 'utf8')) as OpenCodeRuntimeTranscriptResponse;
|
||||
const parsed = JSON.parse(
|
||||
await readFile(FIXTURE_PATH, 'utf8')
|
||||
) as OpenCodeRuntimeTranscriptResponse;
|
||||
if (parsed.providerId !== 'opencode' || !parsed.transcript) {
|
||||
throw new Error('Invalid OpenCode transcript fixture');
|
||||
}
|
||||
return parsed.transcript;
|
||||
}
|
||||
|
||||
async function buildFixtureStream(): Promise<BoardTaskLogStreamResponse> {
|
||||
async function buildFixtureStream(
|
||||
task: TeamTask = RELAY_WORKS_10_TASK
|
||||
): Promise<BoardTaskLogStreamResponse> {
|
||||
const transcript = await loadFixtureTranscript();
|
||||
const source = new OpenCodeTaskLogStreamSource(
|
||||
{
|
||||
|
|
@ -72,13 +91,13 @@ async function buildFixtureStream(): Promise<BoardTaskLogStreamResponse> {
|
|||
} as never,
|
||||
{ resolve: async () => '/tmp/agent_teams_orchestrator' },
|
||||
{
|
||||
getTasks: vi.fn(async () => [RELAY_WORKS_10_TASK]),
|
||||
getTasks: vi.fn(async () => [task]),
|
||||
getDeletedTasks: vi.fn(async () => []),
|
||||
} as never,
|
||||
new BoardTaskExactLogChunkBuilder(),
|
||||
{ readTaskRecords: vi.fn(async () => []) }
|
||||
);
|
||||
const stream = await source.getTaskLogStream(TEAM_NAME, TASK_ID);
|
||||
const stream = await source.getTaskLogStream(TEAM_NAME, task.id);
|
||||
if (!stream) {
|
||||
throw new Error('Expected OpenCode fixture stream');
|
||||
}
|
||||
|
|
@ -138,4 +157,44 @@ describe('TaskLogStreamSection OpenCode real fixture e2e', () => {
|
|||
await flushMicrotasks();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders real OpenCode native tool rows when the task stream includes them', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
apiState.onTeamChange.mockImplementation(() => () => undefined);
|
||||
apiState.getTaskLogStream.mockResolvedValueOnce(
|
||||
await buildFixtureStream(RELAY_WORKS_10_COORDINATION_TASK)
|
||||
);
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(
|
||||
TooltipProvider,
|
||||
null,
|
||||
React.createElement(TaskLogStreamSection, {
|
||||
teamName: TEAM_NAME,
|
||||
taskId: COORDINATION_TASK_ID,
|
||||
liveEnabled: false,
|
||||
})
|
||||
)
|
||||
);
|
||||
await flushMicrotasks();
|
||||
await flushMicrotasks();
|
||||
});
|
||||
|
||||
const text = host.textContent ?? '';
|
||||
expect(text).toContain('Task Log Stream');
|
||||
expect(text).toContain('read');
|
||||
expect(text).toContain('bash');
|
||||
expect(text).toContain('Разбил работу на мелкие задачи');
|
||||
expect(text).not.toContain('SendMessage');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await flushMicrotasks();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue