fix(team): harden runtime status and opencode bootstrap
This commit is contained in:
parent
7e55fdd9cd
commit
e3c62eb620
36 changed files with 3378 additions and 148 deletions
|
|
@ -247,6 +247,10 @@
|
|||
"from": "resources/runtime",
|
||||
"to": "runtime"
|
||||
},
|
||||
{
|
||||
"from": "src/renderer/assets/participant-avatars",
|
||||
"to": "participant-avatars"
|
||||
},
|
||||
{
|
||||
"from": "mcp-server/dist/index.js",
|
||||
"to": "mcp-server/index.js"
|
||||
|
|
|
|||
|
|
@ -498,6 +498,9 @@ async function notifyNewInboxMessages(teamName: string, detail: string): Promise
|
|||
summary,
|
||||
body: extracted.body,
|
||||
dedupeKey: `inbox:${teamName}:${memberName}:${msgId}`,
|
||||
target: isCrossTeam
|
||||
? { kind: 'team', teamName, section: 'messages' }
|
||||
: { kind: 'member', teamName, memberName: fromLabel, focus: 'messages' },
|
||||
suppressToast: effectiveSuppressToast,
|
||||
})
|
||||
.catch(() => undefined);
|
||||
|
|
@ -557,6 +560,7 @@ async function notifyNewSentMessages(teamName: string): Promise<void> {
|
|||
summary,
|
||||
body: extracted.body,
|
||||
dedupeKey: `sent:${teamName}:${msg.timestamp ?? String(prevCount + i)}`,
|
||||
target: { kind: 'member', teamName, memberName: fromLabel, focus: 'messages' },
|
||||
suppressToast,
|
||||
})
|
||||
.catch(() => undefined);
|
||||
|
|
|
|||
|
|
@ -415,6 +415,7 @@ function checkRateLimitMessages(
|
|||
summary: `Rate limit: ${msg.from}`,
|
||||
body: msg.text.slice(0, 200),
|
||||
dedupeKey,
|
||||
target: { kind: 'member', teamName, memberName: msg.from, focus: 'logs' },
|
||||
projectPath,
|
||||
})
|
||||
.catch(() => undefined);
|
||||
|
|
@ -489,6 +490,7 @@ function checkApiErrorMessages(
|
|||
summary: `API Error ${statusCode}: ${msg.from}`,
|
||||
body: msg.text.slice(0, 400),
|
||||
dedupeKey,
|
||||
target: { kind: 'member', teamName, memberName: msg.from, focus: 'logs' },
|
||||
projectPath,
|
||||
})
|
||||
.catch(() => undefined);
|
||||
|
|
@ -4444,6 +4446,7 @@ async function handleShowMessageNotification(
|
|||
summary: d.summary ?? `${d.from} → ${d.to ?? 'team'}`,
|
||||
body: d.body,
|
||||
dedupeKey,
|
||||
target: d.target,
|
||||
suppressToast: d.suppressToast,
|
||||
})
|
||||
.catch(() => undefined);
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import { randomUUID } from 'crypto';
|
|||
import { type ExtractedToolResult } from '../analysis/ToolResultExtractor';
|
||||
|
||||
import type { TriggerColor } from '@shared/constants/triggerColors';
|
||||
import type { TeamEventType } from '@shared/types/notifications';
|
||||
import type { NotificationTarget, TeamEventType } from '@shared/types/notifications';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
|
|
@ -54,6 +54,8 @@ export interface DetectedError {
|
|||
category?: 'error' | 'team';
|
||||
/** For team notifications: specific event sub-type */
|
||||
teamEventType?: TeamEventType;
|
||||
/** Structured destination for notification clicks. */
|
||||
target?: NotificationTarget;
|
||||
/** Explicit key for storage deduplication. Two notifications with the same dedupeKey won't be stored twice. */
|
||||
dedupeKey?: string;
|
||||
/** Additional context about the error */
|
||||
|
|
|
|||
|
|
@ -16,15 +16,19 @@
|
|||
*/
|
||||
|
||||
import { getAppIconPath } from '@main/utils/appIcon';
|
||||
import { getHomeDir } from '@main/utils/pathDecoder';
|
||||
import { getAppDataPath, getHomeDir, getTeamsBasePath } from '@main/utils/pathDecoder';
|
||||
import { safeSendToRenderer } from '@main/utils/safeWebContentsSend';
|
||||
import { stripMarkdown } from '@main/utils/textFormatting';
|
||||
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
||||
import { getMemberColorByName, MEMBER_COLOR_HUE } from '@shared/constants/memberColors';
|
||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { Notification as ElectronNotification } from 'electron';
|
||||
import { Notification as ElectronNotification, nativeImage } from 'electron';
|
||||
import { EventEmitter } from 'events';
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
||||
import * as fsp from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import { pathToFileURL } from 'url';
|
||||
|
||||
import { type DetectedError } from '../error/ErrorMessageBuilder';
|
||||
|
||||
|
|
@ -101,6 +105,16 @@ const LEGACY_NOTIFICATION_FILENAMES = [
|
|||
const LEGACY_NOTIFICATION_PATHS = LEGACY_NOTIFICATION_FILENAMES.map((filename) =>
|
||||
path.join(getHomeDir(), '.claude', filename)
|
||||
);
|
||||
const SENDER_ICON_CACHE = new Map<string, NotificationConstructorOptions['icon'] | undefined>();
|
||||
const WINDOWS_TOAST_AVATAR_CACHE = new Map<string, string | undefined>();
|
||||
const PARTICIPANT_AVATAR_COUNT = 13;
|
||||
const LEAD_PARTICIPANT_AVATAR_NUMBER = 1;
|
||||
|
||||
interface TeamNotificationAvatarMember {
|
||||
name: string;
|
||||
removedAt?: number | string | null;
|
||||
agentType?: string;
|
||||
}
|
||||
|
||||
interface LegacyNotificationData {
|
||||
path: string;
|
||||
|
|
@ -123,6 +137,385 @@ function getNotificationClass(): NotificationClass | null {
|
|||
return (ElectronNotification as NotificationClass | undefined) ?? null;
|
||||
}
|
||||
|
||||
function getNativeImage(): typeof nativeImage | null {
|
||||
return nativeImage && typeof nativeImage.createFromPath === 'function' ? nativeImage : null;
|
||||
}
|
||||
|
||||
function hashStringToIndex(str: string): number {
|
||||
let hash = 5381;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = ((hash << 5) + hash + str.charCodeAt(i)) | 0;
|
||||
}
|
||||
return Math.abs(hash);
|
||||
}
|
||||
|
||||
function getParticipantAvatarNumberByIndex(index: number): number {
|
||||
const normalized =
|
||||
((Math.trunc(index) % PARTICIPANT_AVATAR_COUNT) + PARTICIPANT_AVATAR_COUNT) %
|
||||
PARTICIPANT_AVATAR_COUNT;
|
||||
return normalized + 1;
|
||||
}
|
||||
|
||||
function getFallbackParticipantAvatarNumber(name: string): number {
|
||||
const normalized = name.trim().toLowerCase();
|
||||
if (normalized === 'team-lead' || normalized === 'lead') {
|
||||
return LEAD_PARTICIPANT_AVATAR_NUMBER;
|
||||
}
|
||||
return getParticipantAvatarNumberByIndex(hashStringToIndex(normalized));
|
||||
}
|
||||
|
||||
function getParticipantAvatarNumber(
|
||||
sender: string,
|
||||
members: readonly TeamNotificationAvatarMember[]
|
||||
): number {
|
||||
const senderName = sender.trim();
|
||||
if (!senderName) return getFallbackParticipantAvatarNumber(sender);
|
||||
|
||||
const map = new Map<string, number>();
|
||||
const activeMembers = members.filter((member) => !member.removedAt);
|
||||
const leadMembers = activeMembers.filter((member) => isLeadMember(member));
|
||||
const teammateMembers = activeMembers.filter((member) => !isLeadMember(member));
|
||||
|
||||
for (const [index, member] of leadMembers.entries()) {
|
||||
map.set(
|
||||
member.name,
|
||||
index === 0 ? LEAD_PARTICIPANT_AVATAR_NUMBER : getFallbackParticipantAvatarNumber(member.name)
|
||||
);
|
||||
}
|
||||
|
||||
for (const [index, member] of teammateMembers.entries()) {
|
||||
map.set(member.name, 2 + (index % (PARTICIPANT_AVATAR_COUNT - 1)));
|
||||
}
|
||||
|
||||
for (const member of members) {
|
||||
if (!map.has(member.name)) {
|
||||
map.set(
|
||||
member.name,
|
||||
isLeadMember(member)
|
||||
? LEAD_PARTICIPANT_AVATAR_NUMBER
|
||||
: getFallbackParticipantAvatarNumber(member.name)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
map.set('user', getFallbackParticipantAvatarNumber('user'));
|
||||
map.set('system', getFallbackParticipantAvatarNumber('system'));
|
||||
|
||||
return map.get(senderName) ?? getFallbackParticipantAvatarNumber(senderName);
|
||||
}
|
||||
|
||||
function readTeamNotificationMembers(teamName: string): TeamNotificationAvatarMember[] {
|
||||
try {
|
||||
const configPath = path.join(getTeamsBasePath(), teamName, 'config.json');
|
||||
if (!existsSync(configPath)) return [];
|
||||
|
||||
const parsed = JSON.parse(readFileSync(configPath, 'utf8')) as {
|
||||
members?: unknown;
|
||||
};
|
||||
if (!Array.isArray(parsed.members)) return [];
|
||||
|
||||
return parsed.members
|
||||
.map((member): TeamNotificationAvatarMember | null => {
|
||||
if (!member || typeof member !== 'object') return null;
|
||||
const record = member as Record<string, unknown>;
|
||||
const name = typeof record.name === 'string' ? record.name.trim() : '';
|
||||
if (!name) return null;
|
||||
return {
|
||||
name,
|
||||
removedAt:
|
||||
typeof record.removedAt === 'number' || typeof record.removedAt === 'string'
|
||||
? record.removedAt
|
||||
: null,
|
||||
agentType: typeof record.agentType === 'string' ? record.agentType : undefined,
|
||||
};
|
||||
})
|
||||
.filter((member): member is TeamNotificationAvatarMember => Boolean(member));
|
||||
} catch (error) {
|
||||
logger.debug(`[team-toast] failed to read team members for avatar: ${String(error)}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function resolveParticipantAvatarPath(avatarNumber: number): string | undefined {
|
||||
const filename = `${String(avatarNumber).padStart(2, '0')}.png`;
|
||||
const resourceRoot =
|
||||
typeof process.resourcesPath === 'string' && process.resourcesPath.length > 0
|
||||
? process.resourcesPath
|
||||
: null;
|
||||
const candidates = [
|
||||
path.join(process.cwd(), 'src/renderer/assets/participant-avatars', filename),
|
||||
...(resourceRoot ? [path.join(resourceRoot, 'participant-avatars', filename)] : []),
|
||||
];
|
||||
|
||||
return candidates.find((candidate) => existsSync(candidate));
|
||||
}
|
||||
|
||||
function escapeXmlAttribute(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function escapeXmlText(value: string): string {
|
||||
return value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function formatSenderLabel(sender: string): string | null {
|
||||
const trimmed = sender.trim();
|
||||
if (!trimmed) return null;
|
||||
if (trimmed.toLowerCase() === 'system') return 'System';
|
||||
return trimmed.startsWith('@') ? trimmed : `@${trimmed}`;
|
||||
}
|
||||
|
||||
function cleanNotificationText(value: string): string {
|
||||
return stripMarkdown(stripAgentBlocks(value)).replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function truncateNotificationText(value: string, maxLength: number): string {
|
||||
if (value.length <= maxLength) return value;
|
||||
return `${value.slice(0, Math.max(0, maxLength - 3)).trimEnd()}...`;
|
||||
}
|
||||
|
||||
function extractTaskRef(summary: string): string | null {
|
||||
const match = summary.match(/#([A-Za-z0-9][A-Za-z0-9-]*)/);
|
||||
return match ? `#${match[1]}` : null;
|
||||
}
|
||||
|
||||
function extractTaskSubject(summary: string): string {
|
||||
return summary
|
||||
.replace(/^Comment on\s+#[^:]+:\s*/i, '')
|
||||
.replace(/^Comment on\s+#[^\s]+/i, '')
|
||||
.replace(/^Clarification needed\s+-\s+Task\s+#[^:]+:\s*/i, '')
|
||||
.replace(/^Clarification needed\s+-\s+Task\s+#[^\s]+/i, '')
|
||||
.replace(/^New task\s+#[^:]+:\s*/i, '')
|
||||
.replace(/^New task\s+#[^\s]+/i, '')
|
||||
.replace(/^Task\s+#[^:]+:\s*/i, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function getTeamNotificationAction(
|
||||
payload: TeamNotificationPayload,
|
||||
taskRef: string | null
|
||||
): string {
|
||||
switch (payload.teamEventType) {
|
||||
case 'task_comment':
|
||||
return taskRef ? `commented on ${taskRef}` : 'commented on a task';
|
||||
case 'task_clarification':
|
||||
return taskRef ? `needs clarification on ${taskRef}` : 'needs clarification';
|
||||
case 'task_status_change':
|
||||
return taskRef ? `changed ${taskRef}` : 'changed task status';
|
||||
case 'task_created':
|
||||
return taskRef ? `created ${taskRef}` : 'created a task';
|
||||
case 'all_tasks_completed':
|
||||
return 'completed all tasks';
|
||||
case 'lead_inbox':
|
||||
case 'user_inbox':
|
||||
return 'sent a message';
|
||||
case 'cross_team_message':
|
||||
return 'sent a cross-team message';
|
||||
case 'rate_limit':
|
||||
return /api error/i.test(`${payload.summary} ${payload.body}`)
|
||||
? 'hit an API error'
|
||||
: 'hit rate limit';
|
||||
case 'schedule_completed':
|
||||
return 'completed a schedule';
|
||||
case 'schedule_failed':
|
||||
return 'schedule failed';
|
||||
case 'team_launched':
|
||||
return 'launched a team';
|
||||
default:
|
||||
return 'sent an update';
|
||||
}
|
||||
}
|
||||
|
||||
function getTeamNotificationWhere(
|
||||
payload: TeamNotificationPayload,
|
||||
taskRef: string | null
|
||||
): string {
|
||||
const team = cleanNotificationText(payload.teamDisplayName) || payload.teamDisplayName;
|
||||
const summary = cleanNotificationText(payload.summary);
|
||||
|
||||
if (payload.teamEventType.startsWith('task_')) {
|
||||
const subject = extractTaskSubject(summary);
|
||||
const taskContext = subject || taskRef;
|
||||
return taskContext ? `${taskContext} - ${team}` : team;
|
||||
}
|
||||
|
||||
return team;
|
||||
}
|
||||
|
||||
function buildTeamNotificationPresentation(
|
||||
payload: TeamNotificationPayload,
|
||||
body: string
|
||||
): { title: string; where: string; body: string } {
|
||||
const who = formatSenderLabel(payload.from) ?? cleanNotificationText(payload.teamDisplayName);
|
||||
const summary = cleanNotificationText(payload.summary);
|
||||
const taskRef = extractTaskRef(summary);
|
||||
const action = getTeamNotificationAction(payload, taskRef);
|
||||
const where = getTeamNotificationWhere(payload, taskRef);
|
||||
const normalizedBody = cleanNotificationText(body);
|
||||
|
||||
return {
|
||||
title: truncateNotificationText(`${who} ${action}`.trim(), 96),
|
||||
where: truncateNotificationText(where, 120),
|
||||
body: truncateNotificationText(normalizedBody || summary, 300),
|
||||
};
|
||||
}
|
||||
|
||||
function getSenderInitials(sender: string): string {
|
||||
const trimmed = sender.trim().replace(/^@+/, '');
|
||||
if (!trimmed) return '?';
|
||||
|
||||
const parts = trimmed.split(/[\s._:-]+/).filter(Boolean);
|
||||
const initials =
|
||||
parts.length >= 2
|
||||
? `${parts[0]?.[0] ?? ''}${parts[1]?.[0] ?? ''}`
|
||||
: trimmed.replace(/[\s._:-]+/g, '').slice(0, 2);
|
||||
|
||||
return initials.toLocaleUpperCase() || '?';
|
||||
}
|
||||
|
||||
function resolveSenderParticipantAvatarPath(
|
||||
sender: string,
|
||||
teamName: string,
|
||||
members: readonly TeamNotificationAvatarMember[] | undefined
|
||||
): string | undefined {
|
||||
const senderLabel = sender.trim();
|
||||
if (!senderLabel || senderLabel.toLowerCase() === 'system') return undefined;
|
||||
|
||||
const roster = members && members.length > 0 ? members : readTeamNotificationMembers(teamName);
|
||||
const avatarNumber = getParticipantAvatarNumber(senderLabel, roster);
|
||||
return resolveParticipantAvatarPath(avatarNumber);
|
||||
}
|
||||
|
||||
function getWindowsToastAvatarPath(avatarPath: string): string {
|
||||
const cached = WINDOWS_TOAST_AVATAR_CACHE.get(avatarPath);
|
||||
if (cached) return cached;
|
||||
|
||||
const NativeImage = getNativeImage();
|
||||
if (!NativeImage) {
|
||||
WINDOWS_TOAST_AVATAR_CACHE.set(avatarPath, avatarPath);
|
||||
return avatarPath;
|
||||
}
|
||||
|
||||
try {
|
||||
const source = NativeImage.createFromPath(avatarPath);
|
||||
if (source.isEmpty()) {
|
||||
WINDOWS_TOAST_AVATAR_CACHE.set(avatarPath, avatarPath);
|
||||
return avatarPath;
|
||||
}
|
||||
|
||||
const resized = source.resize({ width: 96, height: 96 });
|
||||
if (resized.isEmpty()) {
|
||||
WINDOWS_TOAST_AVATAR_CACHE.set(avatarPath, avatarPath);
|
||||
return avatarPath;
|
||||
}
|
||||
|
||||
const cacheDir = path.join(getAppDataPath(), 'notification-avatars');
|
||||
mkdirSync(cacheDir, { recursive: true });
|
||||
|
||||
const parsed = path.parse(avatarPath);
|
||||
const outPath = path.join(cacheDir, `${parsed.name}-96.png`);
|
||||
writeFileSync(outPath, resized.toPNG());
|
||||
WINDOWS_TOAST_AVATAR_CACHE.set(avatarPath, outPath);
|
||||
return outPath;
|
||||
} catch (error) {
|
||||
logger.debug(`[team-toast] failed to prepare Windows toast avatar: ${String(error)}`);
|
||||
WINDOWS_TOAST_AVATAR_CACHE.set(avatarPath, avatarPath);
|
||||
return avatarPath;
|
||||
}
|
||||
}
|
||||
|
||||
function buildSenderNotificationIcon(
|
||||
sender: string,
|
||||
teamName: string,
|
||||
members: readonly TeamNotificationAvatarMember[] | undefined
|
||||
): NotificationConstructorOptions['icon'] {
|
||||
const senderLabel = sender.trim();
|
||||
if (!senderLabel || senderLabel.toLowerCase() === 'system') return getAppIconPath();
|
||||
|
||||
const senderAvatarPath = resolveSenderParticipantAvatarPath(senderLabel, teamName, members);
|
||||
const cacheKey = `${teamName}:${senderLabel}:${senderAvatarPath ?? 'generated'}`.toLowerCase();
|
||||
if (SENDER_ICON_CACHE.has(cacheKey)) {
|
||||
return SENDER_ICON_CACHE.get(cacheKey);
|
||||
}
|
||||
|
||||
try {
|
||||
if (senderAvatarPath) {
|
||||
const NativeImage = getNativeImage();
|
||||
if (NativeImage) {
|
||||
const avatarIcon = NativeImage.createFromPath(senderAvatarPath);
|
||||
if (!avatarIcon.isEmpty()) {
|
||||
SENDER_ICON_CACHE.set(cacheKey, avatarIcon);
|
||||
return avatarIcon;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const colorName = getMemberColorByName(senderLabel);
|
||||
const hue = MEMBER_COLOR_HUE[colorName] ?? 210;
|
||||
const initials = escapeXmlAttribute(getSenderInitials(senderLabel));
|
||||
const svg = [
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256">',
|
||||
`<rect width="256" height="256" rx="72" fill="hsl(${hue}, 68%, 38%)"/>`,
|
||||
`<circle cx="128" cy="128" r="102" fill="hsl(${hue}, 74%, 46%)"/>`,
|
||||
`<circle cx="91" cy="86" r="20" fill="hsl(${hue}, 84%, 72%)" opacity="0.9"/>`,
|
||||
`<path d="M54 178c23-31 48-46 74-46s51 15 74 46" fill="none" stroke="hsl(${hue}, 88%, 78%)" stroke-width="18" stroke-linecap="round" opacity="0.5"/>`,
|
||||
`<text x="128" y="148" text-anchor="middle" font-family="Arial, Helvetica, sans-serif" font-size="78" font-weight="700" fill="#fff">${initials}</text>`,
|
||||
'</svg>',
|
||||
].join('');
|
||||
const NativeImage = getNativeImage();
|
||||
const icon = NativeImage?.createFromDataURL(
|
||||
`data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`
|
||||
);
|
||||
const resolvedIcon = icon && !icon.isEmpty() ? icon : getAppIconPath();
|
||||
SENDER_ICON_CACHE.set(cacheKey, resolvedIcon);
|
||||
return resolvedIcon;
|
||||
} catch (error) {
|
||||
logger.debug(`[team-toast] sender icon fallback for "${senderLabel}": ${String(error)}`);
|
||||
const fallbackIcon = getAppIconPath();
|
||||
SENDER_ICON_CACHE.set(cacheKey, fallbackIcon);
|
||||
return fallbackIcon;
|
||||
}
|
||||
}
|
||||
|
||||
function buildWindowsTeamToastXml(input: {
|
||||
title: string;
|
||||
summary?: string;
|
||||
body: string;
|
||||
sender: string;
|
||||
avatarPath?: string;
|
||||
silent: boolean;
|
||||
}): string {
|
||||
const textRows = [
|
||||
`<text>${escapeXmlText(input.title)}</text>`,
|
||||
input.summary ? `<text>${escapeXmlText(input.summary)}</text>` : null,
|
||||
input.body ? `<text>${escapeXmlText(input.body)}</text>` : null,
|
||||
].filter(Boolean);
|
||||
|
||||
const avatarRow = input.avatarPath
|
||||
? `<image placement="appLogoOverride" hint-crop="circle" src="${escapeXmlAttribute(
|
||||
pathToFileURL(input.avatarPath).href
|
||||
)}" alt="${escapeXmlAttribute(`${input.sender} avatar`)}"/>`
|
||||
: null;
|
||||
|
||||
return [
|
||||
'<toast>',
|
||||
'<visual>',
|
||||
'<binding template="ToastGeneric">',
|
||||
...textRows,
|
||||
avatarRow,
|
||||
'</binding>',
|
||||
'</visual>',
|
||||
input.silent ? '<audio silent="true"/>' : null,
|
||||
'</toast>',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('');
|
||||
}
|
||||
|
||||
async function migrateLegacyNotificationPath(): Promise<string> {
|
||||
try {
|
||||
await fsp.readFile(NOTIFICATIONS_PATH, 'utf8');
|
||||
|
|
@ -603,7 +996,7 @@ export class NotificationManager extends EventEmitter {
|
|||
|
||||
/**
|
||||
* Shows a native notification for a team event.
|
||||
* Uses team-specific formatting (title = team name, subtitle = summary).
|
||||
* Uses a consistent who + what + where presentation for all team events.
|
||||
*/
|
||||
private showTeamNativeNotification(
|
||||
stored: StoredNotification,
|
||||
|
|
@ -618,20 +1011,45 @@ export class NotificationManager extends EventEmitter {
|
|||
try {
|
||||
const config = this.configManager.getConfig();
|
||||
const isMac = process.platform === 'darwin';
|
||||
const truncatedBody = stripMarkdown(stripAgentBlocks(payload.body)).slice(0, 300);
|
||||
const iconPath = isMac ? undefined : getAppIconPath();
|
||||
const presentation = buildTeamNotificationPresentation(payload, payload.body);
|
||||
const senderAvatarPath = resolveSenderParticipantAvatarPath(
|
||||
payload.from,
|
||||
payload.teamName,
|
||||
payload.members
|
||||
);
|
||||
const toastXml =
|
||||
process.platform === 'win32' && senderAvatarPath
|
||||
? buildWindowsTeamToastXml({
|
||||
title: presentation.title,
|
||||
summary: presentation.where,
|
||||
body: presentation.body,
|
||||
sender: payload.from,
|
||||
avatarPath: getWindowsToastAvatarPath(senderAvatarPath),
|
||||
silent: !config.notifications.soundEnabled,
|
||||
})
|
||||
: undefined;
|
||||
const senderIcon = toastXml
|
||||
? undefined
|
||||
: buildSenderNotificationIcon(payload.from, payload.teamName, payload.members);
|
||||
|
||||
logger.debug(
|
||||
`[team-toast] creating: title="${payload.teamDisplayName}" summary="${payload.summary ?? ''}" bodyLen=${truncatedBody.length}`
|
||||
`[team-toast] creating: title="${presentation.title}" where="${presentation.where}" bodyLen=${presentation.body.length}`
|
||||
);
|
||||
|
||||
const notification = new NotificationClass({
|
||||
title: payload.teamDisplayName,
|
||||
...(isMac ? { subtitle: payload.summary } : {}),
|
||||
body: !isMac && payload.summary ? `${payload.summary}\n${truncatedBody}` : truncatedBody,
|
||||
sound: config.notifications.soundEnabled ? 'default' : undefined,
|
||||
...(iconPath ? { icon: iconPath } : {}),
|
||||
});
|
||||
const notificationOptions: NotificationConstructorOptions = toastXml
|
||||
? { toastXml }
|
||||
: {
|
||||
title: presentation.title,
|
||||
...(isMac ? { subtitle: presentation.where } : {}),
|
||||
body:
|
||||
!isMac && presentation.where
|
||||
? `${presentation.where}\n${presentation.body}`
|
||||
: presentation.body,
|
||||
sound: config.notifications.soundEnabled ? 'default' : undefined,
|
||||
...(senderIcon ? { icon: senderIcon } : {}),
|
||||
};
|
||||
|
||||
const notification = new NotificationClass(notificationOptions);
|
||||
|
||||
// Hold a strong reference to prevent GC from collecting the notification
|
||||
this.activeNotifications.add(notification);
|
||||
|
|
@ -647,7 +1065,7 @@ export class NotificationManager extends EventEmitter {
|
|||
|
||||
notification.on('show', () => {
|
||||
logger.debug(
|
||||
`[team-toast] OS confirmed show: "${payload.teamDisplayName}" — ${payload.summary ?? ''}`
|
||||
`[team-toast] OS confirmed show: "${presentation.title}" - ${presentation.where}`
|
||||
);
|
||||
});
|
||||
notification.on('failed', (_, error) => {
|
||||
|
|
|
|||
|
|
@ -76,7 +76,10 @@ function isOpaqueSafeTaskIdSegment(segment: string): boolean {
|
|||
export function shouldIgnoreLogSourceWatcherPath(
|
||||
projectDir: string,
|
||||
watchedPath: string,
|
||||
_scope?: { scopedSessionIds?: ReadonlySet<string> }
|
||||
scope?: {
|
||||
scopedSessionIds?: ReadonlySet<string>;
|
||||
pendingRootSessionIds?: ReadonlySet<string>;
|
||||
}
|
||||
): boolean {
|
||||
const parts = getRelativeLogSourceParts(projectDir, watchedPath);
|
||||
if (!parts) {
|
||||
|
|
@ -90,6 +93,31 @@ export function shouldIgnoreLogSourceWatcherPath(
|
|||
if (first === BOARD_TASK_LOG_FRESHNESS_DIRNAME) return false;
|
||||
if (first === BOARD_TASK_CHANGE_FRESHNESS_DIRNAME) return false;
|
||||
|
||||
const scopedSessionIds = scope?.scopedSessionIds;
|
||||
if (scopedSessionIds) {
|
||||
if (parts.length === 1) {
|
||||
if (first.endsWith('.jsonl')) {
|
||||
const sessionId = normalizeLogSourceSessionId(first.slice(0, -'.jsonl'.length));
|
||||
return (
|
||||
!sessionId ||
|
||||
(!scopedSessionIds.has(sessionId) && !scope?.pendingRootSessionIds?.has(sessionId))
|
||||
);
|
||||
}
|
||||
return !scopedSessionIds.has(first);
|
||||
}
|
||||
|
||||
if (!scopedSessionIds.has(first)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (parts[1] === 'subagents') {
|
||||
if (parts.length === 2) return false;
|
||||
if (parts.length === 3) return !isAgentTranscriptFileName(parts[2]);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (parts.length >= 2 && parts[1] === 'subagents') {
|
||||
if (parts.length === 2) return false;
|
||||
if (parts.length === 3) return !isAgentTranscriptFileName(parts[2]);
|
||||
|
|
@ -360,7 +388,10 @@ export class TeamLogSourceTracker {
|
|||
followSymlinks: false,
|
||||
depth: 0,
|
||||
ignored: (watchedPath) =>
|
||||
shouldIgnoreLogSourceWatcherPath(context.projectDir, watchedPath, { scopedSessionIds }),
|
||||
shouldIgnoreLogSourceWatcherPath(context.projectDir, watchedPath, {
|
||||
scopedSessionIds,
|
||||
pendingRootSessionIds: new Set(this.getPendingUnknownSessionIds(state)),
|
||||
}),
|
||||
awaitWriteFinish: {
|
||||
stabilityThreshold: 250,
|
||||
pollInterval: 50,
|
||||
|
|
|
|||
|
|
@ -1820,6 +1820,123 @@ function isDefinitiveOpenCodePreLaunchFailure(
|
|||
);
|
||||
}
|
||||
|
||||
const OPENCODE_BOOTSTRAP_PENDING_DIAGNOSTIC =
|
||||
'opencode_bootstrap_pending_after_materialized_session';
|
||||
|
||||
function isMaterializedOpenCodeSessionId(sessionId: unknown): boolean {
|
||||
if (typeof sessionId !== 'string') {
|
||||
return false;
|
||||
}
|
||||
const trimmed = sessionId.trim();
|
||||
return trimmed.length > 0 && !trimmed.startsWith('failed:');
|
||||
}
|
||||
|
||||
function hasMaterializedOpenCodeRuntimeForBootstrap(
|
||||
member: TeamRuntimeMemberLaunchEvidence | undefined
|
||||
): member is TeamRuntimeMemberLaunchEvidence {
|
||||
if (!member) {
|
||||
return false;
|
||||
}
|
||||
if (isMaterializedOpenCodeSessionId(member.sessionId)) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
hasOpenCodeRuntimeLivenessMarker(member) &&
|
||||
typeof member.runtimePid === 'number' &&
|
||||
Number.isFinite(member.runtimePid) &&
|
||||
member.runtimePid > 0
|
||||
);
|
||||
}
|
||||
|
||||
function hasRecoverableOpenCodeBootstrapDiagnostic(diagnostics: readonly string[]): boolean {
|
||||
const text = diagnostics.join('\n').toLowerCase();
|
||||
if (!text) {
|
||||
return false;
|
||||
}
|
||||
if (hasRealOpenCodeFailureDiagnostic(text)) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
text.includes('runtime_bootstrap_checkin') ||
|
||||
text.includes('member_briefing') ||
|
||||
text.includes('bootstrap mcp') ||
|
||||
text.includes('member_session_recorded') ||
|
||||
text.includes('not connected') ||
|
||||
text.includes('mcp not connected') ||
|
||||
text.includes('member_launch_reconcile_pending') ||
|
||||
text.includes('member_launch_preview_timeout')
|
||||
);
|
||||
}
|
||||
|
||||
function isRecoverableOpenCodeBootstrapPendingLaunchResult(
|
||||
result: TeamRuntimeLaunchResult,
|
||||
memberName: string
|
||||
): boolean {
|
||||
const member = result.members[memberName];
|
||||
if (!hasMaterializedOpenCodeRuntimeForBootstrap(member)) {
|
||||
return false;
|
||||
}
|
||||
if (member.bootstrapConfirmed || member.launchState === 'confirmed_alive') {
|
||||
return false;
|
||||
}
|
||||
if ((member.pendingPermissionRequestIds?.length ?? 0) > 0) {
|
||||
return false;
|
||||
}
|
||||
return hasRecoverableOpenCodeBootstrapDiagnostic(
|
||||
collectRuntimeLaunchFailureDiagnostics(result, memberName)
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeRecoverableOpenCodeBootstrapPendingLaunchResult(
|
||||
result: TeamRuntimeLaunchResult,
|
||||
memberName: string,
|
||||
diagnostics: readonly string[]
|
||||
): TeamRuntimeLaunchResult {
|
||||
const member = result.members[memberName];
|
||||
if (!member) {
|
||||
return result;
|
||||
}
|
||||
const memberDiagnostics = Array.from(
|
||||
new Set([
|
||||
...(member.diagnostics ?? []),
|
||||
OPENCODE_BOOTSTRAP_PENDING_DIAGNOSTIC,
|
||||
'OpenCode runtime session materialized; waiting for runtime_bootstrap_checkin.',
|
||||
...diagnostics,
|
||||
])
|
||||
);
|
||||
const normalizedMember: TeamRuntimeMemberLaunchEvidence = {
|
||||
...member,
|
||||
launchState: 'runtime_pending_bootstrap',
|
||||
agentToolAccepted: true,
|
||||
runtimeAlive: true,
|
||||
bootstrapConfirmed: false,
|
||||
hardFailure: false,
|
||||
hardFailureReason: undefined,
|
||||
pendingPermissionRequestIds: undefined,
|
||||
livenessKind:
|
||||
member.livenessKind === 'confirmed_bootstrap'
|
||||
? 'runtime_process'
|
||||
: (member.livenessKind ?? 'runtime_process'),
|
||||
runtimeDiagnostic:
|
||||
member.runtimeDiagnostic ??
|
||||
'OpenCode runtime process detected; waiting for bootstrap check-in.',
|
||||
runtimeDiagnosticSeverity: member.runtimeDiagnosticSeverity ?? 'info',
|
||||
diagnostics: memberDiagnostics,
|
||||
};
|
||||
const members = {
|
||||
...result.members,
|
||||
[memberName]: normalizedMember,
|
||||
};
|
||||
const teamLaunchState = summarizeRuntimeLaunchResultMembers(members);
|
||||
return {
|
||||
...result,
|
||||
launchPhase: teamLaunchState === 'clean_success' ? result.launchPhase : 'active',
|
||||
teamLaunchState,
|
||||
members,
|
||||
diagnostics: Array.from(new Set([...result.diagnostics, ...memberDiagnostics])),
|
||||
};
|
||||
}
|
||||
|
||||
const OPENCODE_UNCOMMITTED_BOOTSTRAP_DIAGNOSTIC =
|
||||
'OpenCode bridge reported bootstrap confirmation, but no lane runtime evidence was committed.';
|
||||
|
||||
|
|
@ -2064,7 +2181,7 @@ function promoteOpenCodeSecondaryMemberFromCommittedBootstrapEvidence(input: {
|
|||
'opencode_bootstrap_evidence_committed',
|
||||
]),
|
||||
];
|
||||
const runtimeAlive = input.current.runtimeAlive === true;
|
||||
const runtimeAlive = true;
|
||||
return {
|
||||
...input.previous,
|
||||
...input.current,
|
||||
|
|
@ -6574,6 +6691,7 @@ export class TeamProvisioningService {
|
|||
let liveSecondaryLaneRunId: string | null = null;
|
||||
let trackedSecondaryLanePresent = false;
|
||||
let trackedSecondaryLaneSnapshotKnown = false;
|
||||
let trackedSecondaryLaneBootstrapConfirmed: boolean | null = null;
|
||||
if (
|
||||
trackedRun &&
|
||||
laneIdentity.laneKind === 'secondary' &&
|
||||
|
|
@ -6588,6 +6706,15 @@ export class TeamProvisioningService {
|
|||
);
|
||||
trackedSecondaryLanePresent = liveLane != null;
|
||||
liveSecondaryLaneRunId = liveLane ? trackedRunId : null;
|
||||
const liveLaneMember = liveLane
|
||||
? (liveLane.result?.members?.[canonicalMemberName] ??
|
||||
liveLane.result?.members?.[liveLane.member.name])
|
||||
: null;
|
||||
if (liveLaneMember) {
|
||||
trackedSecondaryLaneBootstrapConfirmed =
|
||||
liveLaneMember.bootstrapConfirmed === true ||
|
||||
liveLaneMember.launchState === 'confirmed_alive';
|
||||
}
|
||||
if (!liveLane && trackedSecondaryLaneSnapshotKnown) {
|
||||
return { delivered: false, reason: 'opencode_runtime_not_active' };
|
||||
}
|
||||
|
|
@ -6681,6 +6808,26 @@ export class TeamProvisioningService {
|
|||
return { delivered: false, reason: 'opencode_runtime_not_active' };
|
||||
}
|
||||
|
||||
if (laneIdentity.laneKind === 'secondary' && laneIdentity.laneOwnerProviderId === 'opencode') {
|
||||
const bootstrapReady =
|
||||
trackedSecondaryLaneBootstrapConfirmed === true ||
|
||||
(await this.hasDeliverableOpenCodeRuntimeBootstrapSessionEvidence({
|
||||
teamName,
|
||||
runId: runtimeRunId,
|
||||
laneId: laneIdentity.laneId,
|
||||
memberName: canonicalMemberName,
|
||||
}));
|
||||
if (!bootstrapReady) {
|
||||
return {
|
||||
delivered: false,
|
||||
reason: 'opencode_runtime_not_active',
|
||||
diagnostics: [
|
||||
`OpenCode runtime bootstrap is not confirmed for ${canonicalMemberName}. Message was saved and will be retried after runtime check-in.`,
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.isOpenCodePromptDeliveryWatchdogEnabled()) {
|
||||
const result = await adapter.sendMessageToMember({
|
||||
...(runtimeRunId ? { runId: runtimeRunId } : {}),
|
||||
|
|
@ -8928,6 +9075,31 @@ export class TeamProvisioningService {
|
|||
);
|
||||
}
|
||||
|
||||
private async hasDeliverableOpenCodeRuntimeBootstrapSessionEvidence(input: {
|
||||
teamName: string;
|
||||
runId: string | null;
|
||||
laneId: string;
|
||||
memberName: string;
|
||||
}): Promise<boolean> {
|
||||
const evidence = await readCommittedOpenCodeBootstrapSessionEvidence({
|
||||
teamsBasePath: getTeamsBasePath(),
|
||||
teamName: input.teamName,
|
||||
laneId: input.laneId,
|
||||
}).catch(() => null);
|
||||
if (!evidence?.committed) {
|
||||
return false;
|
||||
}
|
||||
const activeRunId = evidence.activeRunId?.trim() || null;
|
||||
if (activeRunId !== input.runId) {
|
||||
return false;
|
||||
}
|
||||
return evidence.sessions.some(
|
||||
(session) =>
|
||||
session.runId === input.runId &&
|
||||
namesMatchCaseInsensitive(session.memberName, input.memberName)
|
||||
);
|
||||
}
|
||||
|
||||
private async readOpenCodeRuntimeSessionStore(
|
||||
filePath: string
|
||||
): Promise<Record<string, unknown>[]> {
|
||||
|
|
@ -19656,25 +19828,49 @@ export class TeamProvisioningService {
|
|||
},
|
||||
}
|
||||
: result;
|
||||
lane.result = resultWithTiming;
|
||||
lane.warnings = [...resultWithTiming.warnings];
|
||||
const baseFailureDiagnostics = appendDiagnosticOnce(
|
||||
[...requestedDiagnostics, ...migration.diagnostics],
|
||||
timingDiagnostic
|
||||
);
|
||||
const recoverableBootstrapPending = isRecoverableOpenCodeBootstrapPendingLaunchResult(
|
||||
resultWithTiming,
|
||||
lane.member.name
|
||||
);
|
||||
const normalizedResult = recoverableBootstrapPending
|
||||
? normalizeRecoverableOpenCodeBootstrapPendingLaunchResult(
|
||||
resultWithTiming,
|
||||
lane.member.name,
|
||||
baseFailureDiagnostics
|
||||
)
|
||||
: resultWithTiming;
|
||||
lane.result = normalizedResult;
|
||||
lane.warnings = [...normalizedResult.warnings];
|
||||
const launchDiagnostics = appendDiagnosticOnce(
|
||||
[...requestedDiagnostics, ...migration.diagnostics, ...resultWithTiming.diagnostics],
|
||||
[...requestedDiagnostics, ...migration.diagnostics, ...normalizedResult.diagnostics],
|
||||
timingDiagnostic
|
||||
);
|
||||
lane.diagnostics = launchDiagnostics;
|
||||
|
||||
if (
|
||||
isDefinitiveOpenCodePreLaunchFailure(resultWithTiming, lane.member.name) ||
|
||||
resultWithTiming.teamLaunchState === 'partial_failure'
|
||||
if (recoverableBootstrapPending) {
|
||||
await upsertOpenCodeRuntimeLaneIndexEntry({
|
||||
teamsBasePath: getTeamsBasePath(),
|
||||
teamName: run.teamName,
|
||||
laneId: lane.laneId,
|
||||
state: 'active',
|
||||
diagnostics: collectOpenCodeSecondaryLaneFailureDiagnostics(
|
||||
normalizedResult,
|
||||
lane.member.name,
|
||||
baseFailureDiagnostics
|
||||
),
|
||||
}).catch(() => undefined);
|
||||
} else if (
|
||||
isDefinitiveOpenCodePreLaunchFailure(normalizedResult, lane.member.name) ||
|
||||
normalizedResult.teamLaunchState === 'partial_failure'
|
||||
) {
|
||||
const diagnostics = collectOpenCodeSecondaryLaneFailureDiagnostics(
|
||||
resultWithTiming,
|
||||
normalizedResult,
|
||||
lane.member.name,
|
||||
appendDiagnosticOnce(
|
||||
[...requestedDiagnostics, ...migration.diagnostics],
|
||||
timingDiagnostic
|
||||
)
|
||||
baseFailureDiagnostics
|
||||
);
|
||||
await upsertOpenCodeRuntimeLaneIndexEntry({
|
||||
teamsBasePath: getTeamsBasePath(),
|
||||
|
|
@ -24078,6 +24274,7 @@ export class TeamProvisioningService {
|
|||
summary: run.isLaunch ? 'Team launched' : 'Team provisioned',
|
||||
body,
|
||||
dedupeKey: `team_launched:${run.teamName}:${run.runId}`,
|
||||
target: { kind: 'team', teamName: run.teamName, section: 'overview' },
|
||||
projectPath: run.request.cwd,
|
||||
suppressToast,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -267,17 +267,24 @@ export function resolveTeamMemberRuntimeLiveness(
|
|||
});
|
||||
}
|
||||
return result({
|
||||
alive: false,
|
||||
livenessKind: 'runtime_process_candidate',
|
||||
pidSource: 'opencode_bridge',
|
||||
pid: runtimePidRow.pid,
|
||||
alive: hasConfirmedBootstrap,
|
||||
livenessKind: hasConfirmedBootstrap ? 'confirmed_bootstrap' : 'runtime_process_candidate',
|
||||
pidSource: hasConfirmedBootstrap ? 'runtime_bootstrap' : 'opencode_bridge',
|
||||
pid: hasConfirmedBootstrap ? undefined : runtimePidRow.pid,
|
||||
runtimeSessionId,
|
||||
processCommand,
|
||||
runtimeDiagnostic: 'OpenCode runtime pid is alive, but process identity is unverified',
|
||||
runtimeDiagnosticSeverity: 'warning',
|
||||
processCommand: hasConfirmedBootstrap ? undefined : processCommand,
|
||||
runtimeLastSeenAt: hasConfirmedBootstrap
|
||||
? (tracked?.lastHeartbeatAt ?? tracked?.updatedAt)
|
||||
: undefined,
|
||||
runtimeDiagnostic: hasConfirmedBootstrap
|
||||
? 'bootstrap confirmed; runtime pid currently points to a different process'
|
||||
: 'OpenCode runtime pid is alive, but process identity is unverified',
|
||||
runtimeDiagnosticSeverity: hasConfirmedBootstrap ? 'info' : 'warning',
|
||||
diagnostics: [
|
||||
...diagnostics,
|
||||
'matched OpenCode runtime pid without OpenCode process identity',
|
||||
hasConfirmedBootstrap
|
||||
? 'bootstrap confirmed despite runtime pid identity mismatch'
|
||||
: 'matched OpenCode runtime pid without OpenCode process identity',
|
||||
],
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { randomUUID } from 'crypto';
|
|||
|
||||
import type { DetectedError } from '../services/error/ErrorMessageBuilder';
|
||||
import type { TriggerColor } from '@shared/constants/triggerColors';
|
||||
import type { TeamEventType } from '@shared/types/notifications';
|
||||
import type { NotificationTarget, TeamEventType } from '@shared/types/notifications';
|
||||
|
||||
// Re-export for callers that import TeamEventType from this module
|
||||
export type { TeamEventType } from '@shared/types/notifications';
|
||||
|
|
@ -29,10 +29,18 @@ export interface TeamNotificationPayload {
|
|||
teamDisplayName: string;
|
||||
from: string;
|
||||
to?: string;
|
||||
/** Optional team roster for resolving the same participant avatar shown in the UI. */
|
||||
members?: readonly {
|
||||
name: string;
|
||||
removedAt?: number | string | null;
|
||||
agentType?: string;
|
||||
}[];
|
||||
summary: string;
|
||||
body: string;
|
||||
/** Stable key for storage deduplication. REQUIRED — no fallback to Date.now(). */
|
||||
dedupeKey: string;
|
||||
/** Structured destination used by notification click handling. */
|
||||
target?: NotificationTarget;
|
||||
projectPath?: string;
|
||||
/**
|
||||
* When true, the notification is stored in-app but no native OS toast is shown.
|
||||
|
|
@ -76,6 +84,9 @@ const TEAM_NOTIFICATION_CONFIG: Record<TeamEventType, TeamNotificationConfig> =
|
|||
*/
|
||||
export function buildDetectedErrorFromTeam(payload: TeamNotificationPayload): DetectedError {
|
||||
const config = TEAM_NOTIFICATION_CONFIG[payload.teamEventType];
|
||||
const summary = stripAgentBlocks(payload.summary).replace(/\s+/g, ' ').trim();
|
||||
const body = stripAgentBlocks(payload.body).replace(/\s+/g, ' ').trim();
|
||||
const preview = summary && body ? `${summary}: ${body}` : summary || body;
|
||||
|
||||
return {
|
||||
id: randomUUID(),
|
||||
|
|
@ -84,9 +95,10 @@ export function buildDetectedErrorFromTeam(payload: TeamNotificationPayload): De
|
|||
projectId: payload.teamName,
|
||||
filePath: '',
|
||||
source: payload.teamEventType,
|
||||
message: `[${payload.from}] ${stripAgentBlocks(payload.body).trim().slice(0, 300)}`,
|
||||
message: `[${payload.from}] ${preview.slice(0, 300)}`,
|
||||
category: 'team',
|
||||
teamEventType: payload.teamEventType,
|
||||
target: payload.target,
|
||||
dedupeKey: payload.dedupeKey,
|
||||
triggerColor: config.triggerColor,
|
||||
triggerName: config.triggerName,
|
||||
|
|
|
|||
77
src/renderer/components/team/LiveRuntimeStatusBridge.tsx
Normal file
77
src/renderer/components/team/LiveRuntimeStatusBridge.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { memo, useMemo } from 'react';
|
||||
|
||||
import { useStore } from '@renderer/store';
|
||||
import { Activity } from 'lucide-react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import { CollapsibleTeamSection } from './CollapsibleTeamSection';
|
||||
import { LiveRuntimeStatusSection } from './LiveRuntimeStatusSection';
|
||||
import {
|
||||
buildTeamRuntimeDisplayRows,
|
||||
type TeamRuntimeDisplayMember,
|
||||
} from './teamRuntimeDisplayRows';
|
||||
|
||||
export const TEAM_RUNTIME_UI_DECOUPLING_STORAGE_KEY = 'teamRuntimeUiDecouplingEnabled';
|
||||
|
||||
interface LiveRuntimeStatusBridgeProps {
|
||||
teamName: string;
|
||||
members: readonly TeamRuntimeDisplayMember[];
|
||||
}
|
||||
|
||||
export const LiveRuntimeStatusBridge = memo(function LiveRuntimeStatusBridge({
|
||||
teamName,
|
||||
members,
|
||||
}: LiveRuntimeStatusBridgeProps): React.JSX.Element | null {
|
||||
if (!isTeamRuntimeUiDecouplingEnabled()) return null;
|
||||
|
||||
return <LiveRuntimeStatusStoreBridge teamName={teamName} members={members} />;
|
||||
});
|
||||
|
||||
const LiveRuntimeStatusStoreBridge = memo(function LiveRuntimeStatusStoreBridge({
|
||||
teamName,
|
||||
members,
|
||||
}: LiveRuntimeStatusBridgeProps): React.JSX.Element | null {
|
||||
const { runtimeSnapshot, spawnStatuses } = useStore(
|
||||
useShallow((s) => ({
|
||||
runtimeSnapshot: s.teamAgentRuntimeByTeam[teamName],
|
||||
spawnStatuses: s.memberSpawnStatusesByTeam[teamName],
|
||||
}))
|
||||
);
|
||||
const rows = useMemo(
|
||||
() =>
|
||||
buildTeamRuntimeDisplayRows({
|
||||
members,
|
||||
runtimeSnapshot,
|
||||
spawnStatuses,
|
||||
}),
|
||||
[members, runtimeSnapshot, spawnStatuses]
|
||||
);
|
||||
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
const liveCount = rows.filter((row) => row.state === 'running').length;
|
||||
const attentionCount = rows.filter((row) => row.state === 'degraded').length;
|
||||
const badge = attentionCount > 0 ? attentionCount : liveCount > 0 ? liveCount : undefined;
|
||||
|
||||
return (
|
||||
<CollapsibleTeamSection
|
||||
sectionId="live-runtime-status"
|
||||
title="Live Runtime Status"
|
||||
icon={<Activity size={14} />}
|
||||
badge={badge}
|
||||
defaultOpen={false}
|
||||
>
|
||||
<LiveRuntimeStatusSection rows={rows} />
|
||||
</CollapsibleTeamSection>
|
||||
);
|
||||
});
|
||||
|
||||
export function isTeamRuntimeUiDecouplingEnabled(): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
|
||||
try {
|
||||
return window.localStorage.getItem(TEAM_RUNTIME_UI_DECOUPLING_STORAGE_KEY) === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
98
src/renderer/components/team/LiveRuntimeStatusSection.tsx
Normal file
98
src/renderer/components/team/LiveRuntimeStatusSection.tsx
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import { memo } from 'react';
|
||||
|
||||
import type { RuntimeDisplayState, TeamRuntimeDisplayRow } from './teamRuntimeDisplayRows';
|
||||
|
||||
interface LiveRuntimeStatusSectionProps {
|
||||
rows: readonly TeamRuntimeDisplayRow[];
|
||||
}
|
||||
|
||||
const STATE_LABELS: Record<RuntimeDisplayState, string> = {
|
||||
running: 'Running',
|
||||
starting: 'Starting',
|
||||
waiting: 'Waiting',
|
||||
degraded: 'Needs attention',
|
||||
stopped: 'Stopped',
|
||||
unknown: 'Unknown',
|
||||
};
|
||||
|
||||
const STATE_CLASS_NAMES: Record<RuntimeDisplayState, string> = {
|
||||
running: 'border-emerald-500/25 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300',
|
||||
starting: 'border-sky-500/25 bg-sky-500/10 text-sky-700 dark:text-sky-300',
|
||||
waiting: 'border-amber-500/25 bg-amber-500/10 text-amber-700 dark:text-amber-300',
|
||||
degraded: 'border-rose-500/25 bg-rose-500/10 text-rose-700 dark:text-rose-300',
|
||||
stopped: 'border-zinc-500/25 bg-zinc-500/10 text-zinc-700 dark:text-zinc-300',
|
||||
unknown: 'border-zinc-500/20 bg-zinc-500/5 text-zinc-600 dark:text-zinc-400',
|
||||
};
|
||||
|
||||
export const LiveRuntimeStatusSection = memo(function LiveRuntimeStatusSection({
|
||||
rows,
|
||||
}: LiveRuntimeStatusSectionProps): React.JSX.Element | null {
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-3" aria-label="Live runtime status">
|
||||
<span className="sr-only">Live runtime status</span>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Display-only heartbeat and launch state. Process controls remain below.
|
||||
</p>
|
||||
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{rows.map((row) => (
|
||||
<article
|
||||
key={row.memberName}
|
||||
className="border-border/70 bg-card/50 rounded-lg border p-3 shadow-sm"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-foreground truncate text-sm font-medium">{row.memberName}</div>
|
||||
<div className="text-muted-foreground mt-1 line-clamp-2 text-xs">
|
||||
{row.stateReason}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`shrink-0 rounded-full border px-2 py-0.5 text-[11px] font-medium ${STATE_CLASS_NAMES[row.state]}`}
|
||||
>
|
||||
{STATE_LABELS[row.state]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="text-muted-foreground mt-3 flex flex-wrap gap-1.5 text-[11px]">
|
||||
<span className="bg-muted rounded-full px-2 py-0.5">source: {row.source}</span>
|
||||
{row.runtimeModel ? (
|
||||
<span className="bg-muted rounded-full px-2 py-0.5">{row.runtimeModel}</span>
|
||||
) : null}
|
||||
{row.laneKind ? (
|
||||
<span className="bg-muted rounded-full px-2 py-0.5">{row.laneKind} lane</span>
|
||||
) : null}
|
||||
{row.pidLabel ? (
|
||||
<span className="bg-muted rounded-full px-2 py-0.5" title="Diagnostic only">
|
||||
{row.pidLabel}
|
||||
</span>
|
||||
) : null}
|
||||
{row.updatedAt ? (
|
||||
<span className="bg-muted rounded-full px-2 py-0.5">
|
||||
updated {formatRuntimeUpdatedAt(row.updatedAt)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
function formatRuntimeUpdatedAt(value: string): string {
|
||||
const timestamp = Date.parse(value);
|
||||
if (!Number.isFinite(timestamp)) return value;
|
||||
|
||||
const secondsAgo = Math.max(0, Math.round((Date.now() - timestamp) / 1000));
|
||||
if (secondsAgo < 60) return `${secondsAgo}s ago`;
|
||||
|
||||
const minutesAgo = Math.round(secondsAgo / 60);
|
||||
if (minutesAgo < 60) return `${minutesAgo}m ago`;
|
||||
|
||||
return new Date(timestamp).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
|
@ -133,6 +133,7 @@ import { getLaunchJoinMilestonesFromMembers, getLaunchJoinState } from './provis
|
|||
import { TeamProvisioningBanner } from './TeamProvisioningBanner';
|
||||
import { deriveLeadContextButtonLabel } from './leadContextLoadGuards';
|
||||
import { LeadSessionDetailGate } from './LeadSessionDetailGate';
|
||||
import { LiveRuntimeStatusBridge } from './LiveRuntimeStatusBridge';
|
||||
import { loadTeamSessionMetadata } from './teamSessionFetchGuards';
|
||||
import { TeamSessionsSection } from './TeamSessionsSection';
|
||||
|
||||
|
|
@ -1847,13 +1848,22 @@ export const TeamDetailView = memo(function TeamDetailView({
|
|||
const pendingMemberProfile = useStore((s) => s.pendingMemberProfile);
|
||||
useEffect(() => {
|
||||
if (!pendingMemberProfile || !data) return;
|
||||
const member = membersWithLiveBranches.find((m) => m.name === pendingMemberProfile);
|
||||
if (pendingMemberProfile.teamName && pendingMemberProfile.teamName !== teamName) return;
|
||||
|
||||
const member = membersWithLiveBranches.find((m) => m.name === pendingMemberProfile.memberName);
|
||||
if (member) {
|
||||
setSelectedMember(member);
|
||||
setSelectedMemberView(null);
|
||||
setSelectedMemberView({
|
||||
initialTab:
|
||||
pendingMemberProfile.focus === 'logs'
|
||||
? 'logs'
|
||||
: pendingMemberProfile.focus === 'messages'
|
||||
? 'activity'
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
useStore.getState().closeMemberProfile();
|
||||
}, [pendingMemberProfile, membersWithLiveBranches]);
|
||||
}, [pendingMemberProfile, membersWithLiveBranches, teamName, data]);
|
||||
|
||||
const handleDeleteTask = useCallback(
|
||||
(taskId: string) => {
|
||||
|
|
@ -2638,6 +2648,8 @@ export const TeamDetailView = memo(function TeamDetailView({
|
|||
<ScheduleSection teamName={teamName} />
|
||||
</CollapsibleTeamSection>
|
||||
|
||||
<LiveRuntimeStatusBridge teamName={teamName} members={membersWithLiveBranches} />
|
||||
|
||||
{(data.processes?.length ?? 0) > 0 && (
|
||||
<CollapsibleTeamSection
|
||||
sectionId="processes"
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ export const GlobalTaskDetailDialog = (): React.JSX.Element | null => {
|
|||
|
||||
const teamName = globalTaskDetail?.teamName ?? '';
|
||||
const taskId = globalTaskDetail?.taskId ?? '';
|
||||
const commentId = globalTaskDetail?.commentId;
|
||||
const hasTargetTeamData = hasSelectedTargetTeamData(
|
||||
teamName,
|
||||
selectedTeamName,
|
||||
|
|
@ -150,6 +151,7 @@ export const GlobalTaskDetailDialog = (): React.JSX.Element | null => {
|
|||
onClose={closeGlobalTaskDetail}
|
||||
onOwnerChange={undefined}
|
||||
onViewChanges={isFullTeamLoaded ? handleViewChanges : undefined}
|
||||
focusCommentId={commentId}
|
||||
headerExtra={
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -49,6 +49,10 @@ const INITIAL_VISIBLE_COMMENTS = 30;
|
|||
const VISIBLE_COMMENTS_STEP = 50;
|
||||
const MAX_COMMENTS_TO_RENDER = 2000;
|
||||
|
||||
function getTaskCommentElementId(taskId: string, commentId: string): string {
|
||||
return `task-comment-${taskId}-${commentId}`;
|
||||
}
|
||||
|
||||
interface TaskCommentsSectionProps {
|
||||
teamName: string;
|
||||
taskId: string;
|
||||
|
|
@ -66,6 +70,8 @@ interface TaskCommentsSectionProps {
|
|||
containerClassName?: string;
|
||||
/** Snapshot of unread comment IDs captured when the dialog opened. Blue dot is shown for these. */
|
||||
unreadCommentIds?: Set<string>;
|
||||
/** Comment to reveal when the dialog was opened from a notification. */
|
||||
focusCommentId?: string;
|
||||
/**
|
||||
* Ref callback factory from useViewportCommentRead.
|
||||
* When provided, each comment element is registered for viewport-based read tracking.
|
||||
|
|
@ -84,6 +90,7 @@ export const TaskCommentsSection = ({
|
|||
onTaskIdClick,
|
||||
containerClassName,
|
||||
unreadCommentIds,
|
||||
focusCommentId,
|
||||
registerCommentForViewport,
|
||||
}: TaskCommentsSectionProps): React.JSX.Element => {
|
||||
const addTaskComment = useStore((s) => s.addTaskComment);
|
||||
|
|
@ -130,9 +137,15 @@ export const TaskCommentsSection = ({
|
|||
return list;
|
||||
}, [cappedComments]);
|
||||
|
||||
const visibleCountForFocus = useMemo(() => {
|
||||
if (!focusCommentId) return visibleCount;
|
||||
const focusedIndex = sortedComments.findIndex((comment) => comment.id === focusCommentId);
|
||||
return focusedIndex >= 0 ? Math.max(visibleCount, focusedIndex + 1) : visibleCount;
|
||||
}, [focusCommentId, sortedComments, visibleCount]);
|
||||
|
||||
const visibleComments = useMemo(
|
||||
() => sortedComments.slice(0, Math.min(visibleCount, sortedComments.length)),
|
||||
[sortedComments, visibleCount]
|
||||
() => sortedComments.slice(0, Math.min(visibleCountForFocus, sortedComments.length)),
|
||||
[sortedComments, visibleCountForFocus]
|
||||
);
|
||||
|
||||
const visibleCommentIds = useMemo(
|
||||
|
|
@ -163,6 +176,22 @@ export const TaskCommentsSection = ({
|
|||
trimmed.length <= MAX_TEXT_LENGTH &&
|
||||
!addingComment;
|
||||
|
||||
useEffect(() => {
|
||||
if (!focusCommentId) return;
|
||||
const target = document.getElementById(getTaskCommentElementId(taskId, focusCommentId));
|
||||
if (!target) return;
|
||||
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
target.classList.remove('kanban-card-focus-pulse');
|
||||
void target.offsetWidth;
|
||||
target.classList.add('kanban-card-focus-pulse');
|
||||
target.addEventListener(
|
||||
'animationend',
|
||||
() => target.classList.remove('kanban-card-focus-pulse'),
|
||||
{ once: true }
|
||||
);
|
||||
}, [focusCommentId, taskId, visibleComments]);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!canSubmit) return;
|
||||
try {
|
||||
|
|
@ -215,6 +244,8 @@ export const TaskCommentsSection = ({
|
|||
{visibleComments.map((comment, index) => (
|
||||
<AnimatedHeightReveal key={comment.id} animate={newCommentIds.has(comment.id)}>
|
||||
<div
|
||||
id={getTaskCommentElementId(taskId, comment.id)}
|
||||
data-task-comment-id={comment.id}
|
||||
ref={
|
||||
registerCommentForViewport ? registerCommentForViewport(comment.id) : undefined
|
||||
}
|
||||
|
|
|
|||
|
|
@ -119,6 +119,7 @@ interface TaskDetailDialogProps {
|
|||
onViewChanges?: (taskId: string, filePath?: string) => void;
|
||||
onOpenInEditor?: (filePath: string) => void;
|
||||
onDeleteTask?: (taskId: string) => void;
|
||||
focusCommentId?: string;
|
||||
/** Extra content rendered in the dialog header (e.g. "Open team" button). */
|
||||
headerExtra?: React.ReactNode;
|
||||
}
|
||||
|
|
@ -138,6 +139,7 @@ export const TaskDetailDialog = ({
|
|||
onViewChanges,
|
||||
onOpenInEditor,
|
||||
onDeleteTask,
|
||||
focusCommentId,
|
||||
headerExtra,
|
||||
}: TaskDetailDialogProps): React.JSX.Element => {
|
||||
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
|
||||
|
|
@ -1392,6 +1394,7 @@ export const TaskDetailDialog = ({
|
|||
}
|
||||
containerClassName="-mx-6"
|
||||
unreadCommentIds={unreadSnapshotRef.current}
|
||||
focusCommentId={focusCommentId}
|
||||
registerCommentForViewport={registerComment}
|
||||
/>
|
||||
</CollapsibleTeamSection>
|
||||
|
|
|
|||
|
|
@ -420,6 +420,17 @@ export const MemberList = memo(function MemberList({
|
|||
);
|
||||
const removedMembers = useMemo(() => members.filter((m) => m.removedAt), [members]);
|
||||
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
|
||||
// Pre-compute reviewer->task map to avoid O(n*n) scan per member.
|
||||
const reviewTaskByMember = useMemo(() => {
|
||||
const result = new Map<string, TeamTaskWithKanban>();
|
||||
if (!taskMap) return result;
|
||||
for (const task of taskMap.values()) {
|
||||
if (task.reviewer && (task.reviewState === 'review' || task.kanbanColumn === 'review')) {
|
||||
result.set(task.reviewer, task);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, [taskMap]);
|
||||
|
||||
const buildRuntimeSummary = useCallback(
|
||||
(
|
||||
|
|
@ -440,18 +451,6 @@ export const MemberList = memo(function MemberList({
|
|||
);
|
||||
}
|
||||
|
||||
// Pre-compute reviewer→task map to avoid O(n×m) scan per member
|
||||
const reviewTaskByMember = useMemo(() => {
|
||||
const result = new Map<string, TeamTaskWithKanban>();
|
||||
if (!taskMap) return result;
|
||||
for (const task of taskMap.values()) {
|
||||
if (task.reviewer && (task.reviewState === 'review' || task.kanbanColumn === 'review')) {
|
||||
result.set(task.reviewer, task);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, [taskMap]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="flex flex-col gap-1">
|
||||
<div className={gridClass}>
|
||||
|
|
|
|||
317
src/renderer/components/team/teamRuntimeDisplayRows.ts
Normal file
317
src/renderer/components/team/teamRuntimeDisplayRows.ts
Normal file
|
|
@ -0,0 +1,317 @@
|
|||
import type {
|
||||
MemberSpawnStatusEntry,
|
||||
TeamAgentRuntimeDiagnosticSeverity,
|
||||
TeamAgentRuntimeEntry,
|
||||
TeamAgentRuntimeSnapshot,
|
||||
TeamProviderBackendId,
|
||||
TeamProviderId,
|
||||
} from '@shared/types';
|
||||
|
||||
export type RuntimeDisplayState =
|
||||
| 'running'
|
||||
| 'starting'
|
||||
| 'waiting'
|
||||
| 'degraded'
|
||||
| 'stopped'
|
||||
| 'unknown';
|
||||
|
||||
export interface TeamRuntimeDisplayMember {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface TeamRuntimeDisplayRow {
|
||||
memberName: string;
|
||||
state: RuntimeDisplayState;
|
||||
stateReason: string;
|
||||
source: 'runtime' | 'spawn-status' | 'mixed';
|
||||
updatedAt?: string;
|
||||
providerId?: TeamProviderId;
|
||||
providerBackendId?: TeamProviderBackendId;
|
||||
laneId?: string;
|
||||
laneKind?: 'primary' | 'secondary';
|
||||
runtimeModel?: string;
|
||||
diagnostic?: string;
|
||||
diagnosticSeverity?: TeamAgentRuntimeDiagnosticSeverity;
|
||||
pidLabel?: string;
|
||||
actionsAllowed: false;
|
||||
}
|
||||
|
||||
type SpawnDegradation = {
|
||||
reason: string;
|
||||
diagnostic?: string;
|
||||
diagnosticSeverity: TeamAgentRuntimeDiagnosticSeverity;
|
||||
};
|
||||
|
||||
const ACTIVE_SPAWN_STATUSES = new Set(['waiting', 'spawning']);
|
||||
|
||||
export function buildTeamRuntimeDisplayRows({
|
||||
members,
|
||||
runtimeSnapshot,
|
||||
spawnStatuses,
|
||||
}: {
|
||||
members: readonly TeamRuntimeDisplayMember[];
|
||||
runtimeSnapshot?: TeamAgentRuntimeSnapshot | null;
|
||||
spawnStatuses?: Record<string, MemberSpawnStatusEntry> | null;
|
||||
}): TeamRuntimeDisplayRow[] {
|
||||
const runtimeByMember = buildRuntimeEntriesByMember(runtimeSnapshot);
|
||||
|
||||
return members.map((member) => {
|
||||
const runtime = pickLatestRuntimeEntry(runtimeByMember.get(member.name) ?? []);
|
||||
const spawn = spawnStatuses?.[member.name];
|
||||
return buildRuntimeDisplayRow(member.name, runtime, spawn);
|
||||
});
|
||||
}
|
||||
|
||||
function buildRuntimeEntriesByMember(
|
||||
runtimeSnapshot?: TeamAgentRuntimeSnapshot | null
|
||||
): Map<string, TeamAgentRuntimeEntry[]> {
|
||||
const byMember = new Map<string, TeamAgentRuntimeEntry[]>();
|
||||
const runtimeMembers = runtimeSnapshot?.members;
|
||||
if (!runtimeMembers) return byMember;
|
||||
|
||||
for (const [key, entry] of Object.entries(runtimeMembers)) {
|
||||
const memberName = entry.memberName || key;
|
||||
if (!memberName) continue;
|
||||
const entries = byMember.get(memberName);
|
||||
if (entries) {
|
||||
entries.push(entry);
|
||||
} else {
|
||||
byMember.set(memberName, [entry]);
|
||||
}
|
||||
}
|
||||
|
||||
return byMember;
|
||||
}
|
||||
|
||||
function pickLatestRuntimeEntry(
|
||||
entries: readonly TeamAgentRuntimeEntry[]
|
||||
): TeamAgentRuntimeEntry | undefined {
|
||||
let latest: TeamAgentRuntimeEntry | undefined;
|
||||
let latestTimestamp = Number.NEGATIVE_INFINITY;
|
||||
|
||||
for (const entry of entries) {
|
||||
const timestamp = getRuntimeEntryTimestamp(entry);
|
||||
if (!latest || timestamp >= latestTimestamp) {
|
||||
latest = entry;
|
||||
latestTimestamp = timestamp;
|
||||
}
|
||||
}
|
||||
|
||||
return latest;
|
||||
}
|
||||
|
||||
function getRuntimeEntryTimestamp(entry: TeamAgentRuntimeEntry): number {
|
||||
const timestamp = Date.parse(entry.runtimeLastSeenAt ?? entry.updatedAt ?? '');
|
||||
return Number.isFinite(timestamp) ? timestamp : 0;
|
||||
}
|
||||
|
||||
function buildRuntimeDisplayRow(
|
||||
memberName: string,
|
||||
runtime?: TeamAgentRuntimeEntry,
|
||||
spawn?: MemberSpawnStatusEntry
|
||||
): TeamRuntimeDisplayRow {
|
||||
if (runtime) {
|
||||
return buildRuntimeBackedDisplayRow(memberName, runtime, spawn);
|
||||
}
|
||||
|
||||
if (spawn) {
|
||||
return buildSpawnBackedDisplayRow(memberName, spawn);
|
||||
}
|
||||
|
||||
return {
|
||||
memberName,
|
||||
state: 'unknown',
|
||||
stateReason: 'No live runtime snapshot yet',
|
||||
source: 'spawn-status',
|
||||
actionsAllowed: false,
|
||||
};
|
||||
}
|
||||
|
||||
function buildRuntimeBackedDisplayRow(
|
||||
memberName: string,
|
||||
runtime: TeamAgentRuntimeEntry,
|
||||
spawn?: MemberSpawnStatusEntry
|
||||
): TeamRuntimeDisplayRow {
|
||||
const hasErrorDiagnostic = runtime.runtimeDiagnosticSeverity === 'error';
|
||||
const spawnDegradation = getSpawnDegradation(spawn);
|
||||
const state = getRuntimeBackedState(runtime, hasErrorDiagnostic, spawnDegradation != null);
|
||||
const stateReason =
|
||||
spawnDegradation?.reason ??
|
||||
runtime.runtimeDiagnostic ??
|
||||
(runtime.alive === true ? 'Runtime heartbeat is alive' : 'Runtime heartbeat is not alive');
|
||||
|
||||
return {
|
||||
memberName,
|
||||
state,
|
||||
stateReason,
|
||||
source: spawn ? 'mixed' : 'runtime',
|
||||
updatedAt: runtime.runtimeLastSeenAt ?? runtime.updatedAt,
|
||||
providerId: runtime.providerId,
|
||||
providerBackendId: runtime.providerBackendId,
|
||||
laneId: runtime.laneId,
|
||||
laneKind: runtime.laneKind,
|
||||
runtimeModel: runtime.runtimeModel,
|
||||
diagnostic: spawnDegradation?.diagnostic ?? runtime.runtimeDiagnostic,
|
||||
diagnosticSeverity: spawnDegradation?.diagnosticSeverity ?? runtime.runtimeDiagnosticSeverity,
|
||||
pidLabel: formatRuntimePidLabel(runtime),
|
||||
actionsAllowed: false,
|
||||
};
|
||||
}
|
||||
|
||||
function getSpawnDegradation(spawn?: MemberSpawnStatusEntry): SpawnDegradation | null {
|
||||
if (!spawn) return null;
|
||||
|
||||
if (spawn.status === 'error' || spawn.hardFailure === true) {
|
||||
const reason =
|
||||
spawn.error ?? spawn.hardFailureReason ?? spawn.runtimeDiagnostic ?? 'Spawn failed';
|
||||
return {
|
||||
reason,
|
||||
diagnostic: spawn.runtimeDiagnostic ?? reason,
|
||||
diagnosticSeverity: spawn.runtimeDiagnosticSeverity ?? 'error',
|
||||
};
|
||||
}
|
||||
|
||||
if (spawn.bootstrapStalled === true) {
|
||||
const reason = spawn.runtimeDiagnostic ?? 'Runtime is alive, but bootstrap did not confirm';
|
||||
return {
|
||||
reason,
|
||||
diagnostic: spawn.runtimeDiagnostic ?? reason,
|
||||
diagnosticSeverity: spawn.runtimeDiagnosticSeverity ?? 'warning',
|
||||
};
|
||||
}
|
||||
|
||||
if ((spawn.pendingPermissionRequestIds?.length ?? 0) > 0) {
|
||||
const reason = spawn.runtimeDiagnostic ?? 'Runtime is waiting for permission approval';
|
||||
return {
|
||||
reason,
|
||||
diagnostic: spawn.runtimeDiagnostic ?? reason,
|
||||
diagnosticSeverity: spawn.runtimeDiagnosticSeverity ?? 'warning',
|
||||
};
|
||||
}
|
||||
|
||||
if (spawn.runtimeDiagnosticSeverity === 'error') {
|
||||
const reason = spawn.runtimeDiagnostic ?? 'Runtime launch status needs attention';
|
||||
return {
|
||||
reason,
|
||||
diagnostic: spawn.runtimeDiagnostic,
|
||||
diagnosticSeverity: 'error',
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getRuntimeBackedState(
|
||||
runtime: TeamAgentRuntimeEntry,
|
||||
hasErrorDiagnostic: boolean,
|
||||
hasSpawnDegradation: boolean
|
||||
): RuntimeDisplayState {
|
||||
if (hasSpawnDegradation || hasErrorDiagnostic) {
|
||||
return 'degraded';
|
||||
}
|
||||
|
||||
return runtime.alive === true ? 'running' : 'stopped';
|
||||
}
|
||||
|
||||
function buildSpawnBackedDisplayRow(
|
||||
memberName: string,
|
||||
spawn: MemberSpawnStatusEntry
|
||||
): TeamRuntimeDisplayRow {
|
||||
const spawnDegradation = getSpawnDegradation(spawn);
|
||||
if (spawnDegradation) {
|
||||
return {
|
||||
memberName,
|
||||
state: 'degraded',
|
||||
stateReason: spawnDegradation.reason,
|
||||
source: 'spawn-status',
|
||||
updatedAt: spawn.livenessLastCheckedAt ?? spawn.updatedAt,
|
||||
runtimeModel: spawn.runtimeModel,
|
||||
diagnostic: spawnDegradation.diagnostic,
|
||||
diagnosticSeverity: spawnDegradation.diagnosticSeverity,
|
||||
actionsAllowed: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (spawn.status === 'online' && hasConfirmedSpawnLiveness(spawn)) {
|
||||
return {
|
||||
memberName,
|
||||
state: 'running',
|
||||
stateReason: spawn.runtimeDiagnostic ?? 'Spawn status is online',
|
||||
source: 'spawn-status',
|
||||
updatedAt: spawn.livenessLastCheckedAt ?? spawn.lastHeartbeatAt ?? spawn.updatedAt,
|
||||
runtimeModel: spawn.runtimeModel,
|
||||
diagnostic: spawn.runtimeDiagnostic,
|
||||
diagnosticSeverity: spawn.runtimeDiagnosticSeverity,
|
||||
actionsAllowed: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (ACTIVE_SPAWN_STATUSES.has(spawn.status)) {
|
||||
return {
|
||||
memberName,
|
||||
state: spawn.status === 'waiting' ? 'waiting' : 'starting',
|
||||
stateReason: spawn.runtimeDiagnostic ?? `Spawn status is ${spawn.status}`,
|
||||
source: 'spawn-status',
|
||||
updatedAt: spawn.livenessLastCheckedAt ?? spawn.updatedAt,
|
||||
runtimeModel: spawn.runtimeModel,
|
||||
diagnostic: spawn.runtimeDiagnostic,
|
||||
diagnosticSeverity: spawn.runtimeDiagnosticSeverity,
|
||||
actionsAllowed: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (spawn.status === 'offline' || spawn.status === 'skipped') {
|
||||
return {
|
||||
memberName,
|
||||
state: 'stopped',
|
||||
stateReason:
|
||||
spawn.status === 'skipped'
|
||||
? (spawn.skipReason ?? 'Member was skipped for launch')
|
||||
: 'Spawn status is offline',
|
||||
source: 'spawn-status',
|
||||
updatedAt: spawn.updatedAt,
|
||||
runtimeModel: spawn.runtimeModel,
|
||||
diagnostic: spawn.runtimeDiagnostic,
|
||||
diagnosticSeverity: spawn.runtimeDiagnosticSeverity,
|
||||
actionsAllowed: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
memberName,
|
||||
state: 'unknown',
|
||||
stateReason: `Spawn status is ${String(spawn.status)}`,
|
||||
source: 'spawn-status',
|
||||
updatedAt: spawn.updatedAt,
|
||||
runtimeModel: spawn.runtimeModel,
|
||||
diagnostic: spawn.runtimeDiagnostic,
|
||||
diagnosticSeverity: spawn.runtimeDiagnosticSeverity,
|
||||
actionsAllowed: false,
|
||||
};
|
||||
}
|
||||
|
||||
function hasConfirmedSpawnLiveness(spawn: MemberSpawnStatusEntry): boolean {
|
||||
return (
|
||||
spawn.runtimeAlive === true ||
|
||||
spawn.bootstrapConfirmed === true ||
|
||||
spawn.livenessSource === 'heartbeat' ||
|
||||
spawn.livenessSource === 'process'
|
||||
);
|
||||
}
|
||||
|
||||
function formatRuntimePidLabel(runtime: TeamAgentRuntimeEntry): string | undefined {
|
||||
const runtimePid = getFinitePid(runtime.runtimePid);
|
||||
if (runtimePid != null) return `runtime pid ${runtimePid}`;
|
||||
|
||||
const processPid = getFinitePid(runtime.pid);
|
||||
if (processPid != null) return `${runtime.pidSource ?? 'process'} pid ${processPid}`;
|
||||
|
||||
const panePid = getFinitePid(runtime.panePid);
|
||||
if (panePid != null) return `pane pid ${panePid}`;
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getFinitePid(value: number | undefined): number | undefined {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
|
@ -42,11 +42,18 @@ import { createTabUISlice } from './slices/tabUISlice';
|
|||
import {
|
||||
createTeamSlice,
|
||||
getActiveTeamPendingReplyWaits,
|
||||
getCurrentProvisioningProgressForTeam,
|
||||
getLastResolvedTeamDataRefreshAt,
|
||||
hasActiveTeamPendingReplyWait,
|
||||
isTeamDataRefreshPending,
|
||||
selectTeamDataForName,
|
||||
} from './slices/teamSlice';
|
||||
import {
|
||||
decideProcessFanoutDryRun,
|
||||
decideProcessFanoutMode,
|
||||
type TeamProcessFanoutDecision,
|
||||
} from './teamProcessFanoutDryRun';
|
||||
import { installTeamRefreshFanoutDebugBridge } from './teamRefreshFanoutDebugBridge';
|
||||
import {
|
||||
noteTeamRefreshFanout,
|
||||
type TeamRefreshFanoutOperation,
|
||||
|
|
@ -63,6 +70,7 @@ import type {
|
|||
LeadContextUsage,
|
||||
ScheduleChangeEvent,
|
||||
TeamChangeEvent,
|
||||
TeamProvisioningProgress,
|
||||
ToolActivityEventPayload,
|
||||
ToolApprovalEvent,
|
||||
ToolApprovalRequest,
|
||||
|
|
@ -79,6 +87,9 @@ const TEAM_CHANGE_EVENT_WARN_THROTTLE_MS = 2_000;
|
|||
const TEAM_VISIBLE_IDLE_WATCHDOG_POLL_MS = 10_000;
|
||||
const TEAM_VISIBLE_IDLE_WATCHDOG_STALE_MS = 30_000;
|
||||
const TEAM_MESSAGE_FALLBACK_POLL_MS = 10_000;
|
||||
const ACTIVE_PROVISIONING_STATES_FOR_PROCESS_LITE: ReadonlySet<TeamProvisioningProgress['state']> =
|
||||
new Set(['validating', 'spawning', 'configuring', 'assembling', 'finalizing', 'verifying']);
|
||||
export const TEAM_PROCESS_LITE_FANOUT_STORAGE_KEY = 'team:processLiteFanout';
|
||||
const CURRENT_APP_VERSION =
|
||||
typeof __APP_VERSION__ === 'string' ? normalizeVersion(__APP_VERSION__) : '0.0.0';
|
||||
const logger = createLogger('Store:index');
|
||||
|
|
@ -102,6 +113,17 @@ const teamChangeEventDiagnostics = new Map<
|
|||
}
|
||||
>();
|
||||
|
||||
export function isTeamProcessLiteFanoutEnabled(): boolean {
|
||||
if (typeof window === 'undefined') {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
return window.localStorage.getItem(TEAM_PROCESS_LITE_FANOUT_STORAGE_KEY) !== '0';
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function noteTeamChangeEventBurst(teamName: string, eventType: string, visible: boolean): void {
|
||||
if (!visible) return;
|
||||
|
||||
|
|
@ -176,6 +198,7 @@ export const useStore = create<AppState>()((...args) => ({
|
|||
export function initializeNotificationListeners(): () => void {
|
||||
void cleanupCommentReadState();
|
||||
const cleanupFns: (() => void)[] = [];
|
||||
cleanupFns.push(installTeamRefreshFanoutDebugBridge());
|
||||
let cliStatusTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
useStore.getState().subscribeProvisioningProgress();
|
||||
cleanupFns.push(() => {
|
||||
|
|
@ -248,6 +271,10 @@ export function initializeNotificationListeners(): () => void {
|
|||
let memberSpawnRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
let teamAgentRuntimeRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
let toolActivityTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
let processLiteStructuralReconcileTimers = new Map<
|
||||
string,
|
||||
{ firstScheduledAt: number; timer: ReturnType<typeof setTimeout> }
|
||||
>();
|
||||
let inProgressChangePresencePollInFlight = false;
|
||||
let teamMessageFallbackPollInFlight = false;
|
||||
const inProgressChangePresenceCursorByTeam = new Map<string, number>();
|
||||
|
|
@ -263,7 +290,12 @@ export function initializeNotificationListeners(): () => void {
|
|||
const TEAM_MEMBER_SPAWN_REFRESH_THROTTLE_MS = 500;
|
||||
const TEAM_LIST_REFRESH_THROTTLE_MS = 2000;
|
||||
const GLOBAL_TASKS_REFRESH_THROTTLE_MS = 500;
|
||||
const PROCESS_LITE_STRUCTURAL_RECONCILE_IDLE_MS = 2_500;
|
||||
const PROCESS_LITE_STRUCTURAL_RECONCILE_MAX_WAIT_MS = 15_000;
|
||||
const buildTeamChangeFanoutReason = (eventType: string): string => `event:${eventType}`;
|
||||
const isProvisioningProgressActiveForProcessLite = (
|
||||
progress: Pick<TeamProvisioningProgress, 'state'> | null
|
||||
): boolean => progress != null && ACTIVE_PROVISIONING_STATES_FOR_PROCESS_LITE.has(progress.state);
|
||||
const addPendingGlobalRefreshDiagnostic = (
|
||||
pending: Map<string, Set<string>>,
|
||||
teamName: string,
|
||||
|
|
@ -327,61 +359,110 @@ export function initializeNotificationListeners(): () => void {
|
|||
// Best-effort refresh for message-driven events and fallback polling only.
|
||||
}
|
||||
};
|
||||
const scheduleMemberSpawnStatusesRefresh = (teamName: string | null | undefined): void => {
|
||||
type RuntimeRefreshReason = 'event:member-spawn' | 'event:process-lite';
|
||||
interface RuntimeRefreshScheduleOptions {
|
||||
reason?: RuntimeRefreshReason;
|
||||
skipIfHiddenAtExecution?: boolean;
|
||||
}
|
||||
const buildRuntimeRefreshTimerKey = (teamName: string, reason: RuntimeRefreshReason): string =>
|
||||
`${teamName}:${reason}`;
|
||||
const scheduleMemberSpawnStatusesRefresh = (
|
||||
teamName: string | null | undefined,
|
||||
options: RuntimeRefreshScheduleOptions = {}
|
||||
): void => {
|
||||
if (!teamName || !isTeamVisibleInAnyPane(teamName)) {
|
||||
return;
|
||||
}
|
||||
const existingTimer = memberSpawnRefreshTimers.get(teamName);
|
||||
const reason = options.reason ?? 'event:member-spawn';
|
||||
const timerKey = buildRuntimeRefreshTimerKey(teamName, reason);
|
||||
const existingTimer = memberSpawnRefreshTimers.get(timerKey);
|
||||
noteTeamRefreshFanout({
|
||||
teamName,
|
||||
surface: 'team-change-listener',
|
||||
phase: existingTimer ? 'coalesced' : 'scheduled',
|
||||
reason: 'event:member-spawn',
|
||||
reason,
|
||||
operation: 'fetchMemberSpawnStatuses',
|
||||
});
|
||||
if (memberSpawnRefreshTimers.has(teamName)) {
|
||||
if (memberSpawnRefreshTimers.has(timerKey)) {
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
memberSpawnRefreshTimers.delete(teamName);
|
||||
memberSpawnRefreshTimers.delete(timerKey);
|
||||
if (options.skipIfHiddenAtExecution === true && !isTeamVisibleInAnyPane(teamName)) {
|
||||
noteTeamRefreshFanout({
|
||||
teamName,
|
||||
surface: 'team-change-listener',
|
||||
phase: 'skipped',
|
||||
reason,
|
||||
operation: 'fetchMemberSpawnStatuses',
|
||||
visible: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
noteTeamRefreshFanout({
|
||||
teamName,
|
||||
surface: 'team-change-listener',
|
||||
phase: 'executed',
|
||||
reason: 'event:member-spawn',
|
||||
reason,
|
||||
operation: 'fetchMemberSpawnStatuses',
|
||||
});
|
||||
void useStore.getState().fetchMemberSpawnStatuses(teamName);
|
||||
}, TEAM_MEMBER_SPAWN_REFRESH_THROTTLE_MS);
|
||||
memberSpawnRefreshTimers.set(teamName, timer);
|
||||
memberSpawnRefreshTimers.set(timerKey, timer);
|
||||
};
|
||||
const scheduleTeamAgentRuntimeRefresh = (teamName: string | null | undefined): void => {
|
||||
const scheduleTeamAgentRuntimeRefresh = (
|
||||
teamName: string | null | undefined,
|
||||
options: RuntimeRefreshScheduleOptions = {}
|
||||
): void => {
|
||||
if (!teamName || !isTeamVisibleInAnyPane(teamName)) {
|
||||
return;
|
||||
}
|
||||
const existingTimer = teamAgentRuntimeRefreshTimers.get(teamName);
|
||||
const reason = options.reason ?? 'event:member-spawn';
|
||||
const timerKey = buildRuntimeRefreshTimerKey(teamName, reason);
|
||||
const existingTimer = teamAgentRuntimeRefreshTimers.get(timerKey);
|
||||
noteTeamRefreshFanout({
|
||||
teamName,
|
||||
surface: 'team-change-listener',
|
||||
phase: existingTimer ? 'coalesced' : 'scheduled',
|
||||
reason: 'event:member-spawn',
|
||||
reason,
|
||||
operation: 'fetchTeamAgentRuntime',
|
||||
});
|
||||
if (teamAgentRuntimeRefreshTimers.has(teamName)) {
|
||||
if (teamAgentRuntimeRefreshTimers.has(timerKey)) {
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
teamAgentRuntimeRefreshTimers.delete(teamName);
|
||||
teamAgentRuntimeRefreshTimers.delete(timerKey);
|
||||
if (options.skipIfHiddenAtExecution === true && !isTeamVisibleInAnyPane(teamName)) {
|
||||
noteTeamRefreshFanout({
|
||||
teamName,
|
||||
surface: 'team-change-listener',
|
||||
phase: 'skipped',
|
||||
reason,
|
||||
operation: 'fetchTeamAgentRuntime',
|
||||
visible: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
noteTeamRefreshFanout({
|
||||
teamName,
|
||||
surface: 'team-change-listener',
|
||||
phase: 'executed',
|
||||
reason: 'event:member-spawn',
|
||||
reason,
|
||||
operation: 'fetchTeamAgentRuntime',
|
||||
});
|
||||
void useStore.getState().fetchTeamAgentRuntime(teamName);
|
||||
}, TEAM_MEMBER_SPAWN_REFRESH_THROTTLE_MS);
|
||||
teamAgentRuntimeRefreshTimers.set(teamName, timer);
|
||||
teamAgentRuntimeRefreshTimers.set(timerKey, timer);
|
||||
};
|
||||
const scheduleProcessLiteRuntimeRefresh = (teamName: string): void => {
|
||||
scheduleMemberSpawnStatusesRefresh(teamName, {
|
||||
reason: 'event:process-lite',
|
||||
skipIfHiddenAtExecution: true,
|
||||
});
|
||||
scheduleTeamAgentRuntimeRefresh(teamName, {
|
||||
reason: 'event:process-lite',
|
||||
skipIfHiddenAtExecution: true,
|
||||
});
|
||||
};
|
||||
const scheduleTrackedTeamMessageRefresh = (
|
||||
teamName: string | null | undefined,
|
||||
|
|
@ -787,6 +868,128 @@ export function initializeNotificationListeners(): () => void {
|
|||
|
||||
return activeTab.teamName;
|
||||
};
|
||||
const buildProcessFanoutDecision = (
|
||||
event: TeamChangeEvent,
|
||||
isStaleRuntimeEvent: boolean
|
||||
): TeamProcessFanoutDecision => {
|
||||
const state = useStore.getState();
|
||||
const teamName = event.teamName;
|
||||
return decideProcessFanoutMode({
|
||||
teamName,
|
||||
eventType: event.type,
|
||||
detail: event.detail,
|
||||
hasRunId: Boolean(event.runId),
|
||||
isStaleRuntimeEvent,
|
||||
isVisible: isTeamVisibleInAnyPane(teamName),
|
||||
hasVisibleTeamData: selectTeamDataForName(state, teamName) != null,
|
||||
hasActiveProvisioningRun: isProvisioningProgressActiveForProcessLite(
|
||||
getCurrentProvisioningProgressForTeam(state, teamName)
|
||||
),
|
||||
hasCurrentRuntimeRun: state.currentRuntimeRunIdByTeam[teamName] != null,
|
||||
});
|
||||
};
|
||||
const recordProcessFanoutDecision = (
|
||||
event: TeamChangeEvent,
|
||||
decision: TeamProcessFanoutDecision
|
||||
): void => {
|
||||
const dryRun = decideProcessFanoutDryRun({
|
||||
teamName: event.teamName,
|
||||
eventType: event.type,
|
||||
detail: event.detail,
|
||||
hasRunId: Boolean(event.runId),
|
||||
isStaleRuntimeEvent: decision.reason === 'stale-runtime-event',
|
||||
isVisible: decision.reason !== 'hidden-team',
|
||||
hasVisibleTeamData: decision.reason !== 'missing-visible-team-data',
|
||||
hasActiveProvisioningRun: decision.reason !== 'no-active-runtime-context',
|
||||
hasCurrentRuntimeRun: decision.reason !== 'no-active-runtime-context',
|
||||
});
|
||||
const state = useStore.getState();
|
||||
|
||||
noteTeamRefreshFanout({
|
||||
teamName: event.teamName,
|
||||
surface: 'team-change-listener',
|
||||
phase: 'skipped',
|
||||
reason: `dry-run:process-lite:${dryRun.reason}`,
|
||||
operation: dryRun.wouldUseProcessLite ? 'wouldUseProcessLite' : 'wouldKeepStructuralProcess',
|
||||
eventType: event.type,
|
||||
visible: isTeamVisibleInAnyPane(event.teamName),
|
||||
selected: state.selectedTeamName === event.teamName,
|
||||
activeTab: getFocusedVisibleTeamName() === event.teamName,
|
||||
});
|
||||
};
|
||||
const cancelProcessLiteStructuralReconcile = (teamName: string): void => {
|
||||
const existing = processLiteStructuralReconcileTimers.get(teamName);
|
||||
if (!existing) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(existing.timer);
|
||||
processLiteStructuralReconcileTimers.delete(teamName);
|
||||
noteTeamRefreshFanout({
|
||||
teamName,
|
||||
surface: 'team-change-listener',
|
||||
phase: 'skipped',
|
||||
reason: 'event:process-lite:structural-reconcile:cancelled-by-structural',
|
||||
operation: 'refreshTeamData',
|
||||
});
|
||||
};
|
||||
const runProcessLiteStructuralReconcile = (teamName: string): void => {
|
||||
const current = useStore.getState();
|
||||
noteTeamRefreshFanout({
|
||||
teamName,
|
||||
surface: 'team-change-listener',
|
||||
phase: 'executed',
|
||||
reason: 'event:process-lite:structural-reconcile',
|
||||
operation: 'fetchTeams',
|
||||
});
|
||||
void current.fetchTeams();
|
||||
|
||||
if (!isTeamVisibleInAnyPane(teamName) || selectTeamDataForName(current, teamName) == null) {
|
||||
noteTeamRefreshFanout({
|
||||
teamName,
|
||||
surface: 'team-change-listener',
|
||||
phase: 'skipped',
|
||||
reason: 'event:process-lite:structural-reconcile:hidden-or-missing-data',
|
||||
operation: 'refreshTeamData',
|
||||
visible: isTeamVisibleInAnyPane(teamName),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
noteTeamRefreshFanout({
|
||||
teamName,
|
||||
surface: 'team-change-listener',
|
||||
phase: 'executed',
|
||||
reason: 'event:process-lite:structural-reconcile',
|
||||
operation: 'refreshTeamData',
|
||||
selected: current.selectedTeamName === teamName,
|
||||
visible: true,
|
||||
activeTab: getFocusedVisibleTeamName() === teamName,
|
||||
});
|
||||
void current.refreshTeamData(teamName, { withDedup: true });
|
||||
};
|
||||
const scheduleProcessLiteStructuralReconcile = (teamName: string): void => {
|
||||
const now = Date.now();
|
||||
const existing = processLiteStructuralReconcileTimers.get(teamName);
|
||||
const firstScheduledAt = existing?.firstScheduledAt ?? now;
|
||||
if (existing) {
|
||||
clearTimeout(existing.timer);
|
||||
}
|
||||
const elapsed = now - firstScheduledAt;
|
||||
const remainingMaxWait = Math.max(0, PROCESS_LITE_STRUCTURAL_RECONCILE_MAX_WAIT_MS - elapsed);
|
||||
const delay = Math.min(PROCESS_LITE_STRUCTURAL_RECONCILE_IDLE_MS, remainingMaxWait);
|
||||
noteTeamRefreshFanout({
|
||||
teamName,
|
||||
surface: 'team-change-listener',
|
||||
phase: existing ? 'coalesced' : 'scheduled',
|
||||
reason: 'event:process-lite:structural-reconcile',
|
||||
operation: 'refreshTeamData',
|
||||
});
|
||||
const timer = setTimeout(() => {
|
||||
processLiteStructuralReconcileTimers.delete(teamName);
|
||||
runProcessLiteStructuralReconcile(teamName);
|
||||
}, delay);
|
||||
processLiteStructuralReconcileTimers.set(teamName, { firstScheduledAt, timer });
|
||||
};
|
||||
|
||||
const pollTrackedTeamMessageFallback = async (): Promise<void> => {
|
||||
if (teamMessageFallbackPollInFlight) {
|
||||
|
|
@ -1369,6 +1572,48 @@ export function initializeNotificationListeners(): () => void {
|
|||
return;
|
||||
}
|
||||
|
||||
if (event.type === 'process') {
|
||||
const processDecision = buildProcessFanoutDecision(event, isStaleRuntimeEvent);
|
||||
recordProcessFanoutDecision(event, processDecision);
|
||||
if (processDecision.mode === 'process-lite' && isTeamProcessLiteFanoutEnabled()) {
|
||||
noteTeamRefreshFanout({
|
||||
teamName: event.teamName,
|
||||
surface: 'team-change-listener',
|
||||
phase: 'skipped',
|
||||
reason: 'event:process-lite:structural-suppressed',
|
||||
operation: 'fetchTeams',
|
||||
eventType: event.type,
|
||||
selected: useStore.getState().selectedTeamName === event.teamName,
|
||||
visible: true,
|
||||
activeTab: getFocusedVisibleTeamName() === event.teamName,
|
||||
});
|
||||
noteTeamRefreshFanout({
|
||||
teamName: event.teamName,
|
||||
surface: 'team-change-listener',
|
||||
phase: 'skipped',
|
||||
reason: 'event:process-lite:structural-suppressed',
|
||||
operation: 'refreshTeamData',
|
||||
eventType: event.type,
|
||||
selected: useStore.getState().selectedTeamName === event.teamName,
|
||||
visible: true,
|
||||
activeTab: getFocusedVisibleTeamName() === event.teamName,
|
||||
});
|
||||
scheduleProcessLiteRuntimeRefresh(event.teamName);
|
||||
scheduleProcessLiteStructuralReconcile(event.teamName);
|
||||
return;
|
||||
}
|
||||
if (processDecision.mode === 'process-lite') {
|
||||
noteTeamRefreshFanout({
|
||||
teamName: event.teamName,
|
||||
surface: 'team-change-listener',
|
||||
phase: 'skipped',
|
||||
reason: 'event:process-lite:disabled',
|
||||
operation: 'wouldKeepStructuralProcess',
|
||||
eventType: event.type,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const eventReason = buildTeamChangeFanoutReason(event.type);
|
||||
|
||||
// Throttled refresh of summary list (keeps TeamListView current without flooding).
|
||||
|
|
@ -1416,6 +1661,7 @@ export function initializeNotificationListeners(): () => void {
|
|||
|
||||
// Per-team throttle (not debounce): keep at most one pending detail refresh per team.
|
||||
// Debounce would delay indefinitely while inbox messages keep arriving.
|
||||
cancelProcessLiteStructuralReconcile(event.teamName);
|
||||
const selectedForRefresh = useStore.getState().selectedTeamName === event.teamName;
|
||||
const activeTabForRefresh = getFocusedVisibleTeamName() === event.teamName;
|
||||
const existingDetailTimer = teamRefreshTimers.get(event.teamName);
|
||||
|
|
@ -1468,6 +1714,10 @@ export function initializeNotificationListeners(): () => void {
|
|||
teamAgentRuntimeRefreshTimers = new Map();
|
||||
for (const t of toolActivityTimers.values()) clearTimeout(t);
|
||||
toolActivityTimers = new Map();
|
||||
for (const state of processLiteStructuralReconcileTimers.values()) {
|
||||
clearTimeout(state.timer);
|
||||
}
|
||||
processLiteStructuralReconcileTimers = new Map();
|
||||
teamLastRelevantActivityAt.clear();
|
||||
teamLastIdleWatchdogRefreshAt.clear();
|
||||
if (teamListRefreshTimer) {
|
||||
|
|
|
|||
|
|
@ -10,11 +10,93 @@ import { getAllTabs } from '../utils/paneHelpers';
|
|||
|
||||
import type { AppState } from '../types';
|
||||
import type { DetectedError } from '@renderer/types/data';
|
||||
import type { NotificationTarget } from '@shared/types';
|
||||
import type { StateCreator } from 'zustand';
|
||||
|
||||
const logger = createLogger('Store:notification');
|
||||
const NOTIFICATIONS_FETCH_LIMIT = 200;
|
||||
|
||||
function getTeamNameFromError(error: DetectedError): string | null {
|
||||
if (error.sessionId.startsWith('team:')) {
|
||||
const teamName = error.sessionId.slice('team:'.length).trim();
|
||||
return teamName || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isNotificationTarget(value: unknown): value is NotificationTarget {
|
||||
if (!value || typeof value !== 'object') return false;
|
||||
const row = value as Record<string, unknown>;
|
||||
if (row.kind === 'team') return typeof row.teamName === 'string' && row.teamName.length > 0;
|
||||
if (row.kind === 'task') {
|
||||
return (
|
||||
typeof row.teamName === 'string' &&
|
||||
row.teamName.length > 0 &&
|
||||
typeof row.taskId === 'string' &&
|
||||
row.taskId.length > 0
|
||||
);
|
||||
}
|
||||
if (row.kind === 'member') {
|
||||
return (
|
||||
typeof row.teamName === 'string' &&
|
||||
row.teamName.length > 0 &&
|
||||
typeof row.memberName === 'string' &&
|
||||
row.memberName.length > 0
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function parseLegacyTeamTarget(error: DetectedError, fallbackTeamName: string): NotificationTarget {
|
||||
const dedupeParts = (error.dedupeKey ?? '').split(':');
|
||||
const kind = dedupeParts[0];
|
||||
const teamName = dedupeParts[1] || fallbackTeamName;
|
||||
|
||||
if (
|
||||
(kind === 'comment' || kind === 'clarification' || kind === 'status' || kind === 'created') &&
|
||||
dedupeParts[2]
|
||||
) {
|
||||
return {
|
||||
kind: 'task',
|
||||
teamName,
|
||||
taskId: dedupeParts[2],
|
||||
commentId: kind === 'comment' ? dedupeParts[3] : undefined,
|
||||
focus: kind === 'comment' || kind === 'clarification' ? 'comments' : 'detail',
|
||||
};
|
||||
}
|
||||
|
||||
if (kind === 'inbox' && dedupeParts[2]) {
|
||||
return { kind: 'member', teamName, memberName: dedupeParts[2], focus: 'messages' };
|
||||
}
|
||||
|
||||
return { kind: 'team', teamName, section: 'overview' };
|
||||
}
|
||||
|
||||
function getTeamNotificationTarget(error: DetectedError): NotificationTarget | null {
|
||||
if (isNotificationTarget(error.target)) {
|
||||
return error.target;
|
||||
}
|
||||
|
||||
const teamName = getTeamNameFromError(error);
|
||||
if (!teamName) return null;
|
||||
|
||||
return parseLegacyTeamTarget(error, teamName);
|
||||
}
|
||||
|
||||
function navigateToTeamNotification(state: AppState, error: DetectedError): void {
|
||||
const target = getTeamNotificationTarget(error);
|
||||
const teamName = target?.teamName ?? getTeamNameFromError(error);
|
||||
if (!teamName) return;
|
||||
|
||||
state.openTeamTab(teamName, error.context.cwd);
|
||||
|
||||
if (target?.kind === 'task') {
|
||||
state.openGlobalTaskDetail(target.teamName, target.taskId, target.commentId);
|
||||
} else if (target?.kind === 'member') {
|
||||
state.openMemberProfile(target.memberName, target.teamName, target.focus);
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Slice Interface
|
||||
// =============================================================================
|
||||
|
|
@ -206,10 +288,9 @@ export const createNotificationSlice: StateCreator<AppState, [], [], Notificatio
|
|||
// Mark the notification as read
|
||||
void state.markNotificationRead(error.id);
|
||||
|
||||
// Team notifications (inbox, clarification, status change, rate-limit): open team tab
|
||||
// Team notifications use structured targets when available.
|
||||
if (error.sessionId.startsWith('team:')) {
|
||||
const teamName = error.sessionId.slice('team:'.length);
|
||||
state.openTeamTab(teamName, error.context.cwd);
|
||||
navigateToTeamNotification(state, error);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1103,6 +1103,13 @@ function fireClarificationNotification(task: GlobalTask, suppressToast: boolean)
|
|||
body,
|
||||
teamEventType: 'task_clarification',
|
||||
dedupeKey: `clarification:${task.teamName}:${task.id}:${task.updatedAt ?? Date.now()}`,
|
||||
target: {
|
||||
kind: 'task',
|
||||
teamName: task.teamName,
|
||||
taskId: task.id,
|
||||
commentId: latestComment?.id,
|
||||
focus: 'comments',
|
||||
},
|
||||
suppressToast,
|
||||
})
|
||||
.catch(() => undefined);
|
||||
|
|
@ -1199,6 +1206,12 @@ function fireStatusChangeNotification(
|
|||
body: task.subject,
|
||||
teamEventType: 'task_status_change',
|
||||
dedupeKey: `status:${task.teamName}:${task.id}:${fromStatus}:${toStatus}:${task.updatedAt ?? Date.now()}`,
|
||||
target: {
|
||||
kind: 'task',
|
||||
teamName: task.teamName,
|
||||
taskId: task.id,
|
||||
focus: 'status',
|
||||
},
|
||||
suppressToast,
|
||||
})
|
||||
.catch(() => undefined);
|
||||
|
|
@ -1256,6 +1269,13 @@ function fireTaskCommentNotification(
|
|||
body: preview,
|
||||
teamEventType: 'task_comment',
|
||||
dedupeKey: `comment:${task.teamName}:${task.id}:${comment.id}`,
|
||||
target: {
|
||||
kind: 'task',
|
||||
teamName: task.teamName,
|
||||
taskId: task.id,
|
||||
commentId: comment.id,
|
||||
focus: 'comments',
|
||||
},
|
||||
suppressToast,
|
||||
})
|
||||
.catch(() => undefined);
|
||||
|
|
@ -1289,6 +1309,12 @@ function fireTaskCreatedNotification(task: GlobalTask, suppressToast: boolean):
|
|||
body: stripAgentBlocks(task.description || task.subject).trim(),
|
||||
teamEventType: 'task_created',
|
||||
dedupeKey: `created:${task.teamName}:${task.id}`,
|
||||
target: {
|
||||
kind: 'task',
|
||||
teamName: task.teamName,
|
||||
taskId: task.id,
|
||||
focus: 'detail',
|
||||
},
|
||||
suppressToast,
|
||||
})
|
||||
.catch(() => undefined);
|
||||
|
|
@ -1347,6 +1373,11 @@ function fireAllTasksCompletedNotification(
|
|||
body: `All tasks in team "${sampleTask.teamDisplayName}" are done`,
|
||||
teamEventType: 'all_tasks_completed',
|
||||
dedupeKey: `all-done:${sampleTask.teamName}:${Date.now()}`,
|
||||
target: {
|
||||
kind: 'team',
|
||||
teamName: sampleTask.teamName,
|
||||
section: 'tasks',
|
||||
},
|
||||
suppressToast,
|
||||
})
|
||||
.catch(() => undefined);
|
||||
|
|
@ -1448,6 +1479,13 @@ function mapReviewError(error: unknown): string {
|
|||
export interface GlobalTaskDetailState {
|
||||
teamName: string;
|
||||
taskId: string;
|
||||
commentId?: string;
|
||||
}
|
||||
|
||||
export interface PendingMemberProfileState {
|
||||
teamName?: string;
|
||||
memberName: string;
|
||||
focus?: 'profile' | 'messages' | 'logs';
|
||||
}
|
||||
|
||||
/** Per-team launch parameters shown in the header badge. */
|
||||
|
|
@ -1584,6 +1622,9 @@ export function selectTeamDataForName(
|
|||
if (!teamName) {
|
||||
return null;
|
||||
}
|
||||
if (state.selectedTeamName === teamName && state.selectedTeamData) {
|
||||
return state.selectedTeamData;
|
||||
}
|
||||
return (
|
||||
state.teamDataCacheByName[teamName] ??
|
||||
(state.selectedTeamName === teamName ? state.selectedTeamData : null)
|
||||
|
|
@ -1931,11 +1972,15 @@ export interface TeamSlice {
|
|||
globalTasksInitialized: boolean;
|
||||
globalTasksError: string | null;
|
||||
globalTaskDetail: GlobalTaskDetailState | null;
|
||||
openGlobalTaskDetail: (teamName: string, taskId: string) => void;
|
||||
openGlobalTaskDetail: (teamName: string, taskId: string, commentId?: string) => void;
|
||||
closeGlobalTaskDetail: () => void;
|
||||
/** Set by MemberHoverCard to signal TeamDetailView to open MemberDetailDialog */
|
||||
pendingMemberProfile: string | null;
|
||||
openMemberProfile: (memberName: string) => void;
|
||||
pendingMemberProfile: PendingMemberProfileState | null;
|
||||
openMemberProfile: (
|
||||
memberName: string,
|
||||
teamName?: string,
|
||||
focus?: PendingMemberProfileState['focus']
|
||||
) => void;
|
||||
closeMemberProfile: () => void;
|
||||
/** Set by GlobalTaskDetailDialog to signal TeamDetailView to open ChangeReviewDialog */
|
||||
pendingReviewRequest: {
|
||||
|
|
@ -2457,12 +2502,13 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
kanbanFilterQuery: null,
|
||||
globalTaskDetail: null,
|
||||
pendingMemberProfile: null,
|
||||
openMemberProfile: (memberName: string) => set({ pendingMemberProfile: memberName }),
|
||||
openMemberProfile: (memberName: string, teamName?: string, focus?: PendingMemberProfileState['focus']) =>
|
||||
set({ pendingMemberProfile: { memberName, teamName, focus } }),
|
||||
closeMemberProfile: () => set({ pendingMemberProfile: null }),
|
||||
pendingReviewRequest: null,
|
||||
setPendingReviewRequest: (req) => set({ pendingReviewRequest: req }),
|
||||
openGlobalTaskDetail: (teamName: string, taskId: string) => {
|
||||
set({ globalTaskDetail: { teamName, taskId } });
|
||||
openGlobalTaskDetail: (teamName: string, taskId: string, commentId?: string) => {
|
||||
set({ globalTaskDetail: { teamName, taskId, commentId } });
|
||||
},
|
||||
closeGlobalTaskDetail: () => set({ globalTaskDetail: null }),
|
||||
addingComment: false,
|
||||
|
|
|
|||
70
src/renderer/store/teamProcessFanoutDryRun.ts
Normal file
70
src/renderer/store/teamProcessFanoutDryRun.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
export type TeamProcessFanoutMode = 'process-lite' | 'structural';
|
||||
|
||||
export interface TeamProcessFanoutInput {
|
||||
teamName: string;
|
||||
eventType: string;
|
||||
detail?: string;
|
||||
hasRunId: boolean;
|
||||
isStaleRuntimeEvent: boolean;
|
||||
isVisible: boolean;
|
||||
hasVisibleTeamData: boolean;
|
||||
hasActiveProvisioningRun: boolean;
|
||||
hasCurrentRuntimeRun: boolean;
|
||||
}
|
||||
|
||||
export type TeamProcessFanoutDryRunInput = TeamProcessFanoutInput;
|
||||
|
||||
export type TeamProcessFanoutDecisionReason =
|
||||
| 'not-process-event'
|
||||
| 'stale-runtime-event'
|
||||
| 'hidden-team'
|
||||
| 'missing-visible-team-data'
|
||||
| 'no-active-runtime-context'
|
||||
| 'unsafe-process-detail'
|
||||
| 'processes-json-visible-runtime-context';
|
||||
|
||||
export interface TeamProcessFanoutDecision {
|
||||
mode: TeamProcessFanoutMode;
|
||||
reason: TeamProcessFanoutDecisionReason;
|
||||
}
|
||||
|
||||
export interface TeamProcessFanoutDryRunDecision {
|
||||
wouldUseProcessLite: boolean;
|
||||
reason: TeamProcessFanoutDecisionReason;
|
||||
}
|
||||
|
||||
export function decideProcessFanoutMode(input: TeamProcessFanoutInput): TeamProcessFanoutDecision {
|
||||
if (input.eventType !== 'process') {
|
||||
return { mode: 'structural', reason: 'not-process-event' };
|
||||
}
|
||||
if (input.isStaleRuntimeEvent) {
|
||||
return { mode: 'structural', reason: 'stale-runtime-event' };
|
||||
}
|
||||
if (!input.isVisible) {
|
||||
return { mode: 'structural', reason: 'hidden-team' };
|
||||
}
|
||||
if (!input.hasVisibleTeamData) {
|
||||
return { mode: 'structural', reason: 'missing-visible-team-data' };
|
||||
}
|
||||
if (!input.hasActiveProvisioningRun && !input.hasCurrentRuntimeRun) {
|
||||
return { mode: 'structural', reason: 'no-active-runtime-context' };
|
||||
}
|
||||
if (input.detail !== 'processes.json') {
|
||||
return { mode: 'structural', reason: 'unsafe-process-detail' };
|
||||
}
|
||||
|
||||
return {
|
||||
mode: 'process-lite',
|
||||
reason: 'processes-json-visible-runtime-context',
|
||||
};
|
||||
}
|
||||
|
||||
export function decideProcessFanoutDryRun(
|
||||
input: TeamProcessFanoutDryRunInput
|
||||
): TeamProcessFanoutDryRunDecision {
|
||||
const decision = decideProcessFanoutMode(input);
|
||||
return {
|
||||
wouldUseProcessLite: decision.mode === 'process-lite',
|
||||
reason: decision.reason,
|
||||
};
|
||||
}
|
||||
49
src/renderer/store/teamRefreshFanoutDebugBridge.ts
Normal file
49
src/renderer/store/teamRefreshFanoutDebugBridge.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import {
|
||||
getTeamRefreshFanoutSnapshot,
|
||||
resetTeamRefreshFanoutDiagnostics,
|
||||
summarizeTeamRefreshFanout,
|
||||
} from './teamRefreshFanoutDiagnostics';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__TEAM_REFRESH_FANOUT__?: Readonly<{
|
||||
snapshot: typeof getTeamRefreshFanoutSnapshot;
|
||||
summary: typeof summarizeTeamRefreshFanout;
|
||||
reset: typeof resetTeamRefreshFanoutDiagnostics;
|
||||
}>;
|
||||
}
|
||||
}
|
||||
|
||||
export const TEAM_REFRESH_FANOUT_DEBUG_STORAGE_KEY = 'debug:teamRefreshFanout';
|
||||
|
||||
function isDebugBridgeEnabled(): boolean {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return window.localStorage.getItem(TEAM_REFRESH_FANOUT_DEBUG_STORAGE_KEY) === '1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function installTeamRefreshFanoutDebugBridge(): () => void {
|
||||
if (typeof window === 'undefined' || !isDebugBridgeEnabled()) {
|
||||
return () => undefined;
|
||||
}
|
||||
|
||||
const bridge = Object.freeze({
|
||||
snapshot: getTeamRefreshFanoutSnapshot,
|
||||
summary: summarizeTeamRefreshFanout,
|
||||
reset: resetTeamRefreshFanoutDiagnostics,
|
||||
});
|
||||
|
||||
window.__TEAM_REFRESH_FANOUT__ = bridge;
|
||||
|
||||
return () => {
|
||||
if (window.__TEAM_REFRESH_FANOUT__ === bridge) {
|
||||
delete window.__TEAM_REFRESH_FANOUT__;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -14,7 +14,9 @@ export type TeamRefreshFanoutOperation =
|
|||
| 'fetchTeamMessageHead'
|
||||
| 'fetchMemberSpawnStatuses'
|
||||
| 'fetchTeamAgentRuntime'
|
||||
| 'refreshTaskChangePresence';
|
||||
| 'refreshTaskChangePresence'
|
||||
| 'wouldUseProcessLite'
|
||||
| 'wouldKeepStructuralProcess';
|
||||
|
||||
export interface TeamRefreshFanoutNote {
|
||||
teamName: string;
|
||||
|
|
@ -44,12 +46,32 @@ export interface TeamRefreshFanoutRecentNote {
|
|||
|
||||
export interface TeamRefreshFanoutSnapshot {
|
||||
counts: Record<string, number>;
|
||||
structuredCounts: Record<string, TeamRefreshFanoutStructuredCount>;
|
||||
recent: TeamRefreshFanoutRecentNote[];
|
||||
lastAt: number;
|
||||
}
|
||||
|
||||
export interface TeamRefreshFanoutStructuredCount {
|
||||
key: string;
|
||||
count: number;
|
||||
surface: TeamRefreshFanoutSurface;
|
||||
reason: string;
|
||||
operation: TeamRefreshFanoutOperation;
|
||||
phase: TeamRefreshFanoutPhase;
|
||||
}
|
||||
|
||||
export interface TeamRefreshFanoutSummaryRow extends TeamRefreshFanoutStructuredCount {}
|
||||
|
||||
export interface TeamRefreshFanoutSummary {
|
||||
generatedAt: number;
|
||||
teamName?: string;
|
||||
total: number;
|
||||
rows: TeamRefreshFanoutSummaryRow[];
|
||||
}
|
||||
|
||||
interface TeamRefreshFanoutBucket {
|
||||
counts: Record<string, number>;
|
||||
structuredCounts: Record<string, TeamRefreshFanoutStructuredCount>;
|
||||
recent: TeamRefreshFanoutRecentNote[];
|
||||
lastAt: number;
|
||||
}
|
||||
|
|
@ -62,6 +84,7 @@ const buckets = new Map<string, TeamRefreshFanoutBucket>();
|
|||
function createEmptyBucket(): TeamRefreshFanoutBucket {
|
||||
return {
|
||||
counts: {},
|
||||
structuredCounts: {},
|
||||
recent: [],
|
||||
lastAt: 0,
|
||||
};
|
||||
|
|
@ -93,6 +116,9 @@ function cloneBucket(
|
|||
|
||||
return {
|
||||
counts: { ...bucket.counts },
|
||||
structuredCounts: Object.fromEntries(
|
||||
Object.entries(bucket.structuredCounts).map(([key, value]) => [key, { ...value }])
|
||||
),
|
||||
recent: bucket.recent.map((note) => ({ ...note })),
|
||||
lastAt: bucket.lastAt,
|
||||
};
|
||||
|
|
@ -112,6 +138,15 @@ export function noteTeamRefreshFanout(note: TeamRefreshFanoutNote): void {
|
|||
const now = Date.now();
|
||||
|
||||
bucket.counts[key] = (bucket.counts[key] ?? 0) + 1;
|
||||
const existingStructured = bucket.structuredCounts[key];
|
||||
bucket.structuredCounts[key] = {
|
||||
key,
|
||||
count: (existingStructured?.count ?? 0) + 1,
|
||||
surface: note.surface,
|
||||
reason: note.reason,
|
||||
operation: note.operation,
|
||||
phase: note.phase,
|
||||
};
|
||||
bucket.lastAt = now;
|
||||
bucket.recent.push({
|
||||
at: now,
|
||||
|
|
@ -131,7 +166,18 @@ export function noteTeamRefreshFanout(note: TeamRefreshFanoutNote): void {
|
|||
}
|
||||
}
|
||||
|
||||
export function getTeamRefreshFanoutSnapshotForTests(
|
||||
function collectStructuredCounts(teamName?: string): TeamRefreshFanoutStructuredCount[] {
|
||||
if (teamName) {
|
||||
const bucket = buckets.get(teamName);
|
||||
return bucket ? Object.values(bucket.structuredCounts).map((row) => ({ ...row })) : [];
|
||||
}
|
||||
|
||||
return Array.from(buckets.values()).flatMap((bucket) =>
|
||||
Object.values(bucket.structuredCounts).map((row) => ({ ...row }))
|
||||
);
|
||||
}
|
||||
|
||||
export function getTeamRefreshFanoutSnapshot(
|
||||
teamName?: string
|
||||
): TeamRefreshFanoutSnapshot | Record<string, TeamRefreshFanoutSnapshot> | null {
|
||||
if (teamName) {
|
||||
|
|
@ -143,6 +189,36 @@ export function getTeamRefreshFanoutSnapshotForTests(
|
|||
) as Record<string, TeamRefreshFanoutSnapshot>;
|
||||
}
|
||||
|
||||
export function __resetTeamRefreshFanoutDiagnosticsForTests(): void {
|
||||
export function resetTeamRefreshFanoutDiagnostics(): void {
|
||||
buckets.clear();
|
||||
}
|
||||
|
||||
export function summarizeTeamRefreshFanout(teamName?: string): TeamRefreshFanoutSummary {
|
||||
const aggregate = new Map<string, TeamRefreshFanoutSummaryRow>();
|
||||
|
||||
for (const row of collectStructuredCounts(teamName)) {
|
||||
const existing = aggregate.get(row.key);
|
||||
aggregate.set(row.key, {
|
||||
...row,
|
||||
count: (existing?.count ?? 0) + row.count,
|
||||
});
|
||||
}
|
||||
|
||||
const rows = Array.from(aggregate.values()).sort(
|
||||
(a, b) =>
|
||||
b.count - a.count ||
|
||||
a.operation.localeCompare(b.operation) ||
|
||||
a.reason.localeCompare(b.reason) ||
|
||||
a.phase.localeCompare(b.phase)
|
||||
);
|
||||
|
||||
return {
|
||||
generatedAt: Date.now(),
|
||||
...(teamName ? { teamName } : {}),
|
||||
total: rows.reduce((sum, row) => sum + row.count, 0),
|
||||
rows,
|
||||
};
|
||||
}
|
||||
|
||||
export const getTeamRefreshFanoutSnapshotForTests = getTeamRefreshFanoutSnapshot;
|
||||
export const __resetTeamRefreshFanoutDiagnosticsForTests = resetTeamRefreshFanoutDiagnostics;
|
||||
|
|
|
|||
|
|
@ -33,6 +33,26 @@ export type TeamEventType =
|
|||
| 'schedule_failed'
|
||||
| 'team_launched';
|
||||
|
||||
export type NotificationTarget =
|
||||
| {
|
||||
kind: 'team';
|
||||
teamName: string;
|
||||
section?: 'overview' | 'tasks' | 'members' | 'messages' | 'schedules';
|
||||
}
|
||||
| {
|
||||
kind: 'task';
|
||||
teamName: string;
|
||||
taskId: string;
|
||||
commentId?: string;
|
||||
focus?: 'detail' | 'comments' | 'status' | 'review';
|
||||
}
|
||||
| {
|
||||
kind: 'member';
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
focus?: 'profile' | 'messages' | 'logs';
|
||||
};
|
||||
|
||||
/**
|
||||
* Detected error from session JSONL files.
|
||||
* Used for notification display and deep linking to error locations.
|
||||
|
|
@ -72,6 +92,8 @@ export interface DetectedError {
|
|||
category?: 'error' | 'team';
|
||||
/** For team notifications: specific event sub-type */
|
||||
teamEventType?: TeamEventType;
|
||||
/** Structured destination for notification clicks. */
|
||||
target?: NotificationTarget;
|
||||
/** Explicit key for storage deduplication. Two notifications with the same dedupeKey won't be stored twice. */
|
||||
dedupeKey?: string;
|
||||
/** Additional context */
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { EnhancedChunk } from '@main/types';
|
||||
import type { NotificationTarget, TeamEventType } from './notifications';
|
||||
|
||||
export interface TeamMember {
|
||||
name: string;
|
||||
|
|
@ -1503,12 +1504,9 @@ export interface TeamMessageNotificationData {
|
|||
/** Optional sender color for visual context. */
|
||||
color?: string;
|
||||
/** Team event sub-type for notification categorization. */
|
||||
teamEventType?:
|
||||
| 'task_clarification'
|
||||
| 'task_status_change'
|
||||
| 'task_comment'
|
||||
| 'task_created'
|
||||
| 'all_tasks_completed';
|
||||
teamEventType?: TeamEventType;
|
||||
/** Structured destination used when clicking the OS or in-app notification. */
|
||||
target?: NotificationTarget;
|
||||
/** Stable key for storage deduplication. Required — no fallback to Date.now(). */
|
||||
dedupeKey?: string;
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -32,11 +32,18 @@ import {
|
|||
} from '../../../../src/main/utils/pathDecoder';
|
||||
import { createPersistedLaunchSnapshot } from '../../../../src/main/services/team/TeamLaunchStateEvaluator';
|
||||
import {
|
||||
getOpenCodeRuntimeManifestPath,
|
||||
getOpenCodeRuntimeLaneIndexPath,
|
||||
readOpenCodeRuntimeLaneIndex,
|
||||
setOpenCodeRuntimeActiveRunManifest,
|
||||
upsertOpenCodeRuntimeLaneIndexEntry,
|
||||
} from '../../../../src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader';
|
||||
import {
|
||||
createRuntimeStoreManifestStore,
|
||||
createRuntimeStoreReceiptStore,
|
||||
OPENCODE_RUNTIME_STORE_DESCRIPTORS,
|
||||
RuntimeStoreBatchWriter,
|
||||
} from '../../../../src/main/services/team/opencode/store/RuntimeStoreManifest';
|
||||
|
||||
import type { TeamProvisioningProgress } from '../../../../src/shared/types';
|
||||
|
||||
|
|
@ -9921,6 +9928,7 @@ describe('Team agent launch matrix safe e2e', () => {
|
|||
addGeminiPrimaryToMixedRun(currentRun);
|
||||
staleRun.runId = `run-${teamName}-stale`;
|
||||
currentRun.runId = `run-${teamName}-current`;
|
||||
markMixedOpenCodeLaneConfirmedForTest(currentRun, 'bob');
|
||||
trackLiveRun(svc, staleRun);
|
||||
trackLiveRun(svc, currentRun);
|
||||
|
||||
|
|
@ -9990,6 +9998,7 @@ describe('Team agent launch matrix safe e2e', () => {
|
|||
addGeminiPrimaryToMixedRun(secondRun);
|
||||
firstRun.child = { stdin: { writable: true } };
|
||||
secondRun.child = { stdin: { writable: true } };
|
||||
markMixedOpenCodeLaneConfirmedForTest(secondRun, 'bob');
|
||||
trackLiveRun(svc, firstRun);
|
||||
trackLiveRun(svc, secondRun);
|
||||
|
||||
|
|
@ -10268,6 +10277,7 @@ describe('Team agent launch matrix safe e2e', () => {
|
|||
const currentRun = createMixedLiveRun({ teamName, projectPath, primaryProviderId: 'anthropic' });
|
||||
addGeminiPrimaryToMixedRun(currentRun);
|
||||
currentRun.runId = `run-${teamName}-current`;
|
||||
markMixedOpenCodeLaneConfirmedForTest(currentRun, 'bob');
|
||||
trackLiveRun(svc, currentRun);
|
||||
injectStaleTerminalProvisioningRun(svc, teamName, `run-${teamName}-stale`);
|
||||
|
||||
|
|
@ -11181,7 +11191,7 @@ describe('Team agent launch matrix safe e2e', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('recovers a missing mixed OpenCode lane index from materialized persisted runtime evidence before direct delivery', async () => {
|
||||
it('recovers a missing mixed OpenCode lane index from materialized persisted runtime evidence but blocks direct delivery until bootstrap', async () => {
|
||||
const teamName = 'mixed-opencode-direct-message-recovers-missing-lane-safe-e2e';
|
||||
await writeMixedTeamConfig({ teamName, projectPath });
|
||||
await writeTeamMeta(teamName, projectPath);
|
||||
|
|
@ -11268,8 +11278,11 @@ describe('Team agent launch matrix safe e2e', () => {
|
|||
messageId: 'msg-recovered-missing-lane-bob',
|
||||
})
|
||||
).resolves.toEqual({
|
||||
delivered: true,
|
||||
diagnostics: [],
|
||||
delivered: false,
|
||||
reason: 'opencode_runtime_not_active',
|
||||
diagnostics: [
|
||||
'OpenCode runtime bootstrap is not confirmed for bob. Message was saved and will be retried after runtime check-in.',
|
||||
],
|
||||
});
|
||||
|
||||
expect(adapter.reconcileInputs).toHaveLength(1);
|
||||
|
|
@ -11287,15 +11300,7 @@ describe('Team agent launch matrix safe e2e', () => {
|
|||
},
|
||||
}
|
||||
);
|
||||
expect(adapter.messageInputs).toHaveLength(1);
|
||||
expect(adapter.messageInputs[0]).toMatchObject({
|
||||
teamName,
|
||||
laneId: 'secondary:opencode:bob',
|
||||
memberName: 'bob',
|
||||
cwd: projectPath,
|
||||
text: 'recovered bob receives direct message',
|
||||
messageId: 'msg-recovered-missing-lane-bob',
|
||||
});
|
||||
expect(adapter.messageInputs).toEqual([]);
|
||||
});
|
||||
|
||||
it('recovers a missing mixed OpenCode lane index from confirmed-alive persisted runtime evidence before direct delivery', async () => {
|
||||
|
|
@ -11354,6 +11359,13 @@ describe('Team agent launch matrix safe e2e', () => {
|
|||
const adapter = new FakeOpenCodeRuntimeAdapter('clean_success', {
|
||||
bob: 'confirmed',
|
||||
});
|
||||
await writeOpenCodeBootstrapSessionEvidenceForTest({
|
||||
teamName,
|
||||
laneId: 'secondary:opencode:bob',
|
||||
memberName: 'bob',
|
||||
runId: null,
|
||||
sessionId: 'ses_bob_confirmed_materialized',
|
||||
});
|
||||
await writeAliveProcessRegistry(teamName);
|
||||
const restartedService = new TeamProvisioningService();
|
||||
restartedService.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter]));
|
||||
|
|
@ -12641,6 +12653,7 @@ describe('Team agent launch matrix safe e2e', () => {
|
|||
svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter]));
|
||||
const run = createMixedLiveRun({ teamName, projectPath, primaryProviderId: 'anthropic' });
|
||||
addGeminiPrimaryToMixedRun(run);
|
||||
markMixedOpenCodeLaneConfirmedForTest(run, 'tom');
|
||||
trackLiveRun(svc, run);
|
||||
|
||||
await expect(
|
||||
|
|
@ -12696,6 +12709,7 @@ describe('Team agent launch matrix safe e2e', () => {
|
|||
svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter]));
|
||||
const run = createMixedLiveRun({ teamName, projectPath, primaryProviderId: 'anthropic' });
|
||||
addGeminiPrimaryToMixedRun(run);
|
||||
markMixedOpenCodeLaneConfirmedForTest(run, 'bob');
|
||||
trackLiveRun(svc, run);
|
||||
|
||||
await expect(
|
||||
|
|
@ -17105,6 +17119,7 @@ async function upsertActiveOpenCodeRuntimeLaneForTest(input: {
|
|||
runId?: string | null;
|
||||
diagnostics?: string[];
|
||||
}): Promise<void> {
|
||||
const runId = input.runId ?? null;
|
||||
await upsertOpenCodeRuntimeLaneIndexEntry({
|
||||
teamsBasePath: getTeamsBasePath(),
|
||||
teamName: input.teamName,
|
||||
|
|
@ -17116,7 +17131,76 @@ async function upsertActiveOpenCodeRuntimeLaneForTest(input: {
|
|||
teamsBasePath: getTeamsBasePath(),
|
||||
teamName: input.teamName,
|
||||
laneId: input.laneId,
|
||||
runId: input.runId ?? null,
|
||||
runId,
|
||||
});
|
||||
await writeOpenCodeBootstrapSessionEvidenceForTest({
|
||||
teamName: input.teamName,
|
||||
laneId: input.laneId,
|
||||
runId,
|
||||
});
|
||||
}
|
||||
|
||||
async function writeOpenCodeBootstrapSessionEvidenceForTest(input: {
|
||||
teamName: string;
|
||||
laneId: string;
|
||||
runId?: string | null;
|
||||
memberName?: string;
|
||||
sessionId?: string;
|
||||
}): Promise<void> {
|
||||
const runId = input.runId ?? null;
|
||||
const descriptor = OPENCODE_RUNTIME_STORE_DESCRIPTORS.find(
|
||||
(candidate) => candidate.schemaName === 'opencode.sessionStore'
|
||||
);
|
||||
if (!descriptor) {
|
||||
throw new Error('OpenCode session store descriptor missing');
|
||||
}
|
||||
const manifestPath = getOpenCodeRuntimeManifestPath(
|
||||
getTeamsBasePath(),
|
||||
input.teamName,
|
||||
input.laneId
|
||||
);
|
||||
const runtimeDirectory = path.dirname(manifestPath);
|
||||
await fs.mkdir(runtimeDirectory, { recursive: true });
|
||||
const memberName = input.memberName ?? input.laneId.split(':').at(-1) ?? input.laneId;
|
||||
const writer = new RuntimeStoreBatchWriter(
|
||||
runtimeDirectory,
|
||||
createRuntimeStoreManifestStore({
|
||||
filePath: manifestPath,
|
||||
teamName: input.teamName,
|
||||
}),
|
||||
createRuntimeStoreReceiptStore({
|
||||
filePath: path.join(runtimeDirectory, 'opencode-runtime-receipts.json'),
|
||||
}),
|
||||
{
|
||||
clock: () => new Date('2026-04-23T10:00:00.000Z'),
|
||||
batchIdFactory: () => `batch-${input.teamName}-${input.laneId}`,
|
||||
receiptIdFactory: () => `receipt-${input.teamName}-${input.laneId}`,
|
||||
}
|
||||
);
|
||||
await writer.writeBatch({
|
||||
teamName: input.teamName,
|
||||
runId,
|
||||
capabilitySnapshotId: null,
|
||||
behaviorFingerprint: null,
|
||||
reason: 'launch_checkpoint',
|
||||
writes: [
|
||||
{
|
||||
descriptor,
|
||||
data: {
|
||||
sessions: [
|
||||
{
|
||||
id: input.sessionId ?? `ses-${input.teamName}-${input.laneId}`,
|
||||
teamName: input.teamName,
|
||||
memberName,
|
||||
laneId: input.laneId,
|
||||
runId,
|
||||
observedAt: '2026-04-23T10:00:00.000Z',
|
||||
source: 'runtime_bootstrap_checkin',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -17291,6 +17375,42 @@ function createMixedLiveRun(input: {
|
|||
};
|
||||
}
|
||||
|
||||
function markMixedOpenCodeLaneConfirmedForTest(run: any, memberName: string): void {
|
||||
const now = '2026-04-23T10:00:00.000Z';
|
||||
const laneId = `secondary:opencode:${memberName}`;
|
||||
const lane = run.mixedSecondaryLanes?.find((candidate: any) => candidate.laneId === laneId);
|
||||
if (!lane) {
|
||||
throw new Error(`Missing mixed OpenCode lane fixture for ${memberName}`);
|
||||
}
|
||||
lane.runId = run.runId;
|
||||
lane.state = 'active';
|
||||
lane.result = {
|
||||
runId: run.runId,
|
||||
teamName: run.teamName,
|
||||
launchPhase: 'reconciled',
|
||||
teamLaunchState: 'clean_success',
|
||||
members: {
|
||||
[memberName]: {
|
||||
memberName,
|
||||
providerId: 'opencode',
|
||||
launchState: 'confirmed_alive',
|
||||
agentToolAccepted: true,
|
||||
runtimeAlive: true,
|
||||
bootstrapConfirmed: true,
|
||||
hardFailure: false,
|
||||
sessionId: `session-${memberName}`,
|
||||
runtimePid: 10_000,
|
||||
livenessKind: 'confirmed_bootstrap',
|
||||
pidSource: 'opencode_bridge',
|
||||
diagnostics: ['fake OpenCode launch ready'],
|
||||
lastEvaluatedAt: now,
|
||||
},
|
||||
},
|
||||
warnings: [],
|
||||
diagnostics: ['fake OpenCode launch ready'],
|
||||
};
|
||||
}
|
||||
|
||||
function addGeminiPrimaryToMixedRun(run: any): void {
|
||||
const now = '2026-04-23T10:00:00.000Z';
|
||||
const reviewer = {
|
||||
|
|
|
|||
|
|
@ -167,15 +167,14 @@ describe('TeamLogSourceTracker', () => {
|
|||
await tracker.disableTracking('demo', 'change_presence');
|
||||
});
|
||||
|
||||
it('emits log-source-change when a pending root transcript becomes confirmed', async () => {
|
||||
it('emits log-source-change when a scoped root transcript appears', async () => {
|
||||
tempDir = await mkdtemp(path.join(tmpdir(), 'team-log-source-tracker-pending-root-'));
|
||||
let confirmed = false;
|
||||
|
||||
const logsFinder = {
|
||||
getLiveLogSourceWatchContext: vi.fn(async () => ({
|
||||
projectDir: tempDir!,
|
||||
sessionIds: confirmed ? ['new-runtime'] : [],
|
||||
watchSessionIds: confirmed ? ['new-runtime'] : [],
|
||||
sessionIds: ['new-runtime'],
|
||||
watchSessionIds: ['new-runtime'],
|
||||
})),
|
||||
} as unknown as TeamMemberLogsFinder;
|
||||
|
||||
|
|
@ -187,7 +186,6 @@ describe('TeamLogSourceTracker', () => {
|
|||
emitter.mockClear();
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
confirmed = true;
|
||||
await writeFile(path.join(tempDir, 'new-runtime.jsonl'), '{"seq":1}\n');
|
||||
|
||||
await vi.waitFor(() => {
|
||||
|
|
@ -327,6 +325,7 @@ describe('TeamLogSourceTracker', () => {
|
|||
|
||||
it('ignores internal ledger artifact paths but keeps freshness signals visible', () => {
|
||||
const projectDir = '/tmp/demo-project';
|
||||
const scopedSessionIds = new Set(['lead-session']);
|
||||
|
||||
expect(
|
||||
shouldIgnoreLogSourceWatcherPath(
|
||||
|
|
@ -346,5 +345,71 @@ describe('TeamLogSourceTracker', () => {
|
|||
path.join(projectDir, '.board-task-change-freshness', 'task.json')
|
||||
)
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldIgnoreLogSourceWatcherPath(
|
||||
projectDir,
|
||||
path.join(projectDir, '.board-task-log-freshness', 'task.json'),
|
||||
{ scopedSessionIds }
|
||||
)
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldIgnoreLogSourceWatcherPath(
|
||||
projectDir,
|
||||
path.join(projectDir, 'lead-session.jsonl'),
|
||||
{ scopedSessionIds }
|
||||
)
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldIgnoreLogSourceWatcherPath(projectDir, path.join(projectDir, 'old-session.jsonl'), {
|
||||
scopedSessionIds,
|
||||
})
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldIgnoreLogSourceWatcherPath(
|
||||
projectDir,
|
||||
path.join(projectDir, 'pending-session.jsonl'),
|
||||
{
|
||||
scopedSessionIds,
|
||||
pendingRootSessionIds: new Set(['pending-session']),
|
||||
}
|
||||
)
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldIgnoreLogSourceWatcherPath(projectDir, path.join(projectDir, 'lead-session'), {
|
||||
scopedSessionIds,
|
||||
})
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldIgnoreLogSourceWatcherPath(projectDir, path.join(projectDir, 'pending-session'), {
|
||||
scopedSessionIds,
|
||||
pendingRootSessionIds: new Set(['pending-session']),
|
||||
})
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldIgnoreLogSourceWatcherPath(projectDir, path.join(projectDir, 'old-session'), {
|
||||
scopedSessionIds,
|
||||
})
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldIgnoreLogSourceWatcherPath(
|
||||
projectDir,
|
||||
path.join(projectDir, 'lead-session', 'subagents', 'agent-worker.jsonl'),
|
||||
{ scopedSessionIds }
|
||||
)
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldIgnoreLogSourceWatcherPath(
|
||||
projectDir,
|
||||
path.join(projectDir, 'lead-session', 'subagents', 'agent-acompact-worker.jsonl'),
|
||||
{ scopedSessionIds }
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldIgnoreLogSourceWatcherPath(
|
||||
projectDir,
|
||||
path.join(projectDir, 'old-session', 'subagents', 'agent-worker.jsonl'),
|
||||
{ scopedSessionIds }
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -405,6 +405,24 @@ async function writeCommittedOpenCodeSessionStore(input: {
|
|||
});
|
||||
}
|
||||
|
||||
async function writeDefaultBobOpenCodeBootstrapEvidence(): Promise<void> {
|
||||
await writeCommittedOpenCodeSessionStore({
|
||||
teamName: 'team-a',
|
||||
laneId: 'secondary:opencode:bob',
|
||||
runId: 'opencode-run-bob',
|
||||
sessions: [
|
||||
{
|
||||
id: 'oc-session-bob',
|
||||
teamName: 'team-a',
|
||||
memberName: 'bob',
|
||||
laneId: 'secondary:opencode:bob',
|
||||
runId: 'opencode-run-bob',
|
||||
source: 'runtime_bootstrap_checkin',
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function createMemberSpawnStatusEntry(
|
||||
overrides: Record<string, unknown> = {}
|
||||
): Record<string, unknown> {
|
||||
|
|
@ -3944,6 +3962,7 @@ describe('TeamProvisioningService', () => {
|
|||
memberName: 'bob',
|
||||
cwd: '/repo',
|
||||
});
|
||||
await writeDefaultBobOpenCodeBootstrapEvidence();
|
||||
(svc as any).configReader = {
|
||||
getConfig: vi.fn(async () => ({
|
||||
projectPath: '/repo',
|
||||
|
|
@ -4043,6 +4062,7 @@ describe('TeamProvisioningService', () => {
|
|||
memberName: 'bob',
|
||||
cwd: '/repo',
|
||||
});
|
||||
await writeDefaultBobOpenCodeBootstrapEvidence();
|
||||
|
||||
await expect(
|
||||
svc.deliverOpenCodeMemberMessage('team-a', {
|
||||
|
|
@ -4127,6 +4147,7 @@ describe('TeamProvisioningService', () => {
|
|||
memberName: 'bob',
|
||||
cwd: '/repo/.agent-team-worktrees/bob',
|
||||
});
|
||||
await writeDefaultBobOpenCodeBootstrapEvidence();
|
||||
(svc as any).resolveCurrentOpenCodeRuntimeRunId = vi.fn(async () => 'opencode-run-bob');
|
||||
(svc as any).isOpenCodeRuntimeLaneIndexActive = vi.fn(async () => true);
|
||||
(svc as any).configReader = {
|
||||
|
|
@ -4236,6 +4257,7 @@ describe('TeamProvisioningService', () => {
|
|||
memberName: 'bob',
|
||||
cwd: '/repo',
|
||||
});
|
||||
await writeDefaultBobOpenCodeBootstrapEvidence();
|
||||
(svc as any).configReader = {
|
||||
getConfig: vi.fn(async () => ({
|
||||
projectPath: '/repo',
|
||||
|
|
@ -4373,6 +4395,7 @@ describe('TeamProvisioningService', () => {
|
|||
memberName: 'bob',
|
||||
cwd: '/repo',
|
||||
});
|
||||
await writeDefaultBobOpenCodeBootstrapEvidence();
|
||||
(svc as any).configReader = {
|
||||
getConfig: vi.fn(async () => ({
|
||||
projectPath: '/repo',
|
||||
|
|
@ -4502,6 +4525,7 @@ describe('TeamProvisioningService', () => {
|
|||
memberName: 'bob',
|
||||
cwd: '/repo',
|
||||
});
|
||||
await writeDefaultBobOpenCodeBootstrapEvidence();
|
||||
(svc as any).configReader = {
|
||||
getConfig: vi.fn(async () => ({
|
||||
projectPath: '/repo',
|
||||
|
|
@ -4590,6 +4614,7 @@ describe('TeamProvisioningService', () => {
|
|||
memberName: 'bob',
|
||||
cwd: '/repo',
|
||||
});
|
||||
await writeDefaultBobOpenCodeBootstrapEvidence();
|
||||
(svc as any).configReader = {
|
||||
getConfig: vi.fn(async () => ({
|
||||
projectPath: '/repo',
|
||||
|
|
@ -4698,6 +4723,7 @@ describe('TeamProvisioningService', () => {
|
|||
memberName: 'bob',
|
||||
cwd: '/repo',
|
||||
});
|
||||
await writeDefaultBobOpenCodeBootstrapEvidence();
|
||||
(svc as any).configReader = {
|
||||
getConfig: vi.fn(async () => ({
|
||||
projectPath: '/repo',
|
||||
|
|
@ -4781,6 +4807,7 @@ describe('TeamProvisioningService', () => {
|
|||
memberName: 'bob',
|
||||
cwd: '/repo',
|
||||
});
|
||||
await writeDefaultBobOpenCodeBootstrapEvidence();
|
||||
(svc as any).configReader = {
|
||||
getConfig: vi.fn(async () => ({
|
||||
projectPath: '/repo',
|
||||
|
|
@ -4893,6 +4920,7 @@ describe('TeamProvisioningService', () => {
|
|||
memberName: 'bob',
|
||||
cwd: '/repo',
|
||||
});
|
||||
await writeDefaultBobOpenCodeBootstrapEvidence();
|
||||
(svc as any).configReader = {
|
||||
getConfig: vi.fn(async () => ({
|
||||
projectPath: '/repo',
|
||||
|
|
@ -5348,6 +5376,7 @@ describe('TeamProvisioningService', () => {
|
|||
memberName: 'bob',
|
||||
cwd: '/repo',
|
||||
});
|
||||
await writeDefaultBobOpenCodeBootstrapEvidence();
|
||||
(svc as any).configReader = {
|
||||
getConfig: vi.fn(async () => ({
|
||||
projectPath: '/repo',
|
||||
|
|
@ -5464,6 +5493,7 @@ describe('TeamProvisioningService', () => {
|
|||
memberName: 'bob',
|
||||
cwd: '/repo',
|
||||
});
|
||||
await writeDefaultBobOpenCodeBootstrapEvidence();
|
||||
(svc as any).configReader = {
|
||||
getConfig: vi.fn(async () => ({
|
||||
projectPath: '/repo',
|
||||
|
|
@ -5589,6 +5619,7 @@ describe('TeamProvisioningService', () => {
|
|||
memberName: 'bob',
|
||||
cwd: '/repo',
|
||||
});
|
||||
await writeDefaultBobOpenCodeBootstrapEvidence();
|
||||
(svc as any).configReader = {
|
||||
getConfig: vi.fn(async () => ({
|
||||
projectPath: '/repo',
|
||||
|
|
@ -5694,6 +5725,7 @@ describe('TeamProvisioningService', () => {
|
|||
memberName: 'bob',
|
||||
cwd: '/repo',
|
||||
});
|
||||
await writeDefaultBobOpenCodeBootstrapEvidence();
|
||||
(svc as any).configReader = {
|
||||
getConfig: vi.fn(async () => ({
|
||||
projectPath: '/repo',
|
||||
|
|
@ -5841,6 +5873,7 @@ describe('TeamProvisioningService', () => {
|
|||
memberName: 'bob',
|
||||
cwd: '/repo',
|
||||
});
|
||||
await writeDefaultBobOpenCodeBootstrapEvidence();
|
||||
(svc as any).configReader = {
|
||||
getConfig: vi.fn(async () => ({
|
||||
projectPath: '/repo',
|
||||
|
|
@ -5964,6 +5997,7 @@ describe('TeamProvisioningService', () => {
|
|||
memberName: 'bob',
|
||||
cwd: '/repo',
|
||||
});
|
||||
await writeDefaultBobOpenCodeBootstrapEvidence();
|
||||
(svc as any).configReader = {
|
||||
getConfig: vi.fn(async () => ({
|
||||
projectPath: '/repo',
|
||||
|
|
@ -6102,25 +6136,21 @@ describe('TeamProvisioningService', () => {
|
|||
laneId,
|
||||
state: 'active',
|
||||
});
|
||||
const manifestPath = getOpenCodeRuntimeManifestPath(tempTeamsBase, teamName, laneId);
|
||||
await fsPromises.mkdir(path.dirname(manifestPath), { recursive: true });
|
||||
await fsPromises.writeFile(
|
||||
manifestPath,
|
||||
`${JSON.stringify(
|
||||
await writeCommittedOpenCodeSessionStore({
|
||||
teamName,
|
||||
laneId,
|
||||
runId: 'opencode-run-durable',
|
||||
sessions: [
|
||||
{
|
||||
...createDefaultRuntimeStoreManifest(teamName, '2026-04-22T12:00:00.000Z'),
|
||||
activeRunId: 'opencode-run-durable',
|
||||
id: 'oc-session-bob',
|
||||
teamName,
|
||||
memberName: 'bob',
|
||||
laneId,
|
||||
runId: 'opencode-run-durable',
|
||||
source: 'runtime_bootstrap_checkin',
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}\n`,
|
||||
'utf8'
|
||||
);
|
||||
await fsPromises.writeFile(
|
||||
path.join(path.dirname(manifestPath), 'opencode-sessions.json'),
|
||||
`${JSON.stringify({ sessions: [{ id: 'oc-session-bob' }] })}\n`,
|
||||
'utf8'
|
||||
);
|
||||
],
|
||||
});
|
||||
|
||||
await expect(
|
||||
svc.deliverOpenCodeMemberMessage(teamName, {
|
||||
|
|
@ -6145,6 +6175,85 @@ describe('TeamProvisioningService', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('blocks OpenCode secondary delivery when runtime session exists but bootstrap did not check in', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const teamName = 'team-a';
|
||||
const laneId = 'secondary:opencode:bob';
|
||||
const sendMessageToMember = vi.fn(async (input: Record<string, unknown>) => ({
|
||||
ok: true,
|
||||
providerId: 'opencode',
|
||||
memberName: String(input.memberName),
|
||||
sessionId: 'oc-session-bob',
|
||||
diagnostics: [],
|
||||
}));
|
||||
svc.setRuntimeAdapterRegistry(
|
||||
new TeamRuntimeAdapterRegistry([
|
||||
{
|
||||
providerId: 'opencode',
|
||||
prepare: vi.fn(),
|
||||
launch: vi.fn(),
|
||||
reconcile: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
sendMessageToMember,
|
||||
} as any,
|
||||
])
|
||||
);
|
||||
(svc as any).configReader = {
|
||||
getConfig: vi.fn(async () => ({
|
||||
projectPath: '/repo',
|
||||
members: [
|
||||
{ name: 'team-lead', providerId: 'codex', model: 'gpt-5.4' },
|
||||
{ name: 'bob', providerId: 'opencode', model: 'minimax-m2.5-free' },
|
||||
],
|
||||
})),
|
||||
};
|
||||
(svc as any).teamMetaStore = {
|
||||
getMeta: vi.fn(async () => ({
|
||||
launchIdentity: { providerId: 'codex' },
|
||||
providerId: 'codex',
|
||||
})),
|
||||
};
|
||||
(svc as any).membersMetaStore = {
|
||||
getMembers: vi.fn(async () => [
|
||||
{ name: 'bob', providerId: 'opencode', model: 'opencode/minimax-m2.5-free' },
|
||||
]),
|
||||
};
|
||||
await upsertOpenCodeRuntimeLaneIndexEntry({
|
||||
teamsBasePath: tempTeamsBase,
|
||||
teamName,
|
||||
laneId,
|
||||
state: 'active',
|
||||
});
|
||||
await writeCommittedOpenCodeSessionStore({
|
||||
teamName,
|
||||
laneId,
|
||||
runId: 'opencode-run-pending-bootstrap',
|
||||
sessions: [],
|
||||
});
|
||||
|
||||
await expect(
|
||||
svc.deliverOpenCodeMemberMessage(teamName, {
|
||||
memberName: 'bob',
|
||||
text: 'must wait for bootstrap',
|
||||
messageId: 'msg-before-bootstrap-checkin',
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
delivered: false,
|
||||
reason: 'opencode_runtime_not_active',
|
||||
diagnostics: [
|
||||
expect.stringContaining('OpenCode runtime bootstrap is not confirmed for bob'),
|
||||
],
|
||||
});
|
||||
expect(sendMessageToMember).not.toHaveBeenCalled();
|
||||
await expect(readOpenCodeRuntimeLaneIndex(tempTeamsBase, teamName)).resolves.toMatchObject({
|
||||
lanes: {
|
||||
[laneId]: {
|
||||
state: 'active',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects stale active lane manifest without runtime evidence before delivery', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const teamName = 'team-a';
|
||||
|
|
@ -6385,25 +6494,21 @@ describe('TeamProvisioningService', () => {
|
|||
laneId,
|
||||
state: 'active',
|
||||
});
|
||||
const manifestPath = getOpenCodeRuntimeManifestPath(tempTeamsBase, teamName, laneId);
|
||||
await fsPromises.mkdir(path.dirname(manifestPath), { recursive: true });
|
||||
await fsPromises.writeFile(
|
||||
manifestPath,
|
||||
`${JSON.stringify(
|
||||
await writeCommittedOpenCodeSessionStore({
|
||||
teamName,
|
||||
laneId,
|
||||
runId: 'opencode-run-from-manifest',
|
||||
sessions: [
|
||||
{
|
||||
...createDefaultRuntimeStoreManifest(teamName, '2026-04-22T12:00:00.000Z'),
|
||||
activeRunId: 'opencode-run-from-manifest',
|
||||
id: 'oc-session-bob',
|
||||
teamName,
|
||||
memberName: 'bob',
|
||||
laneId,
|
||||
runId: 'opencode-run-from-manifest',
|
||||
source: 'runtime_bootstrap_checkin',
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}\n`,
|
||||
'utf8'
|
||||
);
|
||||
await fsPromises.writeFile(
|
||||
path.join(path.dirname(manifestPath), 'opencode-sessions.json'),
|
||||
`${JSON.stringify({ sessions: [{ id: 'oc-session-bob' }] })}\n`,
|
||||
'utf8'
|
||||
);
|
||||
],
|
||||
});
|
||||
|
||||
await expect(
|
||||
svc.deliverOpenCodeMemberMessage(teamName, {
|
||||
|
|
@ -6568,7 +6673,111 @@ describe('TeamProvisioningService', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('marks an OpenCode secondary lane degraded when launch fails after runtime materializes', async () => {
|
||||
it('does not keep an OpenCode secondary lane active from prompt acceptance without runtime evidence', async () => {
|
||||
const teamName = 'mixed-accepted-without-runtime-evidence';
|
||||
const svc = new TeamProvisioningService();
|
||||
const adapterLaunch = vi.fn(async (input: Record<string, unknown>) => ({
|
||||
runId: String(input.runId),
|
||||
teamName: String(input.teamName),
|
||||
launchPhase: 'finished',
|
||||
teamLaunchState: 'partial_failure',
|
||||
members: {
|
||||
tom: {
|
||||
memberName: 'tom',
|
||||
providerId: 'opencode',
|
||||
launchState: 'failed_to_start',
|
||||
agentToolAccepted: true,
|
||||
runtimeAlive: false,
|
||||
bootstrapConfirmed: false,
|
||||
hardFailure: true,
|
||||
hardFailureReason: 'OpenCode bridge reported member launch failure',
|
||||
diagnostics: ['runtime_bootstrap_checkin failed: MCP Not connected'],
|
||||
},
|
||||
},
|
||||
warnings: [],
|
||||
diagnostics: ['OpenCode bridge reported member launch failure'],
|
||||
}));
|
||||
svc.setRuntimeAdapterRegistry(
|
||||
new TeamRuntimeAdapterRegistry([
|
||||
{
|
||||
providerId: 'opencode',
|
||||
prepare: vi.fn(),
|
||||
launch: adapterLaunch,
|
||||
reconcile: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
} as any,
|
||||
])
|
||||
);
|
||||
(svc as any).launchStateStore = {
|
||||
read: vi.fn(async () => null),
|
||||
write: vi.fn(async () => {}),
|
||||
clear: vi.fn(async () => {}),
|
||||
};
|
||||
|
||||
const run = createMemberSpawnRun({
|
||||
teamName,
|
||||
expectedMembers: ['bob'],
|
||||
});
|
||||
run.isLaunch = true;
|
||||
run.request = {
|
||||
teamName,
|
||||
cwd: '/tmp/mixed-accepted-without-runtime-evidence',
|
||||
providerId: 'codex',
|
||||
model: 'gpt-5.4',
|
||||
effort: 'high',
|
||||
skipPermissions: true,
|
||||
};
|
||||
run.effectiveMembers = [
|
||||
{
|
||||
name: 'bob',
|
||||
role: 'Developer',
|
||||
providerId: 'codex',
|
||||
model: 'gpt-5.4',
|
||||
effort: 'high',
|
||||
},
|
||||
];
|
||||
run.mixedSecondaryLanes = [
|
||||
{
|
||||
laneId: 'secondary:opencode:tom',
|
||||
providerId: 'opencode',
|
||||
member: {
|
||||
name: 'tom',
|
||||
role: 'Developer',
|
||||
providerId: 'opencode',
|
||||
model: 'minimax-m2.5-free',
|
||||
effort: 'medium',
|
||||
},
|
||||
runId: null,
|
||||
state: 'queued',
|
||||
result: null,
|
||||
warnings: [],
|
||||
diagnostics: [],
|
||||
},
|
||||
];
|
||||
|
||||
await (svc as any).launchMixedSecondaryLaneIfNeeded(run);
|
||||
await vi.waitFor(
|
||||
async () => {
|
||||
await expect(readOpenCodeRuntimeLaneIndex(tempTeamsBase, teamName)).resolves.toMatchObject({
|
||||
lanes: {
|
||||
'secondary:opencode:tom': {
|
||||
state: 'degraded',
|
||||
diagnostics: expect.not.arrayContaining([
|
||||
'opencode_bootstrap_pending_after_materialized_session',
|
||||
]),
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(run.mixedSecondaryLanes?.[0]?.result?.members.tom).toMatchObject({
|
||||
launchState: 'failed_to_start',
|
||||
hardFailure: true,
|
||||
});
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps an OpenCode secondary lane active when bootstrap is pending after runtime materializes', async () => {
|
||||
const teamName = 'mixed-runtime-materialized-failure';
|
||||
const svc = new TeamProvisioningService();
|
||||
const adapterLaunch = vi.fn(async (input: Record<string, unknown>) => ({
|
||||
|
|
@ -6660,17 +6869,36 @@ describe('TeamProvisioningService', () => {
|
|||
await vi.waitFor(
|
||||
async () => {
|
||||
expect(adapterLaunch).toHaveBeenCalledTimes(1);
|
||||
const launchInput = adapterLaunch.mock.calls[0]?.[0] as { runId?: string } | undefined;
|
||||
await expect(
|
||||
new OpenCodeRuntimeManifestEvidenceReader({ teamsBasePath: tempTeamsBase }).read(
|
||||
teamName,
|
||||
'secondary:opencode:tom'
|
||||
)
|
||||
).resolves.toMatchObject({
|
||||
activeRunId: launchInput?.runId,
|
||||
});
|
||||
await expect(readOpenCodeRuntimeLaneIndex(tempTeamsBase, teamName)).resolves.toMatchObject({
|
||||
lanes: {
|
||||
'secondary:opencode:tom': {
|
||||
state: 'degraded',
|
||||
state: 'active',
|
||||
diagnostics: expect.arrayContaining([
|
||||
'OpenCode bridge reported member launch failure',
|
||||
'OpenCode bootstrap MCP did not complete required tools before assistant response: runtime_bootstrap_checkin, member_briefing',
|
||||
'opencode_bootstrap_pending_after_materialized_session',
|
||||
]),
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(run.mixedSecondaryLanes?.[0]?.result?.members.tom).toMatchObject({
|
||||
launchState: 'runtime_pending_bootstrap',
|
||||
agentToolAccepted: true,
|
||||
runtimeAlive: true,
|
||||
bootstrapConfirmed: false,
|
||||
hardFailure: false,
|
||||
livenessKind: 'runtime_process',
|
||||
});
|
||||
expect(run.mixedSecondaryLanes?.[0]?.result?.teamLaunchState).toBe('partial_pending');
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
|
|
@ -11960,7 +12188,7 @@ describe('TeamProvisioningService', () => {
|
|||
launchState: 'confirmed_alive',
|
||||
agentToolAccepted: true,
|
||||
bootstrapConfirmed: true,
|
||||
runtimeAlive: false,
|
||||
runtimeAlive: true,
|
||||
});
|
||||
const persisted = JSON.parse(
|
||||
await fsPromises.readFile(getTeamLaunchStatePath(teamName), 'utf8')
|
||||
|
|
@ -11968,7 +12196,7 @@ describe('TeamProvisioningService', () => {
|
|||
expect(persisted.members.tom).toMatchObject({
|
||||
launchState: 'confirmed_alive',
|
||||
bootstrapConfirmed: true,
|
||||
runtimeAlive: false,
|
||||
runtimeAlive: true,
|
||||
runtimeSessionId: 'ses-tom',
|
||||
});
|
||||
});
|
||||
|
|
@ -12051,7 +12279,7 @@ describe('TeamProvisioningService', () => {
|
|||
expect(persisted.members.tom).toMatchObject({
|
||||
launchState: 'confirmed_alive',
|
||||
bootstrapConfirmed: true,
|
||||
runtimeAlive: false,
|
||||
runtimeAlive: true,
|
||||
runtimeSessionId: 'ses-tom',
|
||||
});
|
||||
expect(persisted.teamLaunchState).toBe('clean_success');
|
||||
|
|
@ -12072,7 +12300,7 @@ describe('TeamProvisioningService', () => {
|
|||
expect(persistedAfterMissingWrite.members.tom).toMatchObject({
|
||||
launchState: 'confirmed_alive',
|
||||
bootstrapConfirmed: true,
|
||||
runtimeAlive: false,
|
||||
runtimeAlive: true,
|
||||
runtimeSessionId: 'ses-tom',
|
||||
});
|
||||
expect(persistedAfterMissingWrite.teamLaunchState).toBe('clean_success');
|
||||
|
|
|
|||
|
|
@ -172,6 +172,36 @@ describe('resolveTeamMemberRuntimeLiveness', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('does not let a reused OpenCode runtime pid downgrade committed bootstrap evidence', () => {
|
||||
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: 'node unrelated-worker.js' }],
|
||||
processTableAvailable: true,
|
||||
nowIso: NOW,
|
||||
});
|
||||
|
||||
expect(result.alive).toBe(true);
|
||||
expect(result.livenessKind).toBe('confirmed_bootstrap');
|
||||
expect(result.pidSource).toBe('runtime_bootstrap');
|
||||
expect(result.pid).toBeUndefined();
|
||||
expect(result.diagnostics).toContain(
|
||||
'bootstrap confirmed despite runtime pid identity mismatch'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not trust a stale persisted pid without current process identity', () => {
|
||||
const result = resolveTeamMemberRuntimeLiveness({
|
||||
teamName: 'demo',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,53 @@
|
|||
import React, { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { LiveRuntimeStatusSection } from '@renderer/components/team/LiveRuntimeStatusSection';
|
||||
|
||||
describe('LiveRuntimeStatusSection', () => {
|
||||
let host: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
root = createRoot(host);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
document.body.innerHTML = '';
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('renders display-only runtime rows without process actions', async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<LiveRuntimeStatusSection
|
||||
rows={[
|
||||
{
|
||||
memberName: 'alice',
|
||||
state: 'running',
|
||||
stateReason: 'Runtime heartbeat is alive',
|
||||
source: 'runtime',
|
||||
runtimeModel: 'claude-sonnet-4.5',
|
||||
pidLabel: 'runtime pid 1234',
|
||||
actionsAllowed: false,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('Live runtime status');
|
||||
expect(host.textContent).toContain('Display-only heartbeat and launch state');
|
||||
expect(host.textContent).toContain('alice');
|
||||
expect(host.textContent).toContain('runtime pid 1234');
|
||||
expect(host.textContent).not.toContain('Kill');
|
||||
expect(host.textContent).not.toContain('Open');
|
||||
expect(host.querySelectorAll('button')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
190
test/renderer/components/team/teamRuntimeDisplayRows.test.ts
Normal file
190
test/renderer/components/team/teamRuntimeDisplayRows.test.ts
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildTeamRuntimeDisplayRows } from '@renderer/components/team/teamRuntimeDisplayRows';
|
||||
|
||||
import type {
|
||||
MemberSpawnStatusEntry,
|
||||
TeamAgentRuntimeEntry,
|
||||
TeamAgentRuntimeSnapshot,
|
||||
} from '@shared/types';
|
||||
|
||||
const members = [{ name: 'alice' }, { name: 'bob' }];
|
||||
|
||||
function createRuntimeEntry(overrides: Partial<TeamAgentRuntimeEntry> = {}): TeamAgentRuntimeEntry {
|
||||
return {
|
||||
memberName: 'alice',
|
||||
alive: true,
|
||||
restartable: true,
|
||||
updatedAt: '2026-05-03T10:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createRuntimeSnapshot(
|
||||
membersByName: Record<string, TeamAgentRuntimeEntry>
|
||||
): TeamAgentRuntimeSnapshot {
|
||||
return {
|
||||
teamName: 'my-team',
|
||||
updatedAt: '2026-05-03T10:00:00.000Z',
|
||||
runId: 'run-1',
|
||||
members: membersByName,
|
||||
};
|
||||
}
|
||||
|
||||
function createSpawnStatus(overrides: Partial<MemberSpawnStatusEntry> = {}): MemberSpawnStatusEntry {
|
||||
return {
|
||||
status: 'spawning',
|
||||
launchState: 'starting',
|
||||
updatedAt: '2026-05-03T10:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('buildTeamRuntimeDisplayRows', () => {
|
||||
it('maps alive runtime entries to running display rows', () => {
|
||||
const rows = buildTeamRuntimeDisplayRows({
|
||||
members,
|
||||
runtimeSnapshot: createRuntimeSnapshot({
|
||||
alice: createRuntimeEntry({ runtimeModel: 'claude-sonnet-4.5', runtimePid: 1234 }),
|
||||
}),
|
||||
});
|
||||
|
||||
expect(rows[0]).toMatchObject({
|
||||
memberName: 'alice',
|
||||
state: 'running',
|
||||
source: 'runtime',
|
||||
runtimeModel: 'claude-sonnet-4.5',
|
||||
pidLabel: 'runtime pid 1234',
|
||||
actionsAllowed: false,
|
||||
});
|
||||
expect(rows[1]).toMatchObject({
|
||||
memberName: 'bob',
|
||||
state: 'unknown',
|
||||
actionsAllowed: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not treat historical bootstrap as running when runtime is not alive', () => {
|
||||
const rows = buildTeamRuntimeDisplayRows({
|
||||
members: [{ name: 'alice' }],
|
||||
runtimeSnapshot: createRuntimeSnapshot({
|
||||
alice: createRuntimeEntry({
|
||||
alive: false,
|
||||
historicalBootstrapConfirmed: true,
|
||||
runtimeDiagnostic: 'Runtime heartbeat is stale',
|
||||
}),
|
||||
}),
|
||||
spawnStatuses: {
|
||||
alice: createSpawnStatus({
|
||||
status: 'online',
|
||||
launchState: 'confirmed_alive',
|
||||
bootstrapConfirmed: true,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
expect(rows[0]).toMatchObject({
|
||||
memberName: 'alice',
|
||||
state: 'stopped',
|
||||
source: 'mixed',
|
||||
stateReason: 'Runtime heartbeat is stale',
|
||||
actionsAllowed: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('maps a non-alive runtime with error diagnostics to degraded', () => {
|
||||
const rows = buildTeamRuntimeDisplayRows({
|
||||
members: [{ name: 'alice' }],
|
||||
runtimeSnapshot: createRuntimeSnapshot({
|
||||
alice: createRuntimeEntry({
|
||||
alive: false,
|
||||
runtimeDiagnostic: 'Runtime process crashed',
|
||||
runtimeDiagnosticSeverity: 'error',
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
expect(rows[0]).toMatchObject({
|
||||
memberName: 'alice',
|
||||
state: 'degraded',
|
||||
stateReason: 'Runtime process crashed',
|
||||
actionsAllowed: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('degrades mixed rows when runtime is alive but spawn evidence has failed', () => {
|
||||
const rows = buildTeamRuntimeDisplayRows({
|
||||
members: [{ name: 'alice' }],
|
||||
runtimeSnapshot: createRuntimeSnapshot({
|
||||
alice: createRuntimeEntry({
|
||||
alive: true,
|
||||
runtimeDiagnostic: 'Runtime heartbeat is alive',
|
||||
}),
|
||||
}),
|
||||
spawnStatuses: {
|
||||
alice: createSpawnStatus({
|
||||
status: 'error',
|
||||
launchState: 'failed_to_start',
|
||||
hardFailure: true,
|
||||
hardFailureReason: 'Bootstrap command failed',
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
expect(rows[0]).toMatchObject({
|
||||
memberName: 'alice',
|
||||
state: 'degraded',
|
||||
source: 'mixed',
|
||||
stateReason: 'Bootstrap command failed',
|
||||
actionsAllowed: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses explicit spawn status handling without promoting unknown statuses to running', () => {
|
||||
const rows = buildTeamRuntimeDisplayRows({
|
||||
members: [{ name: 'alice' }, { name: 'bob' }, { name: 'carol' }],
|
||||
spawnStatuses: {
|
||||
alice: createSpawnStatus({ status: 'spawning' }),
|
||||
bob: createSpawnStatus({
|
||||
status: 'online',
|
||||
launchState: 'confirmed_alive',
|
||||
runtimeAlive: true,
|
||||
}),
|
||||
carol: createSpawnStatus({ status: 'surprising-new-status' as never }),
|
||||
},
|
||||
});
|
||||
|
||||
expect(rows.map((row) => [row.memberName, row.state])).toEqual([
|
||||
['alice', 'starting'],
|
||||
['bob', 'running'],
|
||||
['carol', 'unknown'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('chooses the latest runtime entry when multiple lanes map to one member', () => {
|
||||
const rows = buildTeamRuntimeDisplayRows({
|
||||
members: [{ name: 'alice' }],
|
||||
runtimeSnapshot: createRuntimeSnapshot({
|
||||
'alice-primary': createRuntimeEntry({
|
||||
memberName: 'alice',
|
||||
alive: false,
|
||||
laneKind: 'primary',
|
||||
updatedAt: '2026-05-03T10:00:00.000Z',
|
||||
}),
|
||||
'alice-secondary': createRuntimeEntry({
|
||||
memberName: 'alice',
|
||||
alive: true,
|
||||
laneKind: 'secondary',
|
||||
updatedAt: '2026-05-03T10:01:00.000Z',
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
expect(rows[0]).toMatchObject({
|
||||
memberName: 'alice',
|
||||
state: 'running',
|
||||
laneKind: 'secondary',
|
||||
actionsAllowed: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -2,7 +2,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
onTeamChangeCb: null as
|
||||
| ((event: unknown, data: { type?: string; teamName: string; detail?: string }) => void)
|
||||
| ((
|
||||
event: unknown,
|
||||
data: { type?: string; teamName: string; detail?: string; runId?: string }
|
||||
) => void)
|
||||
| null,
|
||||
onProvisioningProgressCb: null as
|
||||
| ((event: unknown, data: { runId: string; teamName: string }) => void)
|
||||
|
|
@ -35,7 +38,10 @@ vi.mock('@renderer/api', () => ({
|
|||
setToolActivityTracking: vi.fn(async () => undefined),
|
||||
onTeamChange: vi.fn(
|
||||
(
|
||||
cb: (event: unknown, data: { teamName: string; type?: string; detail?: string }) => void
|
||||
cb: (
|
||||
event: unknown,
|
||||
data: { teamName: string; type?: string; detail?: string; runId?: string }
|
||||
) => void
|
||||
): (() => void) => {
|
||||
hoisted.onTeamChangeCb = cb;
|
||||
return () => {
|
||||
|
|
@ -66,6 +72,7 @@ import { __resetTeamSliceModuleStateForTests } from '../../../src/renderer/store
|
|||
import {
|
||||
__resetTeamRefreshFanoutDiagnosticsForTests,
|
||||
getTeamRefreshFanoutSnapshotForTests,
|
||||
summarizeTeamRefreshFanout,
|
||||
type TeamRefreshFanoutSnapshot,
|
||||
} from '../../../src/renderer/store/teamRefreshFanoutDiagnostics';
|
||||
import { api } from '@renderer/api';
|
||||
|
|
@ -79,6 +86,7 @@ describe('team change throttling', () => {
|
|||
__resetTeamRefreshFanoutDiagnosticsForTests();
|
||||
const fetchTeams = vi.fn(async () => undefined);
|
||||
const fetchMemberSpawnStatuses = vi.fn(async () => undefined);
|
||||
const fetchTeamAgentRuntime = vi.fn(async () => undefined);
|
||||
const refreshTeamData = vi.fn(async () => undefined);
|
||||
const refreshTeamMessagesHead = vi.fn(async () => ({
|
||||
feedChanged: true,
|
||||
|
|
@ -91,6 +99,7 @@ describe('team change throttling', () => {
|
|||
useStore.setState({
|
||||
fetchTeams,
|
||||
fetchMemberSpawnStatuses,
|
||||
fetchTeamAgentRuntime,
|
||||
refreshTeamData,
|
||||
refreshTeamMessagesHead,
|
||||
refreshMemberActivityMeta,
|
||||
|
|
@ -124,6 +133,7 @@ describe('team change throttling', () => {
|
|||
cleanup = null;
|
||||
__resetTeamSliceModuleStateForTests();
|
||||
__resetTeamRefreshFanoutDiagnosticsForTests();
|
||||
window.localStorage.removeItem('team:processLiteFanout');
|
||||
vi.mocked(console.warn).mockClear();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
|
@ -192,6 +202,429 @@ describe('team change throttling', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('uses process-lite for strict candidates and delays structural reconcile', async () => {
|
||||
useStore.setState({
|
||||
selectedTeamName: 'my-team',
|
||||
selectedTeamData: {
|
||||
teamName: 'my-team',
|
||||
config: { name: 'My Team', members: [], projectPath: '/repo' },
|
||||
tasks: [],
|
||||
members: [],
|
||||
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
||||
processes: [],
|
||||
},
|
||||
currentRuntimeRunIdByTeam: { 'my-team': 'run-1' },
|
||||
} as never);
|
||||
|
||||
const state = useStore.getState();
|
||||
const fetchTeamsSpy = vi.spyOn(state, 'fetchTeams');
|
||||
const fetchMemberSpawnStatusesSpy = vi.spyOn(state, 'fetchMemberSpawnStatuses');
|
||||
const fetchTeamAgentRuntimeSpy = vi.spyOn(state, 'fetchTeamAgentRuntime');
|
||||
const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData');
|
||||
|
||||
hoisted.onTeamChangeCb?.(
|
||||
{},
|
||||
{ type: 'process', teamName: 'my-team', detail: 'processes.json' }
|
||||
);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
expect(fetchMemberSpawnStatusesSpy).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMemberSpawnStatusesSpy).toHaveBeenCalledWith('my-team');
|
||||
expect(fetchTeamAgentRuntimeSpy).toHaveBeenCalledTimes(1);
|
||||
expect(fetchTeamAgentRuntimeSpy).toHaveBeenCalledWith('my-team');
|
||||
expect(refreshTeamDataSpy).not.toHaveBeenCalled();
|
||||
expect(fetchTeamsSpy).not.toHaveBeenCalled();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1999);
|
||||
expect(refreshTeamDataSpy).not.toHaveBeenCalled();
|
||||
expect(fetchTeamsSpy).not.toHaveBeenCalled();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(fetchTeamsSpy).toHaveBeenCalledTimes(1);
|
||||
expect(refreshTeamDataSpy).toHaveBeenCalledTimes(1);
|
||||
expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team', { withDedup: true });
|
||||
|
||||
const summary = summarizeTeamRefreshFanout('my-team');
|
||||
expect(summary.rows).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
reason: 'dry-run:process-lite:processes-json-visible-runtime-context',
|
||||
operation: 'wouldUseProcessLite',
|
||||
phase: 'skipped',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
reason: 'event:process-lite:structural-suppressed',
|
||||
operation: 'refreshTeamData',
|
||||
phase: 'skipped',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
reason: 'event:process-lite',
|
||||
operation: 'fetchMemberSpawnStatuses',
|
||||
phase: 'executed',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
reason: 'event:process-lite',
|
||||
operation: 'fetchTeamAgentRuntime',
|
||||
phase: 'executed',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
reason: 'event:process-lite:structural-reconcile',
|
||||
operation: 'refreshTeamData',
|
||||
phase: 'executed',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
reason: 'event:process-lite:structural-reconcile',
|
||||
operation: 'fetchTeams',
|
||||
phase: 'executed',
|
||||
}),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('uses process-lite when an active provisioning run exists without current runtime', async () => {
|
||||
useStore.setState({
|
||||
selectedTeamName: 'my-team',
|
||||
selectedTeamData: {
|
||||
teamName: 'my-team',
|
||||
config: { name: 'My Team', members: [], projectPath: '/repo' },
|
||||
tasks: [],
|
||||
members: [],
|
||||
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
||||
processes: [],
|
||||
},
|
||||
currentProvisioningRunIdByTeam: { 'my-team': 'run-1' },
|
||||
provisioningRuns: {
|
||||
'run-1': {
|
||||
runId: 'run-1',
|
||||
teamName: 'my-team',
|
||||
state: 'spawning',
|
||||
message: 'Spawning',
|
||||
startedAt: '2026-05-03T00:00:00.000Z',
|
||||
updatedAt: '2026-05-03T00:00:00.000Z',
|
||||
},
|
||||
},
|
||||
currentRuntimeRunIdByTeam: {},
|
||||
} as never);
|
||||
|
||||
const state = useStore.getState();
|
||||
const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData');
|
||||
const fetchMemberSpawnStatusesSpy = vi.spyOn(state, 'fetchMemberSpawnStatuses');
|
||||
|
||||
hoisted.onTeamChangeCb?.(
|
||||
{},
|
||||
{ type: 'process', teamName: 'my-team', detail: 'processes.json' }
|
||||
);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
expect(fetchMemberSpawnStatusesSpy).toHaveBeenCalledWith('my-team');
|
||||
expect(refreshTeamDataSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not treat terminal or unknown provisioning states as process-lite active', async () => {
|
||||
useStore.setState({
|
||||
selectedTeamName: 'my-team',
|
||||
selectedTeamData: {
|
||||
teamName: 'my-team',
|
||||
config: { name: 'My Team', members: [], projectPath: '/repo' },
|
||||
tasks: [],
|
||||
members: [],
|
||||
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
||||
processes: [],
|
||||
},
|
||||
currentProvisioningRunIdByTeam: { 'my-team': 'run-1' },
|
||||
provisioningRuns: {
|
||||
'run-1': {
|
||||
runId: 'run-1',
|
||||
teamName: 'my-team',
|
||||
state: 'ready',
|
||||
message: 'Ready',
|
||||
startedAt: '2026-05-03T00:00:00.000Z',
|
||||
updatedAt: '2026-05-03T00:00:00.000Z',
|
||||
},
|
||||
},
|
||||
currentRuntimeRunIdByTeam: {},
|
||||
} as never);
|
||||
|
||||
const refreshTeamDataSpy = vi.spyOn(useStore.getState(), 'refreshTeamData');
|
||||
|
||||
hoisted.onTeamChangeCb?.(
|
||||
{},
|
||||
{ type: 'process', teamName: 'my-team', detail: 'processes.json' }
|
||||
);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(800);
|
||||
expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team', { withDedup: true });
|
||||
});
|
||||
|
||||
it('keeps strict process candidates on the structural path when process-lite is disabled', async () => {
|
||||
window.localStorage.setItem('team:processLiteFanout', '0');
|
||||
useStore.setState({
|
||||
selectedTeamName: 'my-team',
|
||||
selectedTeamData: {
|
||||
teamName: 'my-team',
|
||||
config: { name: 'My Team', members: [], projectPath: '/repo' },
|
||||
tasks: [],
|
||||
members: [],
|
||||
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
||||
processes: [],
|
||||
},
|
||||
currentRuntimeRunIdByTeam: { 'my-team': 'run-1' },
|
||||
} as never);
|
||||
|
||||
const state = useStore.getState();
|
||||
const fetchTeamsSpy = vi.spyOn(state, 'fetchTeams');
|
||||
const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData');
|
||||
const fetchTeamAgentRuntimeSpy = vi.spyOn(state, 'fetchTeamAgentRuntime');
|
||||
|
||||
hoisted.onTeamChangeCb?.(
|
||||
{},
|
||||
{ type: 'process', teamName: 'my-team', detail: 'processes.json' }
|
||||
);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(800);
|
||||
expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team', { withDedup: true });
|
||||
expect(fetchTeamAgentRuntimeSpy).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(1200);
|
||||
expect(fetchTeamsSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
const summary = summarizeTeamRefreshFanout('my-team');
|
||||
expect(summary.rows).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
reason: 'event:process-lite:disabled',
|
||||
operation: 'wouldKeepStructuralProcess',
|
||||
phase: 'skipped',
|
||||
}),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('coalesces process-lite structural reconcile until idle or max wait', async () => {
|
||||
useStore.setState({
|
||||
selectedTeamName: 'my-team',
|
||||
selectedTeamData: {
|
||||
teamName: 'my-team',
|
||||
config: { name: 'My Team', members: [], projectPath: '/repo' },
|
||||
tasks: [],
|
||||
members: [],
|
||||
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
||||
processes: [],
|
||||
},
|
||||
currentRuntimeRunIdByTeam: { 'my-team': 'run-1' },
|
||||
} as never);
|
||||
|
||||
const state = useStore.getState();
|
||||
const fetchTeamsSpy = vi.spyOn(state, 'fetchTeams');
|
||||
const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData');
|
||||
|
||||
hoisted.onTeamChangeCb?.(
|
||||
{},
|
||||
{ type: 'process', teamName: 'my-team', detail: 'processes.json' }
|
||||
);
|
||||
for (let elapsed = 2_000; elapsed <= 14_000; elapsed += 2_000) {
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
hoisted.onTeamChangeCb?.(
|
||||
{},
|
||||
{ type: 'process', teamName: 'my-team', detail: 'processes.json' }
|
||||
);
|
||||
expect(refreshTeamDataSpy).not.toHaveBeenCalled();
|
||||
expect(fetchTeamsSpy).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
await vi.advanceTimersByTimeAsync(999);
|
||||
expect(refreshTeamDataSpy).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(fetchTeamsSpy).toHaveBeenCalledTimes(1);
|
||||
expect(refreshTeamDataSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('cancels pending process-lite reconcile when a normal structural event wins', async () => {
|
||||
useStore.setState({
|
||||
selectedTeamName: 'my-team',
|
||||
selectedTeamData: {
|
||||
teamName: 'my-team',
|
||||
config: { name: 'My Team', members: [], projectPath: '/repo' },
|
||||
tasks: [],
|
||||
members: [],
|
||||
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
||||
processes: [],
|
||||
},
|
||||
currentRuntimeRunIdByTeam: { 'my-team': 'run-1' },
|
||||
} as never);
|
||||
|
||||
const state = useStore.getState();
|
||||
const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData');
|
||||
|
||||
hoisted.onTeamChangeCb?.(
|
||||
{},
|
||||
{ type: 'process', teamName: 'my-team', detail: 'processes.json' }
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
hoisted.onTeamChangeCb?.({}, { type: 'task', teamName: 'my-team' });
|
||||
|
||||
await vi.advanceTimersByTimeAsync(800);
|
||||
expect(refreshTeamDataSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(15_000);
|
||||
expect(refreshTeamDataSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not let process-lite coalescing weaken member-spawn runtime refresh semantics', async () => {
|
||||
useStore.setState({
|
||||
selectedTeamName: 'my-team',
|
||||
selectedTeamData: {
|
||||
teamName: 'my-team',
|
||||
config: { name: 'My Team', members: [], projectPath: '/repo' },
|
||||
tasks: [],
|
||||
members: [],
|
||||
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
||||
processes: [],
|
||||
},
|
||||
currentRuntimeRunIdByTeam: { 'my-team': 'run-1' },
|
||||
} as never);
|
||||
|
||||
const state = useStore.getState();
|
||||
const fetchMemberSpawnStatusesSpy = vi.spyOn(state, 'fetchMemberSpawnStatuses');
|
||||
const fetchTeamAgentRuntimeSpy = vi.spyOn(state, 'fetchTeamAgentRuntime');
|
||||
|
||||
hoisted.onTeamChangeCb?.(
|
||||
{},
|
||||
{ type: 'process', teamName: 'my-team', detail: 'processes.json' }
|
||||
);
|
||||
hoisted.onTeamChangeCb?.({}, { type: 'member-spawn', teamName: 'my-team' });
|
||||
|
||||
useStore.setState({
|
||||
paneLayout: {
|
||||
focusedPaneId: 'p1',
|
||||
panes: [
|
||||
{
|
||||
id: 'p1',
|
||||
widthFraction: 1,
|
||||
tabs: [{ id: 't2', type: 'team', teamName: 'other-team', label: 'other-team' }],
|
||||
activeTabId: 't2',
|
||||
},
|
||||
],
|
||||
},
|
||||
} as never);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
expect(fetchMemberSpawnStatusesSpy).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMemberSpawnStatusesSpy).toHaveBeenCalledWith('my-team');
|
||||
expect(fetchTeamAgentRuntimeSpy).toHaveBeenCalledTimes(1);
|
||||
expect(fetchTeamAgentRuntimeSpy).toHaveBeenCalledWith('my-team');
|
||||
});
|
||||
|
||||
it('cleans up pending process-lite reconcile timers', async () => {
|
||||
useStore.setState({
|
||||
selectedTeamName: 'my-team',
|
||||
selectedTeamData: {
|
||||
teamName: 'my-team',
|
||||
config: { name: 'My Team', members: [], projectPath: '/repo' },
|
||||
tasks: [],
|
||||
members: [],
|
||||
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
||||
processes: [],
|
||||
},
|
||||
currentRuntimeRunIdByTeam: { 'my-team': 'run-1' },
|
||||
} as never);
|
||||
|
||||
const state = useStore.getState();
|
||||
const fetchTeamsSpy = vi.spyOn(state, 'fetchTeams');
|
||||
const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData');
|
||||
|
||||
hoisted.onTeamChangeCb?.(
|
||||
{},
|
||||
{ type: 'process', teamName: 'my-team', detail: 'processes.json' }
|
||||
);
|
||||
cleanup?.();
|
||||
cleanup = null;
|
||||
|
||||
await vi.advanceTimersByTimeAsync(20_000);
|
||||
expect(fetchTeamsSpy).not.toHaveBeenCalled();
|
||||
expect(refreshTeamDataSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('records unsafe process details as structural dry-run without changing refresh behavior', async () => {
|
||||
useStore.setState({
|
||||
selectedTeamName: 'my-team',
|
||||
selectedTeamData: {
|
||||
teamName: 'my-team',
|
||||
config: { name: 'My Team', members: [], projectPath: '/repo' },
|
||||
tasks: [],
|
||||
members: [],
|
||||
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
||||
processes: [],
|
||||
},
|
||||
currentRuntimeRunIdByTeam: { 'my-team': 'run-1' },
|
||||
} as never);
|
||||
|
||||
const refreshTeamDataSpy = vi.spyOn(useStore.getState(), 'refreshTeamData');
|
||||
|
||||
hoisted.onTeamChangeCb?.({}, { type: 'process', teamName: 'my-team', detail: 'cancelled' });
|
||||
|
||||
await vi.advanceTimersByTimeAsync(800);
|
||||
expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team', { withDedup: true });
|
||||
|
||||
const summary = summarizeTeamRefreshFanout('my-team');
|
||||
expect(summary.rows).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
reason: 'dry-run:process-lite:unsafe-process-detail',
|
||||
operation: 'wouldKeepStructuralProcess',
|
||||
phase: 'skipped',
|
||||
}),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps hidden process events out of visible detail refresh while recording structural dry-run', async () => {
|
||||
useStore.setState({
|
||||
paneLayout: {
|
||||
focusedPaneId: 'p1',
|
||||
panes: [
|
||||
{
|
||||
id: 'p1',
|
||||
widthFraction: 1,
|
||||
tabs: [{ id: 't1', type: 'team', teamName: 'my-team', label: 'my-team' }],
|
||||
activeTabId: 't1',
|
||||
},
|
||||
],
|
||||
},
|
||||
teamDataCacheByName: {
|
||||
'other-team': {
|
||||
teamName: 'other-team',
|
||||
config: { name: 'Other Team', members: [], projectPath: '/repo' },
|
||||
tasks: [],
|
||||
members: [],
|
||||
kanbanState: { teamName: 'other-team', reviewers: [], tasks: {} },
|
||||
processes: [],
|
||||
},
|
||||
},
|
||||
currentRuntimeRunIdByTeam: { 'other-team': 'run-1' },
|
||||
} as never);
|
||||
|
||||
const refreshTeamDataSpy = vi.spyOn(useStore.getState(), 'refreshTeamData');
|
||||
|
||||
hoisted.onTeamChangeCb?.(
|
||||
{},
|
||||
{ type: 'process', teamName: 'other-team', detail: 'processes.json' }
|
||||
);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(800);
|
||||
expect(refreshTeamDataSpy).not.toHaveBeenCalledWith('other-team', { withDedup: true });
|
||||
|
||||
const summary = summarizeTeamRefreshFanout('other-team');
|
||||
expect(summary.rows).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
reason: 'dry-run:process-lite:hidden-team',
|
||||
operation: 'wouldKeepStructuralProcess',
|
||||
phase: 'skipped',
|
||||
}),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps task and config events on the existing global task refresh path', async () => {
|
||||
const fetchAllTasksSpy = vi.fn(async () => undefined);
|
||||
useStore.setState({ fetchAllTasks: fetchAllTasksSpy } as never);
|
||||
|
|
|
|||
112
test/renderer/store/teamProcessFanoutDryRun.test.ts
Normal file
112
test/renderer/store/teamProcessFanoutDryRun.test.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
decideProcessFanoutMode,
|
||||
decideProcessFanoutDryRun,
|
||||
type TeamProcessFanoutInput,
|
||||
} from '../../../src/renderer/store/teamProcessFanoutDryRun';
|
||||
|
||||
const baseInput: TeamProcessFanoutInput = {
|
||||
teamName: 'my-team',
|
||||
eventType: 'process',
|
||||
detail: 'processes.json',
|
||||
hasRunId: false,
|
||||
isStaleRuntimeEvent: false,
|
||||
isVisible: true,
|
||||
hasVisibleTeamData: true,
|
||||
hasActiveProvisioningRun: false,
|
||||
hasCurrentRuntimeRun: true,
|
||||
};
|
||||
|
||||
describe('teamProcessFanoutDryRun', () => {
|
||||
it('does not mark non-process events as candidates', () => {
|
||||
expect(decideProcessFanoutDryRun({ ...baseInput, eventType: 'config' })).toEqual({
|
||||
wouldUseProcessLite: false,
|
||||
reason: 'not-process-event',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not mark stale runtime events as candidates', () => {
|
||||
expect(decideProcessFanoutDryRun({ ...baseInput, isStaleRuntimeEvent: true })).toEqual({
|
||||
wouldUseProcessLite: false,
|
||||
reason: 'stale-runtime-event',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not mark hidden teams as candidates', () => {
|
||||
expect(decideProcessFanoutDryRun({ ...baseInput, isVisible: false })).toEqual({
|
||||
wouldUseProcessLite: false,
|
||||
reason: 'hidden-team',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not mark visible teams without visible data as candidates', () => {
|
||||
expect(decideProcessFanoutDryRun({ ...baseInput, hasVisibleTeamData: false })).toEqual({
|
||||
wouldUseProcessLite: false,
|
||||
reason: 'missing-visible-team-data',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not mark teams without runtime or provisioning context as candidates', () => {
|
||||
expect(
|
||||
decideProcessFanoutDryRun({
|
||||
...baseInput,
|
||||
hasActiveProvisioningRun: false,
|
||||
hasCurrentRuntimeRun: false,
|
||||
})
|
||||
).toEqual({
|
||||
wouldUseProcessLite: false,
|
||||
reason: 'no-active-runtime-context',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not treat runId alone as a safe process-lite signal', () => {
|
||||
expect(
|
||||
decideProcessFanoutDryRun({
|
||||
...baseInput,
|
||||
detail: 'cancelled',
|
||||
hasRunId: true,
|
||||
})
|
||||
).toEqual({
|
||||
wouldUseProcessLite: false,
|
||||
reason: 'unsafe-process-detail',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not mark missing process detail as a candidate even with runId', () => {
|
||||
expect(
|
||||
decideProcessFanoutDryRun({
|
||||
...baseInput,
|
||||
detail: undefined,
|
||||
hasRunId: true,
|
||||
})
|
||||
).toEqual({
|
||||
wouldUseProcessLite: false,
|
||||
reason: 'unsafe-process-detail',
|
||||
});
|
||||
});
|
||||
|
||||
it('marks visible processes.json updates with runtime context as candidates', () => {
|
||||
expect(decideProcessFanoutDryRun(baseInput)).toEqual({
|
||||
wouldUseProcessLite: true,
|
||||
reason: 'processes-json-visible-runtime-context',
|
||||
});
|
||||
expect(decideProcessFanoutMode(baseInput)).toEqual({
|
||||
mode: 'process-lite',
|
||||
reason: 'processes-json-visible-runtime-context',
|
||||
});
|
||||
});
|
||||
|
||||
it('marks visible processes.json updates with active provisioning as candidates', () => {
|
||||
expect(
|
||||
decideProcessFanoutMode({
|
||||
...baseInput,
|
||||
hasActiveProvisioningRun: true,
|
||||
hasCurrentRuntimeRun: false,
|
||||
})
|
||||
).toEqual({
|
||||
mode: 'process-lite',
|
||||
reason: 'processes-json-visible-runtime-context',
|
||||
});
|
||||
});
|
||||
});
|
||||
81
test/renderer/store/teamRefreshFanoutDebugBridge.test.ts
Normal file
81
test/renderer/store/teamRefreshFanoutDebugBridge.test.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
installTeamRefreshFanoutDebugBridge,
|
||||
TEAM_REFRESH_FANOUT_DEBUG_STORAGE_KEY,
|
||||
} from '../../../src/renderer/store/teamRefreshFanoutDebugBridge';
|
||||
import {
|
||||
__resetTeamRefreshFanoutDiagnosticsForTests,
|
||||
noteTeamRefreshFanout,
|
||||
} from '../../../src/renderer/store/teamRefreshFanoutDiagnostics';
|
||||
|
||||
describe('teamRefreshFanoutDebugBridge', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
delete window.__TEAM_REFRESH_FANOUT__;
|
||||
__resetTeamRefreshFanoutDiagnosticsForTests();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
delete window.__TEAM_REFRESH_FANOUT__;
|
||||
__resetTeamRefreshFanoutDiagnosticsForTests();
|
||||
});
|
||||
|
||||
it('does not install without the localStorage flag', () => {
|
||||
const cleanup = installTeamRefreshFanoutDebugBridge();
|
||||
|
||||
expect(window.__TEAM_REFRESH_FANOUT__).toBeUndefined();
|
||||
cleanup();
|
||||
expect(window.__TEAM_REFRESH_FANOUT__).toBeUndefined();
|
||||
});
|
||||
|
||||
it('installs a frozen bridge behind the localStorage flag', () => {
|
||||
localStorage.setItem(TEAM_REFRESH_FANOUT_DEBUG_STORAGE_KEY, '1');
|
||||
|
||||
const cleanup = installTeamRefreshFanoutDebugBridge();
|
||||
|
||||
expect(window.__TEAM_REFRESH_FANOUT__).toBeDefined();
|
||||
expect(Object.isFrozen(window.__TEAM_REFRESH_FANOUT__)).toBe(true);
|
||||
expect(typeof window.__TEAM_REFRESH_FANOUT__?.snapshot).toBe('function');
|
||||
expect(typeof window.__TEAM_REFRESH_FANOUT__?.summary).toBe('function');
|
||||
expect(typeof window.__TEAM_REFRESH_FANOUT__?.reset).toBe('function');
|
||||
|
||||
cleanup();
|
||||
expect(window.__TEAM_REFRESH_FANOUT__).toBeUndefined();
|
||||
});
|
||||
|
||||
it('cleanup removes only the bridge it installed', () => {
|
||||
localStorage.setItem(TEAM_REFRESH_FANOUT_DEBUG_STORAGE_KEY, '1');
|
||||
const cleanup = installTeamRefreshFanoutDebugBridge();
|
||||
const replacement = {
|
||||
snapshot: window.__TEAM_REFRESH_FANOUT__!.snapshot,
|
||||
summary: window.__TEAM_REFRESH_FANOUT__!.summary,
|
||||
reset: window.__TEAM_REFRESH_FANOUT__!.reset,
|
||||
};
|
||||
window.__TEAM_REFRESH_FANOUT__ = replacement;
|
||||
|
||||
cleanup();
|
||||
|
||||
expect(window.__TEAM_REFRESH_FANOUT__).toBe(replacement);
|
||||
});
|
||||
|
||||
it('bridge reset clears diagnostics', () => {
|
||||
localStorage.setItem(TEAM_REFRESH_FANOUT_DEBUG_STORAGE_KEY, '1');
|
||||
const cleanup = installTeamRefreshFanoutDebugBridge();
|
||||
|
||||
noteTeamRefreshFanout({
|
||||
teamName: 'team-a',
|
||||
surface: 'team-change-listener',
|
||||
phase: 'scheduled',
|
||||
reason: 'event:process',
|
||||
operation: 'refreshTeamData',
|
||||
});
|
||||
|
||||
expect(window.__TEAM_REFRESH_FANOUT__?.summary('team-a').total).toBe(1);
|
||||
window.__TEAM_REFRESH_FANOUT__?.reset();
|
||||
expect(window.__TEAM_REFRESH_FANOUT__?.summary('team-a').total).toBe(0);
|
||||
|
||||
cleanup();
|
||||
});
|
||||
});
|
||||
|
|
@ -7,6 +7,7 @@ import {
|
|||
MAX_TEAM_REFRESH_DIAGNOSTIC_RECENT_NOTES,
|
||||
MAX_TEAM_REFRESH_DIAGNOSTIC_TEAMS,
|
||||
noteTeamRefreshFanout,
|
||||
summarizeTeamRefreshFanout,
|
||||
type TeamRefreshFanoutSnapshot,
|
||||
} from '../../../src/renderer/store/teamRefreshFanoutDiagnostics';
|
||||
|
||||
|
|
@ -118,6 +119,84 @@ describe('teamRefreshFanoutDiagnostics', () => {
|
|||
expect(getTeamRefreshFanoutSnapshotForTests()).toEqual({});
|
||||
});
|
||||
|
||||
it('summarizes structured counts without splitting colon-containing reasons', () => {
|
||||
noteTeamRefreshFanout({
|
||||
teamName: 'team-a',
|
||||
surface: 'team-change-listener',
|
||||
phase: 'scheduled',
|
||||
reason: 'event:process',
|
||||
operation: 'refreshTeamData',
|
||||
});
|
||||
|
||||
const summary = summarizeTeamRefreshFanout('team-a');
|
||||
|
||||
expect(summary).toMatchObject({ teamName: 'team-a', total: 1 });
|
||||
expect(summary.rows[0]).toMatchObject({
|
||||
count: 1,
|
||||
surface: 'team-change-listener',
|
||||
reason: 'event:process',
|
||||
operation: 'refreshTeamData',
|
||||
phase: 'scheduled',
|
||||
});
|
||||
});
|
||||
|
||||
it('sorts summary rows by count descending and aggregates across teams', () => {
|
||||
const highVolumeNote = {
|
||||
teamName: 'team-a',
|
||||
surface: 'team-change-listener',
|
||||
phase: 'scheduled',
|
||||
reason: 'event:process',
|
||||
operation: 'refreshTeamData',
|
||||
} as const;
|
||||
const lowVolumeNote = {
|
||||
teamName: 'team-b',
|
||||
surface: 'team-change-listener',
|
||||
phase: 'scheduled',
|
||||
reason: 'event:config',
|
||||
operation: 'fetchTeams',
|
||||
} as const;
|
||||
|
||||
noteTeamRefreshFanout(highVolumeNote);
|
||||
noteTeamRefreshFanout(highVolumeNote);
|
||||
noteTeamRefreshFanout(lowVolumeNote);
|
||||
|
||||
const summary = summarizeTeamRefreshFanout();
|
||||
|
||||
expect(summary.total).toBe(3);
|
||||
expect(summary.rows[0]).toMatchObject({
|
||||
count: 2,
|
||||
reason: 'event:process',
|
||||
operation: 'refreshTeamData',
|
||||
});
|
||||
expect(summary.rows[1]).toMatchObject({
|
||||
count: 1,
|
||||
reason: 'event:config',
|
||||
operation: 'fetchTeams',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns cloned snapshots that cannot mutate internal buckets', () => {
|
||||
const note = {
|
||||
teamName: 'team-a',
|
||||
surface: 'team-change-listener',
|
||||
phase: 'scheduled',
|
||||
reason: 'event:process',
|
||||
operation: 'refreshTeamData',
|
||||
} as const;
|
||||
noteTeamRefreshFanout(note);
|
||||
|
||||
const key = buildTeamRefreshFanoutCountKey(note);
|
||||
const snapshot = snapshotFor('team-a');
|
||||
snapshot.counts[key] = 99;
|
||||
snapshot.structuredCounts[key]!.count = 99;
|
||||
snapshot.recent[0]!.reason = 'mutated';
|
||||
|
||||
const nextSnapshot = snapshotFor('team-a');
|
||||
expect(nextSnapshot.counts[key]).toBe(1);
|
||||
expect(nextSnapshot.structuredCounts[key]?.count).toBe(1);
|
||||
expect(nextSnapshot.recent[0]?.reason).toBe('event:process');
|
||||
});
|
||||
|
||||
it('ignores invalid empty team or reason values', () => {
|
||||
noteTeamRefreshFanout({
|
||||
teamName: '',
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
selectMemberMessagesForTeamMember,
|
||||
selectResolvedMemberForTeamName,
|
||||
selectResolvedMembersForTeamName,
|
||||
selectTeamDataForName,
|
||||
} from '../../../src/renderer/store/slices/teamSlice';
|
||||
import {
|
||||
__resetTeamRefreshFanoutDiagnosticsForTests,
|
||||
|
|
@ -1703,6 +1704,35 @@ describe('teamSlice actions', () => {
|
|||
expect(nextResolvedMembers).toBe(initialResolvedMembers);
|
||||
});
|
||||
|
||||
it('prefers selected team data over stale cached data for the active team', () => {
|
||||
const store = createSliceStore();
|
||||
const staleCachedData = createTeamSnapshot({
|
||||
members: [],
|
||||
});
|
||||
const freshSelectedData = createTeamSnapshot({
|
||||
members: [
|
||||
{
|
||||
name: 'alice',
|
||||
currentTaskId: null,
|
||||
taskCount: 0,
|
||||
color: 'blue',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
store.setState({
|
||||
selectedTeamName: 'my-team',
|
||||
selectedTeamData: freshSelectedData,
|
||||
teamDataCacheByName: {
|
||||
'my-team': staleCachedData,
|
||||
},
|
||||
memberActivityMetaByTeam: {},
|
||||
});
|
||||
|
||||
expect(selectTeamDataForName(store.getState(), 'my-team')).toBe(freshSelectedData);
|
||||
expect(selectResolvedMembersForTeamName(store.getState(), 'my-team')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('memoizes team-scoped member messages selectors over the merged message feed', () => {
|
||||
const store = createSliceStore();
|
||||
store.setState({
|
||||
|
|
|
|||
Loading…
Reference in a new issue