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",
|
"from": "resources/runtime",
|
||||||
"to": "runtime"
|
"to": "runtime"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"from": "src/renderer/assets/participant-avatars",
|
||||||
|
"to": "participant-avatars"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"from": "mcp-server/dist/index.js",
|
"from": "mcp-server/dist/index.js",
|
||||||
"to": "mcp-server/index.js"
|
"to": "mcp-server/index.js"
|
||||||
|
|
|
||||||
|
|
@ -498,6 +498,9 @@ async function notifyNewInboxMessages(teamName: string, detail: string): Promise
|
||||||
summary,
|
summary,
|
||||||
body: extracted.body,
|
body: extracted.body,
|
||||||
dedupeKey: `inbox:${teamName}:${memberName}:${msgId}`,
|
dedupeKey: `inbox:${teamName}:${memberName}:${msgId}`,
|
||||||
|
target: isCrossTeam
|
||||||
|
? { kind: 'team', teamName, section: 'messages' }
|
||||||
|
: { kind: 'member', teamName, memberName: fromLabel, focus: 'messages' },
|
||||||
suppressToast: effectiveSuppressToast,
|
suppressToast: effectiveSuppressToast,
|
||||||
})
|
})
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
|
|
@ -557,6 +560,7 @@ async function notifyNewSentMessages(teamName: string): Promise<void> {
|
||||||
summary,
|
summary,
|
||||||
body: extracted.body,
|
body: extracted.body,
|
||||||
dedupeKey: `sent:${teamName}:${msg.timestamp ?? String(prevCount + i)}`,
|
dedupeKey: `sent:${teamName}:${msg.timestamp ?? String(prevCount + i)}`,
|
||||||
|
target: { kind: 'member', teamName, memberName: fromLabel, focus: 'messages' },
|
||||||
suppressToast,
|
suppressToast,
|
||||||
})
|
})
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
|
|
|
||||||
|
|
@ -415,6 +415,7 @@ function checkRateLimitMessages(
|
||||||
summary: `Rate limit: ${msg.from}`,
|
summary: `Rate limit: ${msg.from}`,
|
||||||
body: msg.text.slice(0, 200),
|
body: msg.text.slice(0, 200),
|
||||||
dedupeKey,
|
dedupeKey,
|
||||||
|
target: { kind: 'member', teamName, memberName: msg.from, focus: 'logs' },
|
||||||
projectPath,
|
projectPath,
|
||||||
})
|
})
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
|
|
@ -489,6 +490,7 @@ function checkApiErrorMessages(
|
||||||
summary: `API Error ${statusCode}: ${msg.from}`,
|
summary: `API Error ${statusCode}: ${msg.from}`,
|
||||||
body: msg.text.slice(0, 400),
|
body: msg.text.slice(0, 400),
|
||||||
dedupeKey,
|
dedupeKey,
|
||||||
|
target: { kind: 'member', teamName, memberName: msg.from, focus: 'logs' },
|
||||||
projectPath,
|
projectPath,
|
||||||
})
|
})
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
|
|
@ -4444,6 +4446,7 @@ async function handleShowMessageNotification(
|
||||||
summary: d.summary ?? `${d.from} → ${d.to ?? 'team'}`,
|
summary: d.summary ?? `${d.from} → ${d.to ?? 'team'}`,
|
||||||
body: d.body,
|
body: d.body,
|
||||||
dedupeKey,
|
dedupeKey,
|
||||||
|
target: d.target,
|
||||||
suppressToast: d.suppressToast,
|
suppressToast: d.suppressToast,
|
||||||
})
|
})
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import { randomUUID } from 'crypto';
|
||||||
import { type ExtractedToolResult } from '../analysis/ToolResultExtractor';
|
import { type ExtractedToolResult } from '../analysis/ToolResultExtractor';
|
||||||
|
|
||||||
import type { TriggerColor } from '@shared/constants/triggerColors';
|
import type { TriggerColor } from '@shared/constants/triggerColors';
|
||||||
import type { TeamEventType } from '@shared/types/notifications';
|
import type { NotificationTarget, TeamEventType } from '@shared/types/notifications';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Types
|
// Types
|
||||||
|
|
@ -54,6 +54,8 @@ export interface DetectedError {
|
||||||
category?: 'error' | 'team';
|
category?: 'error' | 'team';
|
||||||
/** For team notifications: specific event sub-type */
|
/** For team notifications: specific event sub-type */
|
||||||
teamEventType?: TeamEventType;
|
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. */
|
/** Explicit key for storage deduplication. Two notifications with the same dedupeKey won't be stored twice. */
|
||||||
dedupeKey?: string;
|
dedupeKey?: string;
|
||||||
/** Additional context about the error */
|
/** Additional context about the error */
|
||||||
|
|
|
||||||
|
|
@ -16,15 +16,19 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { getAppIconPath } from '@main/utils/appIcon';
|
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 { safeSendToRenderer } from '@main/utils/safeWebContentsSend';
|
||||||
import { stripMarkdown } from '@main/utils/textFormatting';
|
import { stripMarkdown } from '@main/utils/textFormatting';
|
||||||
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
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 { createLogger } from '@shared/utils/logger';
|
||||||
import { Notification as ElectronNotification } from 'electron';
|
import { Notification as ElectronNotification, nativeImage } from 'electron';
|
||||||
import { EventEmitter } from 'events';
|
import { EventEmitter } from 'events';
|
||||||
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
||||||
import * as fsp from 'fs/promises';
|
import * as fsp from 'fs/promises';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
import { pathToFileURL } from 'url';
|
||||||
|
|
||||||
import { type DetectedError } from '../error/ErrorMessageBuilder';
|
import { type DetectedError } from '../error/ErrorMessageBuilder';
|
||||||
|
|
||||||
|
|
@ -101,6 +105,16 @@ const LEGACY_NOTIFICATION_FILENAMES = [
|
||||||
const LEGACY_NOTIFICATION_PATHS = LEGACY_NOTIFICATION_FILENAMES.map((filename) =>
|
const LEGACY_NOTIFICATION_PATHS = LEGACY_NOTIFICATION_FILENAMES.map((filename) =>
|
||||||
path.join(getHomeDir(), '.claude', 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 {
|
interface LegacyNotificationData {
|
||||||
path: string;
|
path: string;
|
||||||
|
|
@ -123,6 +137,385 @@ function getNotificationClass(): NotificationClass | null {
|
||||||
return (ElectronNotification as NotificationClass | undefined) ?? 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> {
|
async function migrateLegacyNotificationPath(): Promise<string> {
|
||||||
try {
|
try {
|
||||||
await fsp.readFile(NOTIFICATIONS_PATH, 'utf8');
|
await fsp.readFile(NOTIFICATIONS_PATH, 'utf8');
|
||||||
|
|
@ -603,7 +996,7 @@ export class NotificationManager extends EventEmitter {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shows a native notification for a team event.
|
* 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(
|
private showTeamNativeNotification(
|
||||||
stored: StoredNotification,
|
stored: StoredNotification,
|
||||||
|
|
@ -618,20 +1011,45 @@ export class NotificationManager extends EventEmitter {
|
||||||
try {
|
try {
|
||||||
const config = this.configManager.getConfig();
|
const config = this.configManager.getConfig();
|
||||||
const isMac = process.platform === 'darwin';
|
const isMac = process.platform === 'darwin';
|
||||||
const truncatedBody = stripMarkdown(stripAgentBlocks(payload.body)).slice(0, 300);
|
const presentation = buildTeamNotificationPresentation(payload, payload.body);
|
||||||
const iconPath = isMac ? undefined : getAppIconPath();
|
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(
|
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({
|
const notificationOptions: NotificationConstructorOptions = toastXml
|
||||||
title: payload.teamDisplayName,
|
? { toastXml }
|
||||||
...(isMac ? { subtitle: payload.summary } : {}),
|
: {
|
||||||
body: !isMac && payload.summary ? `${payload.summary}\n${truncatedBody}` : truncatedBody,
|
title: presentation.title,
|
||||||
sound: config.notifications.soundEnabled ? 'default' : undefined,
|
...(isMac ? { subtitle: presentation.where } : {}),
|
||||||
...(iconPath ? { icon: iconPath } : {}),
|
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
|
// Hold a strong reference to prevent GC from collecting the notification
|
||||||
this.activeNotifications.add(notification);
|
this.activeNotifications.add(notification);
|
||||||
|
|
@ -647,7 +1065,7 @@ export class NotificationManager extends EventEmitter {
|
||||||
|
|
||||||
notification.on('show', () => {
|
notification.on('show', () => {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`[team-toast] OS confirmed show: "${payload.teamDisplayName}" — ${payload.summary ?? ''}`
|
`[team-toast] OS confirmed show: "${presentation.title}" - ${presentation.where}`
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
notification.on('failed', (_, error) => {
|
notification.on('failed', (_, error) => {
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,10 @@ function isOpaqueSafeTaskIdSegment(segment: string): boolean {
|
||||||
export function shouldIgnoreLogSourceWatcherPath(
|
export function shouldIgnoreLogSourceWatcherPath(
|
||||||
projectDir: string,
|
projectDir: string,
|
||||||
watchedPath: string,
|
watchedPath: string,
|
||||||
_scope?: { scopedSessionIds?: ReadonlySet<string> }
|
scope?: {
|
||||||
|
scopedSessionIds?: ReadonlySet<string>;
|
||||||
|
pendingRootSessionIds?: ReadonlySet<string>;
|
||||||
|
}
|
||||||
): boolean {
|
): boolean {
|
||||||
const parts = getRelativeLogSourceParts(projectDir, watchedPath);
|
const parts = getRelativeLogSourceParts(projectDir, watchedPath);
|
||||||
if (!parts) {
|
if (!parts) {
|
||||||
|
|
@ -90,6 +93,31 @@ export function shouldIgnoreLogSourceWatcherPath(
|
||||||
if (first === BOARD_TASK_LOG_FRESHNESS_DIRNAME) return false;
|
if (first === BOARD_TASK_LOG_FRESHNESS_DIRNAME) return false;
|
||||||
if (first === BOARD_TASK_CHANGE_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 && parts[1] === 'subagents') {
|
||||||
if (parts.length === 2) return false;
|
if (parts.length === 2) return false;
|
||||||
if (parts.length === 3) return !isAgentTranscriptFileName(parts[2]);
|
if (parts.length === 3) return !isAgentTranscriptFileName(parts[2]);
|
||||||
|
|
@ -360,7 +388,10 @@ export class TeamLogSourceTracker {
|
||||||
followSymlinks: false,
|
followSymlinks: false,
|
||||||
depth: 0,
|
depth: 0,
|
||||||
ignored: (watchedPath) =>
|
ignored: (watchedPath) =>
|
||||||
shouldIgnoreLogSourceWatcherPath(context.projectDir, watchedPath, { scopedSessionIds }),
|
shouldIgnoreLogSourceWatcherPath(context.projectDir, watchedPath, {
|
||||||
|
scopedSessionIds,
|
||||||
|
pendingRootSessionIds: new Set(this.getPendingUnknownSessionIds(state)),
|
||||||
|
}),
|
||||||
awaitWriteFinish: {
|
awaitWriteFinish: {
|
||||||
stabilityThreshold: 250,
|
stabilityThreshold: 250,
|
||||||
pollInterval: 50,
|
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 =
|
const OPENCODE_UNCOMMITTED_BOOTSTRAP_DIAGNOSTIC =
|
||||||
'OpenCode bridge reported bootstrap confirmation, but no lane runtime evidence was committed.';
|
'OpenCode bridge reported bootstrap confirmation, but no lane runtime evidence was committed.';
|
||||||
|
|
||||||
|
|
@ -2064,7 +2181,7 @@ function promoteOpenCodeSecondaryMemberFromCommittedBootstrapEvidence(input: {
|
||||||
'opencode_bootstrap_evidence_committed',
|
'opencode_bootstrap_evidence_committed',
|
||||||
]),
|
]),
|
||||||
];
|
];
|
||||||
const runtimeAlive = input.current.runtimeAlive === true;
|
const runtimeAlive = true;
|
||||||
return {
|
return {
|
||||||
...input.previous,
|
...input.previous,
|
||||||
...input.current,
|
...input.current,
|
||||||
|
|
@ -6574,6 +6691,7 @@ export class TeamProvisioningService {
|
||||||
let liveSecondaryLaneRunId: string | null = null;
|
let liveSecondaryLaneRunId: string | null = null;
|
||||||
let trackedSecondaryLanePresent = false;
|
let trackedSecondaryLanePresent = false;
|
||||||
let trackedSecondaryLaneSnapshotKnown = false;
|
let trackedSecondaryLaneSnapshotKnown = false;
|
||||||
|
let trackedSecondaryLaneBootstrapConfirmed: boolean | null = null;
|
||||||
if (
|
if (
|
||||||
trackedRun &&
|
trackedRun &&
|
||||||
laneIdentity.laneKind === 'secondary' &&
|
laneIdentity.laneKind === 'secondary' &&
|
||||||
|
|
@ -6588,6 +6706,15 @@ export class TeamProvisioningService {
|
||||||
);
|
);
|
||||||
trackedSecondaryLanePresent = liveLane != null;
|
trackedSecondaryLanePresent = liveLane != null;
|
||||||
liveSecondaryLaneRunId = liveLane ? trackedRunId : 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) {
|
if (!liveLane && trackedSecondaryLaneSnapshotKnown) {
|
||||||
return { delivered: false, reason: 'opencode_runtime_not_active' };
|
return { delivered: false, reason: 'opencode_runtime_not_active' };
|
||||||
}
|
}
|
||||||
|
|
@ -6681,6 +6808,26 @@ export class TeamProvisioningService {
|
||||||
return { delivered: false, reason: 'opencode_runtime_not_active' };
|
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()) {
|
if (!this.isOpenCodePromptDeliveryWatchdogEnabled()) {
|
||||||
const result = await adapter.sendMessageToMember({
|
const result = await adapter.sendMessageToMember({
|
||||||
...(runtimeRunId ? { runId: runtimeRunId } : {}),
|
...(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(
|
private async readOpenCodeRuntimeSessionStore(
|
||||||
filePath: string
|
filePath: string
|
||||||
): Promise<Record<string, unknown>[]> {
|
): Promise<Record<string, unknown>[]> {
|
||||||
|
|
@ -19656,25 +19828,49 @@ export class TeamProvisioningService {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: result;
|
: result;
|
||||||
lane.result = resultWithTiming;
|
const baseFailureDiagnostics = appendDiagnosticOnce(
|
||||||
lane.warnings = [...resultWithTiming.warnings];
|
[...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(
|
const launchDiagnostics = appendDiagnosticOnce(
|
||||||
[...requestedDiagnostics, ...migration.diagnostics, ...resultWithTiming.diagnostics],
|
[...requestedDiagnostics, ...migration.diagnostics, ...normalizedResult.diagnostics],
|
||||||
timingDiagnostic
|
timingDiagnostic
|
||||||
);
|
);
|
||||||
lane.diagnostics = launchDiagnostics;
|
lane.diagnostics = launchDiagnostics;
|
||||||
|
|
||||||
if (
|
if (recoverableBootstrapPending) {
|
||||||
isDefinitiveOpenCodePreLaunchFailure(resultWithTiming, lane.member.name) ||
|
await upsertOpenCodeRuntimeLaneIndexEntry({
|
||||||
resultWithTiming.teamLaunchState === 'partial_failure'
|
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(
|
const diagnostics = collectOpenCodeSecondaryLaneFailureDiagnostics(
|
||||||
resultWithTiming,
|
normalizedResult,
|
||||||
lane.member.name,
|
lane.member.name,
|
||||||
appendDiagnosticOnce(
|
baseFailureDiagnostics
|
||||||
[...requestedDiagnostics, ...migration.diagnostics],
|
|
||||||
timingDiagnostic
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
await upsertOpenCodeRuntimeLaneIndexEntry({
|
await upsertOpenCodeRuntimeLaneIndexEntry({
|
||||||
teamsBasePath: getTeamsBasePath(),
|
teamsBasePath: getTeamsBasePath(),
|
||||||
|
|
@ -24078,6 +24274,7 @@ export class TeamProvisioningService {
|
||||||
summary: run.isLaunch ? 'Team launched' : 'Team provisioned',
|
summary: run.isLaunch ? 'Team launched' : 'Team provisioned',
|
||||||
body,
|
body,
|
||||||
dedupeKey: `team_launched:${run.teamName}:${run.runId}`,
|
dedupeKey: `team_launched:${run.teamName}:${run.runId}`,
|
||||||
|
target: { kind: 'team', teamName: run.teamName, section: 'overview' },
|
||||||
projectPath: run.request.cwd,
|
projectPath: run.request.cwd,
|
||||||
suppressToast,
|
suppressToast,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -267,17 +267,24 @@ export function resolveTeamMemberRuntimeLiveness(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return result({
|
return result({
|
||||||
alive: false,
|
alive: hasConfirmedBootstrap,
|
||||||
livenessKind: 'runtime_process_candidate',
|
livenessKind: hasConfirmedBootstrap ? 'confirmed_bootstrap' : 'runtime_process_candidate',
|
||||||
pidSource: 'opencode_bridge',
|
pidSource: hasConfirmedBootstrap ? 'runtime_bootstrap' : 'opencode_bridge',
|
||||||
pid: runtimePidRow.pid,
|
pid: hasConfirmedBootstrap ? undefined : runtimePidRow.pid,
|
||||||
runtimeSessionId,
|
runtimeSessionId,
|
||||||
processCommand,
|
processCommand: hasConfirmedBootstrap ? undefined : processCommand,
|
||||||
runtimeDiagnostic: 'OpenCode runtime pid is alive, but process identity is unverified',
|
runtimeLastSeenAt: hasConfirmedBootstrap
|
||||||
runtimeDiagnosticSeverity: 'warning',
|
? (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: [
|
||||||
...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 { DetectedError } from '../services/error/ErrorMessageBuilder';
|
||||||
import type { TriggerColor } from '@shared/constants/triggerColors';
|
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
|
// Re-export for callers that import TeamEventType from this module
|
||||||
export type { TeamEventType } from '@shared/types/notifications';
|
export type { TeamEventType } from '@shared/types/notifications';
|
||||||
|
|
@ -29,10 +29,18 @@ export interface TeamNotificationPayload {
|
||||||
teamDisplayName: string;
|
teamDisplayName: string;
|
||||||
from: string;
|
from: string;
|
||||||
to?: 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;
|
summary: string;
|
||||||
body: string;
|
body: string;
|
||||||
/** Stable key for storage deduplication. REQUIRED — no fallback to Date.now(). */
|
/** Stable key for storage deduplication. REQUIRED — no fallback to Date.now(). */
|
||||||
dedupeKey: string;
|
dedupeKey: string;
|
||||||
|
/** Structured destination used by notification click handling. */
|
||||||
|
target?: NotificationTarget;
|
||||||
projectPath?: string;
|
projectPath?: string;
|
||||||
/**
|
/**
|
||||||
* When true, the notification is stored in-app but no native OS toast is shown.
|
* 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 {
|
export function buildDetectedErrorFromTeam(payload: TeamNotificationPayload): DetectedError {
|
||||||
const config = TEAM_NOTIFICATION_CONFIG[payload.teamEventType];
|
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 {
|
return {
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
|
|
@ -84,9 +95,10 @@ export function buildDetectedErrorFromTeam(payload: TeamNotificationPayload): De
|
||||||
projectId: payload.teamName,
|
projectId: payload.teamName,
|
||||||
filePath: '',
|
filePath: '',
|
||||||
source: payload.teamEventType,
|
source: payload.teamEventType,
|
||||||
message: `[${payload.from}] ${stripAgentBlocks(payload.body).trim().slice(0, 300)}`,
|
message: `[${payload.from}] ${preview.slice(0, 300)}`,
|
||||||
category: 'team',
|
category: 'team',
|
||||||
teamEventType: payload.teamEventType,
|
teamEventType: payload.teamEventType,
|
||||||
|
target: payload.target,
|
||||||
dedupeKey: payload.dedupeKey,
|
dedupeKey: payload.dedupeKey,
|
||||||
triggerColor: config.triggerColor,
|
triggerColor: config.triggerColor,
|
||||||
triggerName: config.triggerName,
|
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 { TeamProvisioningBanner } from './TeamProvisioningBanner';
|
||||||
import { deriveLeadContextButtonLabel } from './leadContextLoadGuards';
|
import { deriveLeadContextButtonLabel } from './leadContextLoadGuards';
|
||||||
import { LeadSessionDetailGate } from './LeadSessionDetailGate';
|
import { LeadSessionDetailGate } from './LeadSessionDetailGate';
|
||||||
|
import { LiveRuntimeStatusBridge } from './LiveRuntimeStatusBridge';
|
||||||
import { loadTeamSessionMetadata } from './teamSessionFetchGuards';
|
import { loadTeamSessionMetadata } from './teamSessionFetchGuards';
|
||||||
import { TeamSessionsSection } from './TeamSessionsSection';
|
import { TeamSessionsSection } from './TeamSessionsSection';
|
||||||
|
|
||||||
|
|
@ -1847,13 +1848,22 @@ export const TeamDetailView = memo(function TeamDetailView({
|
||||||
const pendingMemberProfile = useStore((s) => s.pendingMemberProfile);
|
const pendingMemberProfile = useStore((s) => s.pendingMemberProfile);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pendingMemberProfile || !data) return;
|
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) {
|
if (member) {
|
||||||
setSelectedMember(member);
|
setSelectedMember(member);
|
||||||
setSelectedMemberView(null);
|
setSelectedMemberView({
|
||||||
|
initialTab:
|
||||||
|
pendingMemberProfile.focus === 'logs'
|
||||||
|
? 'logs'
|
||||||
|
: pendingMemberProfile.focus === 'messages'
|
||||||
|
? 'activity'
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
useStore.getState().closeMemberProfile();
|
useStore.getState().closeMemberProfile();
|
||||||
}, [pendingMemberProfile, membersWithLiveBranches]);
|
}, [pendingMemberProfile, membersWithLiveBranches, teamName, data]);
|
||||||
|
|
||||||
const handleDeleteTask = useCallback(
|
const handleDeleteTask = useCallback(
|
||||||
(taskId: string) => {
|
(taskId: string) => {
|
||||||
|
|
@ -2638,6 +2648,8 @@ export const TeamDetailView = memo(function TeamDetailView({
|
||||||
<ScheduleSection teamName={teamName} />
|
<ScheduleSection teamName={teamName} />
|
||||||
</CollapsibleTeamSection>
|
</CollapsibleTeamSection>
|
||||||
|
|
||||||
|
<LiveRuntimeStatusBridge teamName={teamName} members={membersWithLiveBranches} />
|
||||||
|
|
||||||
{(data.processes?.length ?? 0) > 0 && (
|
{(data.processes?.length ?? 0) > 0 && (
|
||||||
<CollapsibleTeamSection
|
<CollapsibleTeamSection
|
||||||
sectionId="processes"
|
sectionId="processes"
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ export const GlobalTaskDetailDialog = (): React.JSX.Element | null => {
|
||||||
|
|
||||||
const teamName = globalTaskDetail?.teamName ?? '';
|
const teamName = globalTaskDetail?.teamName ?? '';
|
||||||
const taskId = globalTaskDetail?.taskId ?? '';
|
const taskId = globalTaskDetail?.taskId ?? '';
|
||||||
|
const commentId = globalTaskDetail?.commentId;
|
||||||
const hasTargetTeamData = hasSelectedTargetTeamData(
|
const hasTargetTeamData = hasSelectedTargetTeamData(
|
||||||
teamName,
|
teamName,
|
||||||
selectedTeamName,
|
selectedTeamName,
|
||||||
|
|
@ -150,6 +151,7 @@ export const GlobalTaskDetailDialog = (): React.JSX.Element | null => {
|
||||||
onClose={closeGlobalTaskDetail}
|
onClose={closeGlobalTaskDetail}
|
||||||
onOwnerChange={undefined}
|
onOwnerChange={undefined}
|
||||||
onViewChanges={isFullTeamLoaded ? handleViewChanges : undefined}
|
onViewChanges={isFullTeamLoaded ? handleViewChanges : undefined}
|
||||||
|
focusCommentId={commentId}
|
||||||
headerExtra={
|
headerExtra={
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,10 @@ const INITIAL_VISIBLE_COMMENTS = 30;
|
||||||
const VISIBLE_COMMENTS_STEP = 50;
|
const VISIBLE_COMMENTS_STEP = 50;
|
||||||
const MAX_COMMENTS_TO_RENDER = 2000;
|
const MAX_COMMENTS_TO_RENDER = 2000;
|
||||||
|
|
||||||
|
function getTaskCommentElementId(taskId: string, commentId: string): string {
|
||||||
|
return `task-comment-${taskId}-${commentId}`;
|
||||||
|
}
|
||||||
|
|
||||||
interface TaskCommentsSectionProps {
|
interface TaskCommentsSectionProps {
|
||||||
teamName: string;
|
teamName: string;
|
||||||
taskId: string;
|
taskId: string;
|
||||||
|
|
@ -66,6 +70,8 @@ interface TaskCommentsSectionProps {
|
||||||
containerClassName?: string;
|
containerClassName?: string;
|
||||||
/** Snapshot of unread comment IDs captured when the dialog opened. Blue dot is shown for these. */
|
/** Snapshot of unread comment IDs captured when the dialog opened. Blue dot is shown for these. */
|
||||||
unreadCommentIds?: Set<string>;
|
unreadCommentIds?: Set<string>;
|
||||||
|
/** Comment to reveal when the dialog was opened from a notification. */
|
||||||
|
focusCommentId?: string;
|
||||||
/**
|
/**
|
||||||
* Ref callback factory from useViewportCommentRead.
|
* Ref callback factory from useViewportCommentRead.
|
||||||
* When provided, each comment element is registered for viewport-based read tracking.
|
* When provided, each comment element is registered for viewport-based read tracking.
|
||||||
|
|
@ -84,6 +90,7 @@ export const TaskCommentsSection = ({
|
||||||
onTaskIdClick,
|
onTaskIdClick,
|
||||||
containerClassName,
|
containerClassName,
|
||||||
unreadCommentIds,
|
unreadCommentIds,
|
||||||
|
focusCommentId,
|
||||||
registerCommentForViewport,
|
registerCommentForViewport,
|
||||||
}: TaskCommentsSectionProps): React.JSX.Element => {
|
}: TaskCommentsSectionProps): React.JSX.Element => {
|
||||||
const addTaskComment = useStore((s) => s.addTaskComment);
|
const addTaskComment = useStore((s) => s.addTaskComment);
|
||||||
|
|
@ -130,9 +137,15 @@ export const TaskCommentsSection = ({
|
||||||
return list;
|
return list;
|
||||||
}, [cappedComments]);
|
}, [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(
|
const visibleComments = useMemo(
|
||||||
() => sortedComments.slice(0, Math.min(visibleCount, sortedComments.length)),
|
() => sortedComments.slice(0, Math.min(visibleCountForFocus, sortedComments.length)),
|
||||||
[sortedComments, visibleCount]
|
[sortedComments, visibleCountForFocus]
|
||||||
);
|
);
|
||||||
|
|
||||||
const visibleCommentIds = useMemo(
|
const visibleCommentIds = useMemo(
|
||||||
|
|
@ -163,6 +176,22 @@ export const TaskCommentsSection = ({
|
||||||
trimmed.length <= MAX_TEXT_LENGTH &&
|
trimmed.length <= MAX_TEXT_LENGTH &&
|
||||||
!addingComment;
|
!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 () => {
|
const handleSubmit = useCallback(async () => {
|
||||||
if (!canSubmit) return;
|
if (!canSubmit) return;
|
||||||
try {
|
try {
|
||||||
|
|
@ -215,6 +244,8 @@ export const TaskCommentsSection = ({
|
||||||
{visibleComments.map((comment, index) => (
|
{visibleComments.map((comment, index) => (
|
||||||
<AnimatedHeightReveal key={comment.id} animate={newCommentIds.has(comment.id)}>
|
<AnimatedHeightReveal key={comment.id} animate={newCommentIds.has(comment.id)}>
|
||||||
<div
|
<div
|
||||||
|
id={getTaskCommentElementId(taskId, comment.id)}
|
||||||
|
data-task-comment-id={comment.id}
|
||||||
ref={
|
ref={
|
||||||
registerCommentForViewport ? registerCommentForViewport(comment.id) : undefined
|
registerCommentForViewport ? registerCommentForViewport(comment.id) : undefined
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -119,6 +119,7 @@ interface TaskDetailDialogProps {
|
||||||
onViewChanges?: (taskId: string, filePath?: string) => void;
|
onViewChanges?: (taskId: string, filePath?: string) => void;
|
||||||
onOpenInEditor?: (filePath: string) => void;
|
onOpenInEditor?: (filePath: string) => void;
|
||||||
onDeleteTask?: (taskId: string) => void;
|
onDeleteTask?: (taskId: string) => void;
|
||||||
|
focusCommentId?: string;
|
||||||
/** Extra content rendered in the dialog header (e.g. "Open team" button). */
|
/** Extra content rendered in the dialog header (e.g. "Open team" button). */
|
||||||
headerExtra?: React.ReactNode;
|
headerExtra?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
@ -138,6 +139,7 @@ export const TaskDetailDialog = ({
|
||||||
onViewChanges,
|
onViewChanges,
|
||||||
onOpenInEditor,
|
onOpenInEditor,
|
||||||
onDeleteTask,
|
onDeleteTask,
|
||||||
|
focusCommentId,
|
||||||
headerExtra,
|
headerExtra,
|
||||||
}: TaskDetailDialogProps): React.JSX.Element => {
|
}: TaskDetailDialogProps): React.JSX.Element => {
|
||||||
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
|
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
|
||||||
|
|
@ -1392,6 +1394,7 @@ export const TaskDetailDialog = ({
|
||||||
}
|
}
|
||||||
containerClassName="-mx-6"
|
containerClassName="-mx-6"
|
||||||
unreadCommentIds={unreadSnapshotRef.current}
|
unreadCommentIds={unreadSnapshotRef.current}
|
||||||
|
focusCommentId={focusCommentId}
|
||||||
registerCommentForViewport={registerComment}
|
registerCommentForViewport={registerComment}
|
||||||
/>
|
/>
|
||||||
</CollapsibleTeamSection>
|
</CollapsibleTeamSection>
|
||||||
|
|
|
||||||
|
|
@ -420,6 +420,17 @@ export const MemberList = memo(function MemberList({
|
||||||
);
|
);
|
||||||
const removedMembers = useMemo(() => members.filter((m) => m.removedAt), [members]);
|
const removedMembers = useMemo(() => members.filter((m) => m.removedAt), [members]);
|
||||||
const colorMap = useMemo(() => buildMemberColorMap(members), [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(
|
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 (
|
return (
|
||||||
<div ref={containerRef} className="flex flex-col gap-1">
|
<div ref={containerRef} className="flex flex-col gap-1">
|
||||||
<div className={gridClass}>
|
<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 {
|
import {
|
||||||
createTeamSlice,
|
createTeamSlice,
|
||||||
getActiveTeamPendingReplyWaits,
|
getActiveTeamPendingReplyWaits,
|
||||||
|
getCurrentProvisioningProgressForTeam,
|
||||||
getLastResolvedTeamDataRefreshAt,
|
getLastResolvedTeamDataRefreshAt,
|
||||||
hasActiveTeamPendingReplyWait,
|
hasActiveTeamPendingReplyWait,
|
||||||
isTeamDataRefreshPending,
|
isTeamDataRefreshPending,
|
||||||
selectTeamDataForName,
|
selectTeamDataForName,
|
||||||
} from './slices/teamSlice';
|
} from './slices/teamSlice';
|
||||||
|
import {
|
||||||
|
decideProcessFanoutDryRun,
|
||||||
|
decideProcessFanoutMode,
|
||||||
|
type TeamProcessFanoutDecision,
|
||||||
|
} from './teamProcessFanoutDryRun';
|
||||||
|
import { installTeamRefreshFanoutDebugBridge } from './teamRefreshFanoutDebugBridge';
|
||||||
import {
|
import {
|
||||||
noteTeamRefreshFanout,
|
noteTeamRefreshFanout,
|
||||||
type TeamRefreshFanoutOperation,
|
type TeamRefreshFanoutOperation,
|
||||||
|
|
@ -63,6 +70,7 @@ import type {
|
||||||
LeadContextUsage,
|
LeadContextUsage,
|
||||||
ScheduleChangeEvent,
|
ScheduleChangeEvent,
|
||||||
TeamChangeEvent,
|
TeamChangeEvent,
|
||||||
|
TeamProvisioningProgress,
|
||||||
ToolActivityEventPayload,
|
ToolActivityEventPayload,
|
||||||
ToolApprovalEvent,
|
ToolApprovalEvent,
|
||||||
ToolApprovalRequest,
|
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_POLL_MS = 10_000;
|
||||||
const TEAM_VISIBLE_IDLE_WATCHDOG_STALE_MS = 30_000;
|
const TEAM_VISIBLE_IDLE_WATCHDOG_STALE_MS = 30_000;
|
||||||
const TEAM_MESSAGE_FALLBACK_POLL_MS = 10_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 =
|
const CURRENT_APP_VERSION =
|
||||||
typeof __APP_VERSION__ === 'string' ? normalizeVersion(__APP_VERSION__) : '0.0.0';
|
typeof __APP_VERSION__ === 'string' ? normalizeVersion(__APP_VERSION__) : '0.0.0';
|
||||||
const logger = createLogger('Store:index');
|
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 {
|
function noteTeamChangeEventBurst(teamName: string, eventType: string, visible: boolean): void {
|
||||||
if (!visible) return;
|
if (!visible) return;
|
||||||
|
|
||||||
|
|
@ -176,6 +198,7 @@ export const useStore = create<AppState>()((...args) => ({
|
||||||
export function initializeNotificationListeners(): () => void {
|
export function initializeNotificationListeners(): () => void {
|
||||||
void cleanupCommentReadState();
|
void cleanupCommentReadState();
|
||||||
const cleanupFns: (() => void)[] = [];
|
const cleanupFns: (() => void)[] = [];
|
||||||
|
cleanupFns.push(installTeamRefreshFanoutDebugBridge());
|
||||||
let cliStatusTimer: ReturnType<typeof setTimeout> | null = null;
|
let cliStatusTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
useStore.getState().subscribeProvisioningProgress();
|
useStore.getState().subscribeProvisioningProgress();
|
||||||
cleanupFns.push(() => {
|
cleanupFns.push(() => {
|
||||||
|
|
@ -248,6 +271,10 @@ export function initializeNotificationListeners(): () => void {
|
||||||
let memberSpawnRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
let memberSpawnRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
let teamAgentRuntimeRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
let teamAgentRuntimeRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
let toolActivityTimers = 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 inProgressChangePresencePollInFlight = false;
|
||||||
let teamMessageFallbackPollInFlight = false;
|
let teamMessageFallbackPollInFlight = false;
|
||||||
const inProgressChangePresenceCursorByTeam = new Map<string, number>();
|
const inProgressChangePresenceCursorByTeam = new Map<string, number>();
|
||||||
|
|
@ -263,7 +290,12 @@ export function initializeNotificationListeners(): () => void {
|
||||||
const TEAM_MEMBER_SPAWN_REFRESH_THROTTLE_MS = 500;
|
const TEAM_MEMBER_SPAWN_REFRESH_THROTTLE_MS = 500;
|
||||||
const TEAM_LIST_REFRESH_THROTTLE_MS = 2000;
|
const TEAM_LIST_REFRESH_THROTTLE_MS = 2000;
|
||||||
const GLOBAL_TASKS_REFRESH_THROTTLE_MS = 500;
|
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 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 = (
|
const addPendingGlobalRefreshDiagnostic = (
|
||||||
pending: Map<string, Set<string>>,
|
pending: Map<string, Set<string>>,
|
||||||
teamName: string,
|
teamName: string,
|
||||||
|
|
@ -327,61 +359,110 @@ export function initializeNotificationListeners(): () => void {
|
||||||
// Best-effort refresh for message-driven events and fallback polling only.
|
// 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)) {
|
if (!teamName || !isTeamVisibleInAnyPane(teamName)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const existingTimer = memberSpawnRefreshTimers.get(teamName);
|
const reason = options.reason ?? 'event:member-spawn';
|
||||||
|
const timerKey = buildRuntimeRefreshTimerKey(teamName, reason);
|
||||||
|
const existingTimer = memberSpawnRefreshTimers.get(timerKey);
|
||||||
noteTeamRefreshFanout({
|
noteTeamRefreshFanout({
|
||||||
teamName,
|
teamName,
|
||||||
surface: 'team-change-listener',
|
surface: 'team-change-listener',
|
||||||
phase: existingTimer ? 'coalesced' : 'scheduled',
|
phase: existingTimer ? 'coalesced' : 'scheduled',
|
||||||
reason: 'event:member-spawn',
|
reason,
|
||||||
operation: 'fetchMemberSpawnStatuses',
|
operation: 'fetchMemberSpawnStatuses',
|
||||||
});
|
});
|
||||||
if (memberSpawnRefreshTimers.has(teamName)) {
|
if (memberSpawnRefreshTimers.has(timerKey)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const timer = setTimeout(() => {
|
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({
|
noteTeamRefreshFanout({
|
||||||
teamName,
|
teamName,
|
||||||
surface: 'team-change-listener',
|
surface: 'team-change-listener',
|
||||||
phase: 'executed',
|
phase: 'executed',
|
||||||
reason: 'event:member-spawn',
|
reason,
|
||||||
operation: 'fetchMemberSpawnStatuses',
|
operation: 'fetchMemberSpawnStatuses',
|
||||||
});
|
});
|
||||||
void useStore.getState().fetchMemberSpawnStatuses(teamName);
|
void useStore.getState().fetchMemberSpawnStatuses(teamName);
|
||||||
}, TEAM_MEMBER_SPAWN_REFRESH_THROTTLE_MS);
|
}, 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)) {
|
if (!teamName || !isTeamVisibleInAnyPane(teamName)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const existingTimer = teamAgentRuntimeRefreshTimers.get(teamName);
|
const reason = options.reason ?? 'event:member-spawn';
|
||||||
|
const timerKey = buildRuntimeRefreshTimerKey(teamName, reason);
|
||||||
|
const existingTimer = teamAgentRuntimeRefreshTimers.get(timerKey);
|
||||||
noteTeamRefreshFanout({
|
noteTeamRefreshFanout({
|
||||||
teamName,
|
teamName,
|
||||||
surface: 'team-change-listener',
|
surface: 'team-change-listener',
|
||||||
phase: existingTimer ? 'coalesced' : 'scheduled',
|
phase: existingTimer ? 'coalesced' : 'scheduled',
|
||||||
reason: 'event:member-spawn',
|
reason,
|
||||||
operation: 'fetchTeamAgentRuntime',
|
operation: 'fetchTeamAgentRuntime',
|
||||||
});
|
});
|
||||||
if (teamAgentRuntimeRefreshTimers.has(teamName)) {
|
if (teamAgentRuntimeRefreshTimers.has(timerKey)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const timer = setTimeout(() => {
|
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({
|
noteTeamRefreshFanout({
|
||||||
teamName,
|
teamName,
|
||||||
surface: 'team-change-listener',
|
surface: 'team-change-listener',
|
||||||
phase: 'executed',
|
phase: 'executed',
|
||||||
reason: 'event:member-spawn',
|
reason,
|
||||||
operation: 'fetchTeamAgentRuntime',
|
operation: 'fetchTeamAgentRuntime',
|
||||||
});
|
});
|
||||||
void useStore.getState().fetchTeamAgentRuntime(teamName);
|
void useStore.getState().fetchTeamAgentRuntime(teamName);
|
||||||
}, TEAM_MEMBER_SPAWN_REFRESH_THROTTLE_MS);
|
}, 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 = (
|
const scheduleTrackedTeamMessageRefresh = (
|
||||||
teamName: string | null | undefined,
|
teamName: string | null | undefined,
|
||||||
|
|
@ -787,6 +868,128 @@ export function initializeNotificationListeners(): () => void {
|
||||||
|
|
||||||
return activeTab.teamName;
|
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> => {
|
const pollTrackedTeamMessageFallback = async (): Promise<void> => {
|
||||||
if (teamMessageFallbackPollInFlight) {
|
if (teamMessageFallbackPollInFlight) {
|
||||||
|
|
@ -1369,6 +1572,48 @@ export function initializeNotificationListeners(): () => void {
|
||||||
return;
|
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);
|
const eventReason = buildTeamChangeFanoutReason(event.type);
|
||||||
|
|
||||||
// Throttled refresh of summary list (keeps TeamListView current without flooding).
|
// 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.
|
// Per-team throttle (not debounce): keep at most one pending detail refresh per team.
|
||||||
// Debounce would delay indefinitely while inbox messages keep arriving.
|
// Debounce would delay indefinitely while inbox messages keep arriving.
|
||||||
|
cancelProcessLiteStructuralReconcile(event.teamName);
|
||||||
const selectedForRefresh = useStore.getState().selectedTeamName === event.teamName;
|
const selectedForRefresh = useStore.getState().selectedTeamName === event.teamName;
|
||||||
const activeTabForRefresh = getFocusedVisibleTeamName() === event.teamName;
|
const activeTabForRefresh = getFocusedVisibleTeamName() === event.teamName;
|
||||||
const existingDetailTimer = teamRefreshTimers.get(event.teamName);
|
const existingDetailTimer = teamRefreshTimers.get(event.teamName);
|
||||||
|
|
@ -1468,6 +1714,10 @@ export function initializeNotificationListeners(): () => void {
|
||||||
teamAgentRuntimeRefreshTimers = new Map();
|
teamAgentRuntimeRefreshTimers = new Map();
|
||||||
for (const t of toolActivityTimers.values()) clearTimeout(t);
|
for (const t of toolActivityTimers.values()) clearTimeout(t);
|
||||||
toolActivityTimers = new Map();
|
toolActivityTimers = new Map();
|
||||||
|
for (const state of processLiteStructuralReconcileTimers.values()) {
|
||||||
|
clearTimeout(state.timer);
|
||||||
|
}
|
||||||
|
processLiteStructuralReconcileTimers = new Map();
|
||||||
teamLastRelevantActivityAt.clear();
|
teamLastRelevantActivityAt.clear();
|
||||||
teamLastIdleWatchdogRefreshAt.clear();
|
teamLastIdleWatchdogRefreshAt.clear();
|
||||||
if (teamListRefreshTimer) {
|
if (teamListRefreshTimer) {
|
||||||
|
|
|
||||||
|
|
@ -10,11 +10,93 @@ import { getAllTabs } from '../utils/paneHelpers';
|
||||||
|
|
||||||
import type { AppState } from '../types';
|
import type { AppState } from '../types';
|
||||||
import type { DetectedError } from '@renderer/types/data';
|
import type { DetectedError } from '@renderer/types/data';
|
||||||
|
import type { NotificationTarget } from '@shared/types';
|
||||||
import type { StateCreator } from 'zustand';
|
import type { StateCreator } from 'zustand';
|
||||||
|
|
||||||
const logger = createLogger('Store:notification');
|
const logger = createLogger('Store:notification');
|
||||||
const NOTIFICATIONS_FETCH_LIMIT = 200;
|
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
|
// Slice Interface
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -206,10 +288,9 @@ export const createNotificationSlice: StateCreator<AppState, [], [], Notificatio
|
||||||
// Mark the notification as read
|
// Mark the notification as read
|
||||||
void state.markNotificationRead(error.id);
|
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:')) {
|
if (error.sessionId.startsWith('team:')) {
|
||||||
const teamName = error.sessionId.slice('team:'.length);
|
navigateToTeamNotification(state, error);
|
||||||
state.openTeamTab(teamName, error.context.cwd);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1103,6 +1103,13 @@ function fireClarificationNotification(task: GlobalTask, suppressToast: boolean)
|
||||||
body,
|
body,
|
||||||
teamEventType: 'task_clarification',
|
teamEventType: 'task_clarification',
|
||||||
dedupeKey: `clarification:${task.teamName}:${task.id}:${task.updatedAt ?? Date.now()}`,
|
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,
|
suppressToast,
|
||||||
})
|
})
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
|
|
@ -1199,6 +1206,12 @@ function fireStatusChangeNotification(
|
||||||
body: task.subject,
|
body: task.subject,
|
||||||
teamEventType: 'task_status_change',
|
teamEventType: 'task_status_change',
|
||||||
dedupeKey: `status:${task.teamName}:${task.id}:${fromStatus}:${toStatus}:${task.updatedAt ?? Date.now()}`,
|
dedupeKey: `status:${task.teamName}:${task.id}:${fromStatus}:${toStatus}:${task.updatedAt ?? Date.now()}`,
|
||||||
|
target: {
|
||||||
|
kind: 'task',
|
||||||
|
teamName: task.teamName,
|
||||||
|
taskId: task.id,
|
||||||
|
focus: 'status',
|
||||||
|
},
|
||||||
suppressToast,
|
suppressToast,
|
||||||
})
|
})
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
|
|
@ -1256,6 +1269,13 @@ function fireTaskCommentNotification(
|
||||||
body: preview,
|
body: preview,
|
||||||
teamEventType: 'task_comment',
|
teamEventType: 'task_comment',
|
||||||
dedupeKey: `comment:${task.teamName}:${task.id}:${comment.id}`,
|
dedupeKey: `comment:${task.teamName}:${task.id}:${comment.id}`,
|
||||||
|
target: {
|
||||||
|
kind: 'task',
|
||||||
|
teamName: task.teamName,
|
||||||
|
taskId: task.id,
|
||||||
|
commentId: comment.id,
|
||||||
|
focus: 'comments',
|
||||||
|
},
|
||||||
suppressToast,
|
suppressToast,
|
||||||
})
|
})
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
|
|
@ -1289,6 +1309,12 @@ function fireTaskCreatedNotification(task: GlobalTask, suppressToast: boolean):
|
||||||
body: stripAgentBlocks(task.description || task.subject).trim(),
|
body: stripAgentBlocks(task.description || task.subject).trim(),
|
||||||
teamEventType: 'task_created',
|
teamEventType: 'task_created',
|
||||||
dedupeKey: `created:${task.teamName}:${task.id}`,
|
dedupeKey: `created:${task.teamName}:${task.id}`,
|
||||||
|
target: {
|
||||||
|
kind: 'task',
|
||||||
|
teamName: task.teamName,
|
||||||
|
taskId: task.id,
|
||||||
|
focus: 'detail',
|
||||||
|
},
|
||||||
suppressToast,
|
suppressToast,
|
||||||
})
|
})
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
|
|
@ -1347,6 +1373,11 @@ function fireAllTasksCompletedNotification(
|
||||||
body: `All tasks in team "${sampleTask.teamDisplayName}" are done`,
|
body: `All tasks in team "${sampleTask.teamDisplayName}" are done`,
|
||||||
teamEventType: 'all_tasks_completed',
|
teamEventType: 'all_tasks_completed',
|
||||||
dedupeKey: `all-done:${sampleTask.teamName}:${Date.now()}`,
|
dedupeKey: `all-done:${sampleTask.teamName}:${Date.now()}`,
|
||||||
|
target: {
|
||||||
|
kind: 'team',
|
||||||
|
teamName: sampleTask.teamName,
|
||||||
|
section: 'tasks',
|
||||||
|
},
|
||||||
suppressToast,
|
suppressToast,
|
||||||
})
|
})
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
|
|
@ -1448,6 +1479,13 @@ function mapReviewError(error: unknown): string {
|
||||||
export interface GlobalTaskDetailState {
|
export interface GlobalTaskDetailState {
|
||||||
teamName: string;
|
teamName: string;
|
||||||
taskId: 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. */
|
/** Per-team launch parameters shown in the header badge. */
|
||||||
|
|
@ -1584,6 +1622,9 @@ export function selectTeamDataForName(
|
||||||
if (!teamName) {
|
if (!teamName) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
if (state.selectedTeamName === teamName && state.selectedTeamData) {
|
||||||
|
return state.selectedTeamData;
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
state.teamDataCacheByName[teamName] ??
|
state.teamDataCacheByName[teamName] ??
|
||||||
(state.selectedTeamName === teamName ? state.selectedTeamData : null)
|
(state.selectedTeamName === teamName ? state.selectedTeamData : null)
|
||||||
|
|
@ -1931,11 +1972,15 @@ export interface TeamSlice {
|
||||||
globalTasksInitialized: boolean;
|
globalTasksInitialized: boolean;
|
||||||
globalTasksError: string | null;
|
globalTasksError: string | null;
|
||||||
globalTaskDetail: GlobalTaskDetailState | null;
|
globalTaskDetail: GlobalTaskDetailState | null;
|
||||||
openGlobalTaskDetail: (teamName: string, taskId: string) => void;
|
openGlobalTaskDetail: (teamName: string, taskId: string, commentId?: string) => void;
|
||||||
closeGlobalTaskDetail: () => void;
|
closeGlobalTaskDetail: () => void;
|
||||||
/** Set by MemberHoverCard to signal TeamDetailView to open MemberDetailDialog */
|
/** Set by MemberHoverCard to signal TeamDetailView to open MemberDetailDialog */
|
||||||
pendingMemberProfile: string | null;
|
pendingMemberProfile: PendingMemberProfileState | null;
|
||||||
openMemberProfile: (memberName: string) => void;
|
openMemberProfile: (
|
||||||
|
memberName: string,
|
||||||
|
teamName?: string,
|
||||||
|
focus?: PendingMemberProfileState['focus']
|
||||||
|
) => void;
|
||||||
closeMemberProfile: () => void;
|
closeMemberProfile: () => void;
|
||||||
/** Set by GlobalTaskDetailDialog to signal TeamDetailView to open ChangeReviewDialog */
|
/** Set by GlobalTaskDetailDialog to signal TeamDetailView to open ChangeReviewDialog */
|
||||||
pendingReviewRequest: {
|
pendingReviewRequest: {
|
||||||
|
|
@ -2457,12 +2502,13 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
kanbanFilterQuery: null,
|
kanbanFilterQuery: null,
|
||||||
globalTaskDetail: null,
|
globalTaskDetail: null,
|
||||||
pendingMemberProfile: 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 }),
|
closeMemberProfile: () => set({ pendingMemberProfile: null }),
|
||||||
pendingReviewRequest: null,
|
pendingReviewRequest: null,
|
||||||
setPendingReviewRequest: (req) => set({ pendingReviewRequest: req }),
|
setPendingReviewRequest: (req) => set({ pendingReviewRequest: req }),
|
||||||
openGlobalTaskDetail: (teamName: string, taskId: string) => {
|
openGlobalTaskDetail: (teamName: string, taskId: string, commentId?: string) => {
|
||||||
set({ globalTaskDetail: { teamName, taskId } });
|
set({ globalTaskDetail: { teamName, taskId, commentId } });
|
||||||
},
|
},
|
||||||
closeGlobalTaskDetail: () => set({ globalTaskDetail: null }),
|
closeGlobalTaskDetail: () => set({ globalTaskDetail: null }),
|
||||||
addingComment: false,
|
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'
|
| 'fetchTeamMessageHead'
|
||||||
| 'fetchMemberSpawnStatuses'
|
| 'fetchMemberSpawnStatuses'
|
||||||
| 'fetchTeamAgentRuntime'
|
| 'fetchTeamAgentRuntime'
|
||||||
| 'refreshTaskChangePresence';
|
| 'refreshTaskChangePresence'
|
||||||
|
| 'wouldUseProcessLite'
|
||||||
|
| 'wouldKeepStructuralProcess';
|
||||||
|
|
||||||
export interface TeamRefreshFanoutNote {
|
export interface TeamRefreshFanoutNote {
|
||||||
teamName: string;
|
teamName: string;
|
||||||
|
|
@ -44,12 +46,32 @@ export interface TeamRefreshFanoutRecentNote {
|
||||||
|
|
||||||
export interface TeamRefreshFanoutSnapshot {
|
export interface TeamRefreshFanoutSnapshot {
|
||||||
counts: Record<string, number>;
|
counts: Record<string, number>;
|
||||||
|
structuredCounts: Record<string, TeamRefreshFanoutStructuredCount>;
|
||||||
recent: TeamRefreshFanoutRecentNote[];
|
recent: TeamRefreshFanoutRecentNote[];
|
||||||
lastAt: number;
|
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 {
|
interface TeamRefreshFanoutBucket {
|
||||||
counts: Record<string, number>;
|
counts: Record<string, number>;
|
||||||
|
structuredCounts: Record<string, TeamRefreshFanoutStructuredCount>;
|
||||||
recent: TeamRefreshFanoutRecentNote[];
|
recent: TeamRefreshFanoutRecentNote[];
|
||||||
lastAt: number;
|
lastAt: number;
|
||||||
}
|
}
|
||||||
|
|
@ -62,6 +84,7 @@ const buckets = new Map<string, TeamRefreshFanoutBucket>();
|
||||||
function createEmptyBucket(): TeamRefreshFanoutBucket {
|
function createEmptyBucket(): TeamRefreshFanoutBucket {
|
||||||
return {
|
return {
|
||||||
counts: {},
|
counts: {},
|
||||||
|
structuredCounts: {},
|
||||||
recent: [],
|
recent: [],
|
||||||
lastAt: 0,
|
lastAt: 0,
|
||||||
};
|
};
|
||||||
|
|
@ -93,6 +116,9 @@ function cloneBucket(
|
||||||
|
|
||||||
return {
|
return {
|
||||||
counts: { ...bucket.counts },
|
counts: { ...bucket.counts },
|
||||||
|
structuredCounts: Object.fromEntries(
|
||||||
|
Object.entries(bucket.structuredCounts).map(([key, value]) => [key, { ...value }])
|
||||||
|
),
|
||||||
recent: bucket.recent.map((note) => ({ ...note })),
|
recent: bucket.recent.map((note) => ({ ...note })),
|
||||||
lastAt: bucket.lastAt,
|
lastAt: bucket.lastAt,
|
||||||
};
|
};
|
||||||
|
|
@ -112,6 +138,15 @@ export function noteTeamRefreshFanout(note: TeamRefreshFanoutNote): void {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
bucket.counts[key] = (bucket.counts[key] ?? 0) + 1;
|
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.lastAt = now;
|
||||||
bucket.recent.push({
|
bucket.recent.push({
|
||||||
at: now,
|
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
|
teamName?: string
|
||||||
): TeamRefreshFanoutSnapshot | Record<string, TeamRefreshFanoutSnapshot> | null {
|
): TeamRefreshFanoutSnapshot | Record<string, TeamRefreshFanoutSnapshot> | null {
|
||||||
if (teamName) {
|
if (teamName) {
|
||||||
|
|
@ -143,6 +189,36 @@ export function getTeamRefreshFanoutSnapshotForTests(
|
||||||
) as Record<string, TeamRefreshFanoutSnapshot>;
|
) as Record<string, TeamRefreshFanoutSnapshot>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function __resetTeamRefreshFanoutDiagnosticsForTests(): void {
|
export function resetTeamRefreshFanoutDiagnostics(): void {
|
||||||
buckets.clear();
|
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'
|
| 'schedule_failed'
|
||||||
| 'team_launched';
|
| '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.
|
* Detected error from session JSONL files.
|
||||||
* Used for notification display and deep linking to error locations.
|
* Used for notification display and deep linking to error locations.
|
||||||
|
|
@ -72,6 +92,8 @@ export interface DetectedError {
|
||||||
category?: 'error' | 'team';
|
category?: 'error' | 'team';
|
||||||
/** For team notifications: specific event sub-type */
|
/** For team notifications: specific event sub-type */
|
||||||
teamEventType?: TeamEventType;
|
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. */
|
/** Explicit key for storage deduplication. Two notifications with the same dedupeKey won't be stored twice. */
|
||||||
dedupeKey?: string;
|
dedupeKey?: string;
|
||||||
/** Additional context */
|
/** Additional context */
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import type { EnhancedChunk } from '@main/types';
|
import type { EnhancedChunk } from '@main/types';
|
||||||
|
import type { NotificationTarget, TeamEventType } from './notifications';
|
||||||
|
|
||||||
export interface TeamMember {
|
export interface TeamMember {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -1503,12 +1504,9 @@ export interface TeamMessageNotificationData {
|
||||||
/** Optional sender color for visual context. */
|
/** Optional sender color for visual context. */
|
||||||
color?: string;
|
color?: string;
|
||||||
/** Team event sub-type for notification categorization. */
|
/** Team event sub-type for notification categorization. */
|
||||||
teamEventType?:
|
teamEventType?: TeamEventType;
|
||||||
| 'task_clarification'
|
/** Structured destination used when clicking the OS or in-app notification. */
|
||||||
| 'task_status_change'
|
target?: NotificationTarget;
|
||||||
| 'task_comment'
|
|
||||||
| 'task_created'
|
|
||||||
| 'all_tasks_completed';
|
|
||||||
/** Stable key for storage deduplication. Required — no fallback to Date.now(). */
|
/** Stable key for storage deduplication. Required — no fallback to Date.now(). */
|
||||||
dedupeKey?: string;
|
dedupeKey?: string;
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -32,11 +32,18 @@ import {
|
||||||
} from '../../../../src/main/utils/pathDecoder';
|
} from '../../../../src/main/utils/pathDecoder';
|
||||||
import { createPersistedLaunchSnapshot } from '../../../../src/main/services/team/TeamLaunchStateEvaluator';
|
import { createPersistedLaunchSnapshot } from '../../../../src/main/services/team/TeamLaunchStateEvaluator';
|
||||||
import {
|
import {
|
||||||
|
getOpenCodeRuntimeManifestPath,
|
||||||
getOpenCodeRuntimeLaneIndexPath,
|
getOpenCodeRuntimeLaneIndexPath,
|
||||||
readOpenCodeRuntimeLaneIndex,
|
readOpenCodeRuntimeLaneIndex,
|
||||||
setOpenCodeRuntimeActiveRunManifest,
|
setOpenCodeRuntimeActiveRunManifest,
|
||||||
upsertOpenCodeRuntimeLaneIndexEntry,
|
upsertOpenCodeRuntimeLaneIndexEntry,
|
||||||
} from '../../../../src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader';
|
} 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';
|
import type { TeamProvisioningProgress } from '../../../../src/shared/types';
|
||||||
|
|
||||||
|
|
@ -9921,6 +9928,7 @@ describe('Team agent launch matrix safe e2e', () => {
|
||||||
addGeminiPrimaryToMixedRun(currentRun);
|
addGeminiPrimaryToMixedRun(currentRun);
|
||||||
staleRun.runId = `run-${teamName}-stale`;
|
staleRun.runId = `run-${teamName}-stale`;
|
||||||
currentRun.runId = `run-${teamName}-current`;
|
currentRun.runId = `run-${teamName}-current`;
|
||||||
|
markMixedOpenCodeLaneConfirmedForTest(currentRun, 'bob');
|
||||||
trackLiveRun(svc, staleRun);
|
trackLiveRun(svc, staleRun);
|
||||||
trackLiveRun(svc, currentRun);
|
trackLiveRun(svc, currentRun);
|
||||||
|
|
||||||
|
|
@ -9990,6 +9998,7 @@ describe('Team agent launch matrix safe e2e', () => {
|
||||||
addGeminiPrimaryToMixedRun(secondRun);
|
addGeminiPrimaryToMixedRun(secondRun);
|
||||||
firstRun.child = { stdin: { writable: true } };
|
firstRun.child = { stdin: { writable: true } };
|
||||||
secondRun.child = { stdin: { writable: true } };
|
secondRun.child = { stdin: { writable: true } };
|
||||||
|
markMixedOpenCodeLaneConfirmedForTest(secondRun, 'bob');
|
||||||
trackLiveRun(svc, firstRun);
|
trackLiveRun(svc, firstRun);
|
||||||
trackLiveRun(svc, secondRun);
|
trackLiveRun(svc, secondRun);
|
||||||
|
|
||||||
|
|
@ -10268,6 +10277,7 @@ describe('Team agent launch matrix safe e2e', () => {
|
||||||
const currentRun = createMixedLiveRun({ teamName, projectPath, primaryProviderId: 'anthropic' });
|
const currentRun = createMixedLiveRun({ teamName, projectPath, primaryProviderId: 'anthropic' });
|
||||||
addGeminiPrimaryToMixedRun(currentRun);
|
addGeminiPrimaryToMixedRun(currentRun);
|
||||||
currentRun.runId = `run-${teamName}-current`;
|
currentRun.runId = `run-${teamName}-current`;
|
||||||
|
markMixedOpenCodeLaneConfirmedForTest(currentRun, 'bob');
|
||||||
trackLiveRun(svc, currentRun);
|
trackLiveRun(svc, currentRun);
|
||||||
injectStaleTerminalProvisioningRun(svc, teamName, `run-${teamName}-stale`);
|
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';
|
const teamName = 'mixed-opencode-direct-message-recovers-missing-lane-safe-e2e';
|
||||||
await writeMixedTeamConfig({ teamName, projectPath });
|
await writeMixedTeamConfig({ teamName, projectPath });
|
||||||
await writeTeamMeta(teamName, projectPath);
|
await writeTeamMeta(teamName, projectPath);
|
||||||
|
|
@ -11268,8 +11278,11 @@ describe('Team agent launch matrix safe e2e', () => {
|
||||||
messageId: 'msg-recovered-missing-lane-bob',
|
messageId: 'msg-recovered-missing-lane-bob',
|
||||||
})
|
})
|
||||||
).resolves.toEqual({
|
).resolves.toEqual({
|
||||||
delivered: true,
|
delivered: false,
|
||||||
diagnostics: [],
|
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);
|
expect(adapter.reconcileInputs).toHaveLength(1);
|
||||||
|
|
@ -11287,15 +11300,7 @@ describe('Team agent launch matrix safe e2e', () => {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
expect(adapter.messageInputs).toHaveLength(1);
|
expect(adapter.messageInputs).toEqual([]);
|
||||||
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',
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('recovers a missing mixed OpenCode lane index from confirmed-alive persisted runtime evidence before direct delivery', async () => {
|
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', {
|
const adapter = new FakeOpenCodeRuntimeAdapter('clean_success', {
|
||||||
bob: 'confirmed',
|
bob: 'confirmed',
|
||||||
});
|
});
|
||||||
|
await writeOpenCodeBootstrapSessionEvidenceForTest({
|
||||||
|
teamName,
|
||||||
|
laneId: 'secondary:opencode:bob',
|
||||||
|
memberName: 'bob',
|
||||||
|
runId: null,
|
||||||
|
sessionId: 'ses_bob_confirmed_materialized',
|
||||||
|
});
|
||||||
await writeAliveProcessRegistry(teamName);
|
await writeAliveProcessRegistry(teamName);
|
||||||
const restartedService = new TeamProvisioningService();
|
const restartedService = new TeamProvisioningService();
|
||||||
restartedService.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter]));
|
restartedService.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter]));
|
||||||
|
|
@ -12641,6 +12653,7 @@ describe('Team agent launch matrix safe e2e', () => {
|
||||||
svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter]));
|
svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter]));
|
||||||
const run = createMixedLiveRun({ teamName, projectPath, primaryProviderId: 'anthropic' });
|
const run = createMixedLiveRun({ teamName, projectPath, primaryProviderId: 'anthropic' });
|
||||||
addGeminiPrimaryToMixedRun(run);
|
addGeminiPrimaryToMixedRun(run);
|
||||||
|
markMixedOpenCodeLaneConfirmedForTest(run, 'tom');
|
||||||
trackLiveRun(svc, run);
|
trackLiveRun(svc, run);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
|
|
@ -12696,6 +12709,7 @@ describe('Team agent launch matrix safe e2e', () => {
|
||||||
svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter]));
|
svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter]));
|
||||||
const run = createMixedLiveRun({ teamName, projectPath, primaryProviderId: 'anthropic' });
|
const run = createMixedLiveRun({ teamName, projectPath, primaryProviderId: 'anthropic' });
|
||||||
addGeminiPrimaryToMixedRun(run);
|
addGeminiPrimaryToMixedRun(run);
|
||||||
|
markMixedOpenCodeLaneConfirmedForTest(run, 'bob');
|
||||||
trackLiveRun(svc, run);
|
trackLiveRun(svc, run);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
|
|
@ -17105,6 +17119,7 @@ async function upsertActiveOpenCodeRuntimeLaneForTest(input: {
|
||||||
runId?: string | null;
|
runId?: string | null;
|
||||||
diagnostics?: string[];
|
diagnostics?: string[];
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
|
const runId = input.runId ?? null;
|
||||||
await upsertOpenCodeRuntimeLaneIndexEntry({
|
await upsertOpenCodeRuntimeLaneIndexEntry({
|
||||||
teamsBasePath: getTeamsBasePath(),
|
teamsBasePath: getTeamsBasePath(),
|
||||||
teamName: input.teamName,
|
teamName: input.teamName,
|
||||||
|
|
@ -17116,7 +17131,76 @@ async function upsertActiveOpenCodeRuntimeLaneForTest(input: {
|
||||||
teamsBasePath: getTeamsBasePath(),
|
teamsBasePath: getTeamsBasePath(),
|
||||||
teamName: input.teamName,
|
teamName: input.teamName,
|
||||||
laneId: input.laneId,
|
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 {
|
function addGeminiPrimaryToMixedRun(run: any): void {
|
||||||
const now = '2026-04-23T10:00:00.000Z';
|
const now = '2026-04-23T10:00:00.000Z';
|
||||||
const reviewer = {
|
const reviewer = {
|
||||||
|
|
|
||||||
|
|
@ -167,15 +167,14 @@ describe('TeamLogSourceTracker', () => {
|
||||||
await tracker.disableTracking('demo', 'change_presence');
|
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-'));
|
tempDir = await mkdtemp(path.join(tmpdir(), 'team-log-source-tracker-pending-root-'));
|
||||||
let confirmed = false;
|
|
||||||
|
|
||||||
const logsFinder = {
|
const logsFinder = {
|
||||||
getLiveLogSourceWatchContext: vi.fn(async () => ({
|
getLiveLogSourceWatchContext: vi.fn(async () => ({
|
||||||
projectDir: tempDir!,
|
projectDir: tempDir!,
|
||||||
sessionIds: confirmed ? ['new-runtime'] : [],
|
sessionIds: ['new-runtime'],
|
||||||
watchSessionIds: confirmed ? ['new-runtime'] : [],
|
watchSessionIds: ['new-runtime'],
|
||||||
})),
|
})),
|
||||||
} as unknown as TeamMemberLogsFinder;
|
} as unknown as TeamMemberLogsFinder;
|
||||||
|
|
||||||
|
|
@ -187,7 +186,6 @@ describe('TeamLogSourceTracker', () => {
|
||||||
emitter.mockClear();
|
emitter.mockClear();
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
|
||||||
confirmed = true;
|
|
||||||
await writeFile(path.join(tempDir, 'new-runtime.jsonl'), '{"seq":1}\n');
|
await writeFile(path.join(tempDir, 'new-runtime.jsonl'), '{"seq":1}\n');
|
||||||
|
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
|
|
@ -327,6 +325,7 @@ describe('TeamLogSourceTracker', () => {
|
||||||
|
|
||||||
it('ignores internal ledger artifact paths but keeps freshness signals visible', () => {
|
it('ignores internal ledger artifact paths but keeps freshness signals visible', () => {
|
||||||
const projectDir = '/tmp/demo-project';
|
const projectDir = '/tmp/demo-project';
|
||||||
|
const scopedSessionIds = new Set(['lead-session']);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
shouldIgnoreLogSourceWatcherPath(
|
shouldIgnoreLogSourceWatcherPath(
|
||||||
|
|
@ -346,5 +345,71 @@ describe('TeamLogSourceTracker', () => {
|
||||||
path.join(projectDir, '.board-task-change-freshness', 'task.json')
|
path.join(projectDir, '.board-task-change-freshness', 'task.json')
|
||||||
)
|
)
|
||||||
).toBe(false);
|
).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(
|
function createMemberSpawnStatusEntry(
|
||||||
overrides: Record<string, unknown> = {}
|
overrides: Record<string, unknown> = {}
|
||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
|
|
@ -3944,6 +3962,7 @@ describe('TeamProvisioningService', () => {
|
||||||
memberName: 'bob',
|
memberName: 'bob',
|
||||||
cwd: '/repo',
|
cwd: '/repo',
|
||||||
});
|
});
|
||||||
|
await writeDefaultBobOpenCodeBootstrapEvidence();
|
||||||
(svc as any).configReader = {
|
(svc as any).configReader = {
|
||||||
getConfig: vi.fn(async () => ({
|
getConfig: vi.fn(async () => ({
|
||||||
projectPath: '/repo',
|
projectPath: '/repo',
|
||||||
|
|
@ -4043,6 +4062,7 @@ describe('TeamProvisioningService', () => {
|
||||||
memberName: 'bob',
|
memberName: 'bob',
|
||||||
cwd: '/repo',
|
cwd: '/repo',
|
||||||
});
|
});
|
||||||
|
await writeDefaultBobOpenCodeBootstrapEvidence();
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
svc.deliverOpenCodeMemberMessage('team-a', {
|
svc.deliverOpenCodeMemberMessage('team-a', {
|
||||||
|
|
@ -4127,6 +4147,7 @@ describe('TeamProvisioningService', () => {
|
||||||
memberName: 'bob',
|
memberName: 'bob',
|
||||||
cwd: '/repo/.agent-team-worktrees/bob',
|
cwd: '/repo/.agent-team-worktrees/bob',
|
||||||
});
|
});
|
||||||
|
await writeDefaultBobOpenCodeBootstrapEvidence();
|
||||||
(svc as any).resolveCurrentOpenCodeRuntimeRunId = vi.fn(async () => 'opencode-run-bob');
|
(svc as any).resolveCurrentOpenCodeRuntimeRunId = vi.fn(async () => 'opencode-run-bob');
|
||||||
(svc as any).isOpenCodeRuntimeLaneIndexActive = vi.fn(async () => true);
|
(svc as any).isOpenCodeRuntimeLaneIndexActive = vi.fn(async () => true);
|
||||||
(svc as any).configReader = {
|
(svc as any).configReader = {
|
||||||
|
|
@ -4236,6 +4257,7 @@ describe('TeamProvisioningService', () => {
|
||||||
memberName: 'bob',
|
memberName: 'bob',
|
||||||
cwd: '/repo',
|
cwd: '/repo',
|
||||||
});
|
});
|
||||||
|
await writeDefaultBobOpenCodeBootstrapEvidence();
|
||||||
(svc as any).configReader = {
|
(svc as any).configReader = {
|
||||||
getConfig: vi.fn(async () => ({
|
getConfig: vi.fn(async () => ({
|
||||||
projectPath: '/repo',
|
projectPath: '/repo',
|
||||||
|
|
@ -4373,6 +4395,7 @@ describe('TeamProvisioningService', () => {
|
||||||
memberName: 'bob',
|
memberName: 'bob',
|
||||||
cwd: '/repo',
|
cwd: '/repo',
|
||||||
});
|
});
|
||||||
|
await writeDefaultBobOpenCodeBootstrapEvidence();
|
||||||
(svc as any).configReader = {
|
(svc as any).configReader = {
|
||||||
getConfig: vi.fn(async () => ({
|
getConfig: vi.fn(async () => ({
|
||||||
projectPath: '/repo',
|
projectPath: '/repo',
|
||||||
|
|
@ -4502,6 +4525,7 @@ describe('TeamProvisioningService', () => {
|
||||||
memberName: 'bob',
|
memberName: 'bob',
|
||||||
cwd: '/repo',
|
cwd: '/repo',
|
||||||
});
|
});
|
||||||
|
await writeDefaultBobOpenCodeBootstrapEvidence();
|
||||||
(svc as any).configReader = {
|
(svc as any).configReader = {
|
||||||
getConfig: vi.fn(async () => ({
|
getConfig: vi.fn(async () => ({
|
||||||
projectPath: '/repo',
|
projectPath: '/repo',
|
||||||
|
|
@ -4590,6 +4614,7 @@ describe('TeamProvisioningService', () => {
|
||||||
memberName: 'bob',
|
memberName: 'bob',
|
||||||
cwd: '/repo',
|
cwd: '/repo',
|
||||||
});
|
});
|
||||||
|
await writeDefaultBobOpenCodeBootstrapEvidence();
|
||||||
(svc as any).configReader = {
|
(svc as any).configReader = {
|
||||||
getConfig: vi.fn(async () => ({
|
getConfig: vi.fn(async () => ({
|
||||||
projectPath: '/repo',
|
projectPath: '/repo',
|
||||||
|
|
@ -4698,6 +4723,7 @@ describe('TeamProvisioningService', () => {
|
||||||
memberName: 'bob',
|
memberName: 'bob',
|
||||||
cwd: '/repo',
|
cwd: '/repo',
|
||||||
});
|
});
|
||||||
|
await writeDefaultBobOpenCodeBootstrapEvidence();
|
||||||
(svc as any).configReader = {
|
(svc as any).configReader = {
|
||||||
getConfig: vi.fn(async () => ({
|
getConfig: vi.fn(async () => ({
|
||||||
projectPath: '/repo',
|
projectPath: '/repo',
|
||||||
|
|
@ -4781,6 +4807,7 @@ describe('TeamProvisioningService', () => {
|
||||||
memberName: 'bob',
|
memberName: 'bob',
|
||||||
cwd: '/repo',
|
cwd: '/repo',
|
||||||
});
|
});
|
||||||
|
await writeDefaultBobOpenCodeBootstrapEvidence();
|
||||||
(svc as any).configReader = {
|
(svc as any).configReader = {
|
||||||
getConfig: vi.fn(async () => ({
|
getConfig: vi.fn(async () => ({
|
||||||
projectPath: '/repo',
|
projectPath: '/repo',
|
||||||
|
|
@ -4893,6 +4920,7 @@ describe('TeamProvisioningService', () => {
|
||||||
memberName: 'bob',
|
memberName: 'bob',
|
||||||
cwd: '/repo',
|
cwd: '/repo',
|
||||||
});
|
});
|
||||||
|
await writeDefaultBobOpenCodeBootstrapEvidence();
|
||||||
(svc as any).configReader = {
|
(svc as any).configReader = {
|
||||||
getConfig: vi.fn(async () => ({
|
getConfig: vi.fn(async () => ({
|
||||||
projectPath: '/repo',
|
projectPath: '/repo',
|
||||||
|
|
@ -5348,6 +5376,7 @@ describe('TeamProvisioningService', () => {
|
||||||
memberName: 'bob',
|
memberName: 'bob',
|
||||||
cwd: '/repo',
|
cwd: '/repo',
|
||||||
});
|
});
|
||||||
|
await writeDefaultBobOpenCodeBootstrapEvidence();
|
||||||
(svc as any).configReader = {
|
(svc as any).configReader = {
|
||||||
getConfig: vi.fn(async () => ({
|
getConfig: vi.fn(async () => ({
|
||||||
projectPath: '/repo',
|
projectPath: '/repo',
|
||||||
|
|
@ -5464,6 +5493,7 @@ describe('TeamProvisioningService', () => {
|
||||||
memberName: 'bob',
|
memberName: 'bob',
|
||||||
cwd: '/repo',
|
cwd: '/repo',
|
||||||
});
|
});
|
||||||
|
await writeDefaultBobOpenCodeBootstrapEvidence();
|
||||||
(svc as any).configReader = {
|
(svc as any).configReader = {
|
||||||
getConfig: vi.fn(async () => ({
|
getConfig: vi.fn(async () => ({
|
||||||
projectPath: '/repo',
|
projectPath: '/repo',
|
||||||
|
|
@ -5589,6 +5619,7 @@ describe('TeamProvisioningService', () => {
|
||||||
memberName: 'bob',
|
memberName: 'bob',
|
||||||
cwd: '/repo',
|
cwd: '/repo',
|
||||||
});
|
});
|
||||||
|
await writeDefaultBobOpenCodeBootstrapEvidence();
|
||||||
(svc as any).configReader = {
|
(svc as any).configReader = {
|
||||||
getConfig: vi.fn(async () => ({
|
getConfig: vi.fn(async () => ({
|
||||||
projectPath: '/repo',
|
projectPath: '/repo',
|
||||||
|
|
@ -5694,6 +5725,7 @@ describe('TeamProvisioningService', () => {
|
||||||
memberName: 'bob',
|
memberName: 'bob',
|
||||||
cwd: '/repo',
|
cwd: '/repo',
|
||||||
});
|
});
|
||||||
|
await writeDefaultBobOpenCodeBootstrapEvidence();
|
||||||
(svc as any).configReader = {
|
(svc as any).configReader = {
|
||||||
getConfig: vi.fn(async () => ({
|
getConfig: vi.fn(async () => ({
|
||||||
projectPath: '/repo',
|
projectPath: '/repo',
|
||||||
|
|
@ -5841,6 +5873,7 @@ describe('TeamProvisioningService', () => {
|
||||||
memberName: 'bob',
|
memberName: 'bob',
|
||||||
cwd: '/repo',
|
cwd: '/repo',
|
||||||
});
|
});
|
||||||
|
await writeDefaultBobOpenCodeBootstrapEvidence();
|
||||||
(svc as any).configReader = {
|
(svc as any).configReader = {
|
||||||
getConfig: vi.fn(async () => ({
|
getConfig: vi.fn(async () => ({
|
||||||
projectPath: '/repo',
|
projectPath: '/repo',
|
||||||
|
|
@ -5964,6 +5997,7 @@ describe('TeamProvisioningService', () => {
|
||||||
memberName: 'bob',
|
memberName: 'bob',
|
||||||
cwd: '/repo',
|
cwd: '/repo',
|
||||||
});
|
});
|
||||||
|
await writeDefaultBobOpenCodeBootstrapEvidence();
|
||||||
(svc as any).configReader = {
|
(svc as any).configReader = {
|
||||||
getConfig: vi.fn(async () => ({
|
getConfig: vi.fn(async () => ({
|
||||||
projectPath: '/repo',
|
projectPath: '/repo',
|
||||||
|
|
@ -6102,25 +6136,21 @@ describe('TeamProvisioningService', () => {
|
||||||
laneId,
|
laneId,
|
||||||
state: 'active',
|
state: 'active',
|
||||||
});
|
});
|
||||||
const manifestPath = getOpenCodeRuntimeManifestPath(tempTeamsBase, teamName, laneId);
|
await writeCommittedOpenCodeSessionStore({
|
||||||
await fsPromises.mkdir(path.dirname(manifestPath), { recursive: true });
|
teamName,
|
||||||
await fsPromises.writeFile(
|
laneId,
|
||||||
manifestPath,
|
runId: 'opencode-run-durable',
|
||||||
`${JSON.stringify(
|
sessions: [
|
||||||
{
|
{
|
||||||
...createDefaultRuntimeStoreManifest(teamName, '2026-04-22T12:00:00.000Z'),
|
id: 'oc-session-bob',
|
||||||
activeRunId: 'opencode-run-durable',
|
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(
|
await expect(
|
||||||
svc.deliverOpenCodeMemberMessage(teamName, {
|
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 () => {
|
it('rejects stale active lane manifest without runtime evidence before delivery', async () => {
|
||||||
const svc = new TeamProvisioningService();
|
const svc = new TeamProvisioningService();
|
||||||
const teamName = 'team-a';
|
const teamName = 'team-a';
|
||||||
|
|
@ -6385,25 +6494,21 @@ describe('TeamProvisioningService', () => {
|
||||||
laneId,
|
laneId,
|
||||||
state: 'active',
|
state: 'active',
|
||||||
});
|
});
|
||||||
const manifestPath = getOpenCodeRuntimeManifestPath(tempTeamsBase, teamName, laneId);
|
await writeCommittedOpenCodeSessionStore({
|
||||||
await fsPromises.mkdir(path.dirname(manifestPath), { recursive: true });
|
teamName,
|
||||||
await fsPromises.writeFile(
|
laneId,
|
||||||
manifestPath,
|
runId: 'opencode-run-from-manifest',
|
||||||
`${JSON.stringify(
|
sessions: [
|
||||||
{
|
{
|
||||||
...createDefaultRuntimeStoreManifest(teamName, '2026-04-22T12:00:00.000Z'),
|
id: 'oc-session-bob',
|
||||||
activeRunId: 'opencode-run-from-manifest',
|
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(
|
await expect(
|
||||||
svc.deliverOpenCodeMemberMessage(teamName, {
|
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 teamName = 'mixed-runtime-materialized-failure';
|
||||||
const svc = new TeamProvisioningService();
|
const svc = new TeamProvisioningService();
|
||||||
const adapterLaunch = vi.fn(async (input: Record<string, unknown>) => ({
|
const adapterLaunch = vi.fn(async (input: Record<string, unknown>) => ({
|
||||||
|
|
@ -6660,17 +6869,36 @@ describe('TeamProvisioningService', () => {
|
||||||
await vi.waitFor(
|
await vi.waitFor(
|
||||||
async () => {
|
async () => {
|
||||||
expect(adapterLaunch).toHaveBeenCalledTimes(1);
|
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({
|
await expect(readOpenCodeRuntimeLaneIndex(tempTeamsBase, teamName)).resolves.toMatchObject({
|
||||||
lanes: {
|
lanes: {
|
||||||
'secondary:opencode:tom': {
|
'secondary:opencode:tom': {
|
||||||
state: 'degraded',
|
state: 'active',
|
||||||
diagnostics: expect.arrayContaining([
|
diagnostics: expect.arrayContaining([
|
||||||
'OpenCode bridge reported member launch failure',
|
'OpenCode bridge reported member launch failure',
|
||||||
'OpenCode bootstrap MCP did not complete required tools before assistant response: runtime_bootstrap_checkin, member_briefing',
|
'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 }
|
{ timeout: 5000 }
|
||||||
);
|
);
|
||||||
|
|
@ -11960,7 +12188,7 @@ describe('TeamProvisioningService', () => {
|
||||||
launchState: 'confirmed_alive',
|
launchState: 'confirmed_alive',
|
||||||
agentToolAccepted: true,
|
agentToolAccepted: true,
|
||||||
bootstrapConfirmed: true,
|
bootstrapConfirmed: true,
|
||||||
runtimeAlive: false,
|
runtimeAlive: true,
|
||||||
});
|
});
|
||||||
const persisted = JSON.parse(
|
const persisted = JSON.parse(
|
||||||
await fsPromises.readFile(getTeamLaunchStatePath(teamName), 'utf8')
|
await fsPromises.readFile(getTeamLaunchStatePath(teamName), 'utf8')
|
||||||
|
|
@ -11968,7 +12196,7 @@ describe('TeamProvisioningService', () => {
|
||||||
expect(persisted.members.tom).toMatchObject({
|
expect(persisted.members.tom).toMatchObject({
|
||||||
launchState: 'confirmed_alive',
|
launchState: 'confirmed_alive',
|
||||||
bootstrapConfirmed: true,
|
bootstrapConfirmed: true,
|
||||||
runtimeAlive: false,
|
runtimeAlive: true,
|
||||||
runtimeSessionId: 'ses-tom',
|
runtimeSessionId: 'ses-tom',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -12051,7 +12279,7 @@ describe('TeamProvisioningService', () => {
|
||||||
expect(persisted.members.tom).toMatchObject({
|
expect(persisted.members.tom).toMatchObject({
|
||||||
launchState: 'confirmed_alive',
|
launchState: 'confirmed_alive',
|
||||||
bootstrapConfirmed: true,
|
bootstrapConfirmed: true,
|
||||||
runtimeAlive: false,
|
runtimeAlive: true,
|
||||||
runtimeSessionId: 'ses-tom',
|
runtimeSessionId: 'ses-tom',
|
||||||
});
|
});
|
||||||
expect(persisted.teamLaunchState).toBe('clean_success');
|
expect(persisted.teamLaunchState).toBe('clean_success');
|
||||||
|
|
@ -12072,7 +12300,7 @@ describe('TeamProvisioningService', () => {
|
||||||
expect(persistedAfterMissingWrite.members.tom).toMatchObject({
|
expect(persistedAfterMissingWrite.members.tom).toMatchObject({
|
||||||
launchState: 'confirmed_alive',
|
launchState: 'confirmed_alive',
|
||||||
bootstrapConfirmed: true,
|
bootstrapConfirmed: true,
|
||||||
runtimeAlive: false,
|
runtimeAlive: true,
|
||||||
runtimeSessionId: 'ses-tom',
|
runtimeSessionId: 'ses-tom',
|
||||||
});
|
});
|
||||||
expect(persistedAfterMissingWrite.teamLaunchState).toBe('clean_success');
|
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', () => {
|
it('does not trust a stale persisted pid without current process identity', () => {
|
||||||
const result = resolveTeamMemberRuntimeLiveness({
|
const result = resolveTeamMemberRuntimeLiveness({
|
||||||
teamName: 'demo',
|
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(() => ({
|
const hoisted = vi.hoisted(() => ({
|
||||||
onTeamChangeCb: null as
|
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,
|
| null,
|
||||||
onProvisioningProgressCb: null as
|
onProvisioningProgressCb: null as
|
||||||
| ((event: unknown, data: { runId: string; teamName: string }) => void)
|
| ((event: unknown, data: { runId: string; teamName: string }) => void)
|
||||||
|
|
@ -35,7 +38,10 @@ vi.mock('@renderer/api', () => ({
|
||||||
setToolActivityTracking: vi.fn(async () => undefined),
|
setToolActivityTracking: vi.fn(async () => undefined),
|
||||||
onTeamChange: vi.fn(
|
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) => {
|
): (() => void) => {
|
||||||
hoisted.onTeamChangeCb = cb;
|
hoisted.onTeamChangeCb = cb;
|
||||||
return () => {
|
return () => {
|
||||||
|
|
@ -66,6 +72,7 @@ import { __resetTeamSliceModuleStateForTests } from '../../../src/renderer/store
|
||||||
import {
|
import {
|
||||||
__resetTeamRefreshFanoutDiagnosticsForTests,
|
__resetTeamRefreshFanoutDiagnosticsForTests,
|
||||||
getTeamRefreshFanoutSnapshotForTests,
|
getTeamRefreshFanoutSnapshotForTests,
|
||||||
|
summarizeTeamRefreshFanout,
|
||||||
type TeamRefreshFanoutSnapshot,
|
type TeamRefreshFanoutSnapshot,
|
||||||
} from '../../../src/renderer/store/teamRefreshFanoutDiagnostics';
|
} from '../../../src/renderer/store/teamRefreshFanoutDiagnostics';
|
||||||
import { api } from '@renderer/api';
|
import { api } from '@renderer/api';
|
||||||
|
|
@ -79,6 +86,7 @@ describe('team change throttling', () => {
|
||||||
__resetTeamRefreshFanoutDiagnosticsForTests();
|
__resetTeamRefreshFanoutDiagnosticsForTests();
|
||||||
const fetchTeams = vi.fn(async () => undefined);
|
const fetchTeams = vi.fn(async () => undefined);
|
||||||
const fetchMemberSpawnStatuses = vi.fn(async () => undefined);
|
const fetchMemberSpawnStatuses = vi.fn(async () => undefined);
|
||||||
|
const fetchTeamAgentRuntime = vi.fn(async () => undefined);
|
||||||
const refreshTeamData = vi.fn(async () => undefined);
|
const refreshTeamData = vi.fn(async () => undefined);
|
||||||
const refreshTeamMessagesHead = vi.fn(async () => ({
|
const refreshTeamMessagesHead = vi.fn(async () => ({
|
||||||
feedChanged: true,
|
feedChanged: true,
|
||||||
|
|
@ -91,6 +99,7 @@ describe('team change throttling', () => {
|
||||||
useStore.setState({
|
useStore.setState({
|
||||||
fetchTeams,
|
fetchTeams,
|
||||||
fetchMemberSpawnStatuses,
|
fetchMemberSpawnStatuses,
|
||||||
|
fetchTeamAgentRuntime,
|
||||||
refreshTeamData,
|
refreshTeamData,
|
||||||
refreshTeamMessagesHead,
|
refreshTeamMessagesHead,
|
||||||
refreshMemberActivityMeta,
|
refreshMemberActivityMeta,
|
||||||
|
|
@ -124,6 +133,7 @@ describe('team change throttling', () => {
|
||||||
cleanup = null;
|
cleanup = null;
|
||||||
__resetTeamSliceModuleStateForTests();
|
__resetTeamSliceModuleStateForTests();
|
||||||
__resetTeamRefreshFanoutDiagnosticsForTests();
|
__resetTeamRefreshFanoutDiagnosticsForTests();
|
||||||
|
window.localStorage.removeItem('team:processLiteFanout');
|
||||||
vi.mocked(console.warn).mockClear();
|
vi.mocked(console.warn).mockClear();
|
||||||
vi.useRealTimers();
|
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 () => {
|
it('keeps task and config events on the existing global task refresh path', async () => {
|
||||||
const fetchAllTasksSpy = vi.fn(async () => undefined);
|
const fetchAllTasksSpy = vi.fn(async () => undefined);
|
||||||
useStore.setState({ fetchAllTasks: fetchAllTasksSpy } as never);
|
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_RECENT_NOTES,
|
||||||
MAX_TEAM_REFRESH_DIAGNOSTIC_TEAMS,
|
MAX_TEAM_REFRESH_DIAGNOSTIC_TEAMS,
|
||||||
noteTeamRefreshFanout,
|
noteTeamRefreshFanout,
|
||||||
|
summarizeTeamRefreshFanout,
|
||||||
type TeamRefreshFanoutSnapshot,
|
type TeamRefreshFanoutSnapshot,
|
||||||
} from '../../../src/renderer/store/teamRefreshFanoutDiagnostics';
|
} from '../../../src/renderer/store/teamRefreshFanoutDiagnostics';
|
||||||
|
|
||||||
|
|
@ -118,6 +119,84 @@ describe('teamRefreshFanoutDiagnostics', () => {
|
||||||
expect(getTeamRefreshFanoutSnapshotForTests()).toEqual({});
|
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', () => {
|
it('ignores invalid empty team or reason values', () => {
|
||||||
noteTeamRefreshFanout({
|
noteTeamRefreshFanout({
|
||||||
teamName: '',
|
teamName: '',
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import {
|
||||||
selectMemberMessagesForTeamMember,
|
selectMemberMessagesForTeamMember,
|
||||||
selectResolvedMemberForTeamName,
|
selectResolvedMemberForTeamName,
|
||||||
selectResolvedMembersForTeamName,
|
selectResolvedMembersForTeamName,
|
||||||
|
selectTeamDataForName,
|
||||||
} from '../../../src/renderer/store/slices/teamSlice';
|
} from '../../../src/renderer/store/slices/teamSlice';
|
||||||
import {
|
import {
|
||||||
__resetTeamRefreshFanoutDiagnosticsForTests,
|
__resetTeamRefreshFanoutDiagnosticsForTests,
|
||||||
|
|
@ -1703,6 +1704,35 @@ describe('teamSlice actions', () => {
|
||||||
expect(nextResolvedMembers).toBe(initialResolvedMembers);
|
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', () => {
|
it('memoizes team-scoped member messages selectors over the merged message feed', () => {
|
||||||
const store = createSliceStore();
|
const store = createSliceStore();
|
||||||
store.setState({
|
store.setState({
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue