fix(team): harden runtime status and opencode bootstrap

This commit is contained in:
777genius 2026-05-03 13:06:33 +03:00
parent 7e55fdd9cd
commit e3c62eb620
36 changed files with 3378 additions and 148 deletions

View file

@ -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"

View file

@ -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);

View file

@ -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);

View file

@ -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 */

View file

@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function escapeXmlText(value: string): string {
return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
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) => {

View file

@ -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,

View file

@ -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,
}); });

View file

@ -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',
], ],
}); });
} }

View file

@ -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,

View 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;
}
}

View 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',
});
}

View file

@ -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"

View file

@ -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"

View file

@ -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
} }

View file

@ -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>

View file

@ -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}>

View 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;
}

View file

@ -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) {

View file

@ -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;
} }

View file

@ -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,

View 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,
};
}

View 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__;
}
};
}

View file

@ -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;

View file

@ -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 */

View file

@ -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;
/** /**

View file

@ -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 = {

View file

@ -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);
}); });
}); });

View file

@ -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');

View file

@ -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',

View file

@ -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);
});
});

View 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,
});
});
});

View file

@ -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);

View 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',
});
});
});

View 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();
});
});

View file

@ -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: '',

View file

@ -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({