diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index 5cf50a96..29fc6c72 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -198,15 +198,14 @@ const logger = createLogger('IPC:teams'); const seenRateLimitKeys = new Set(); const SEEN_RATE_LIMIT_KEYS_MAX = 500; -function ensureHeavyTeamDataWorkerFallbackAllowed(operation: string): void { +function noteHeavyTeamDataWorkerFallback(operation: string): void { if (!app.isPackaged) { return; } logger.error( - `[${operation}] team-data-worker unavailable in packaged runtime; refusing main-thread fallback for heavy message/activity path` + `[${operation}] team-data-worker unavailable in packaged runtime; falling back to main-thread execution for heavy message/activity path` ); - throw new Error('TEAM_DATA_WORKER_UNAVAILABLE'); } async function getDurableLeadTeammateRoster( @@ -749,11 +748,11 @@ async function handleGetData( logger.warn( `[teams:getData] worker failed, falling back: ${workerErr instanceof Error ? workerErr.message : workerErr}` ); - ensureHeavyTeamDataWorkerFallbackAllowed('teams:getData'); + noteHeavyTeamDataWorkerFallback('teams:getData'); data = await getTeamDataService().getTeamData(tn); } } else { - ensureHeavyTeamDataWorkerFallbackAllowed('teams:getData'); + noteHeavyTeamDataWorkerFallback('teams:getData'); data = await getTeamDataService().getTeamData(tn); } } catch (error) { @@ -1700,7 +1699,7 @@ async function handleGetMessagesPage( ); } } - ensureHeavyTeamDataWorkerFallbackAllowed('teams:getMessagesPage'); + noteHeavyTeamDataWorkerFallback('teams:getMessagesPage'); page = await getTeamDataService().getMessagesPage(vTeam.value!, { cursor, limit }); scanTeamMessageNotifications( page.messages, @@ -1734,7 +1733,7 @@ async function handleGetMemberActivityMeta( ); } } - ensureHeavyTeamDataWorkerFallbackAllowed('teams:getMemberActivityMeta'); + noteHeavyTeamDataWorkerFallback('teams:getMemberActivityMeta'); return getTeamDataService().getMemberActivityMeta(vTeam.value!); }); } diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 04278d0d..54a09a1e 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -702,11 +702,6 @@ interface ProvisioningRun { memberSpawnToolUseIds: Map; /** Per-member latest processed lead-inbox bootstrap signal cursor for the current live run. */ memberSpawnLeadInboxCursorByMember: Map; - /** - * Per-member exact processed lead-inbox messageIds for the current live run. - * This owns live-path correctness and protects against out-of-order inserts. - */ - memberSpawnProcessedLeadInboxMessageIdsByMember: Map>; /** Highest accepted deterministic bootstrap event sequence for this run. */ lastDeterministicBootstrapSeq: number; /** Throttles config/inbox audit work triggered by frequent status polling. */ @@ -842,19 +837,6 @@ function maxMemberSpawnInboxCursor( return compareMemberSpawnInboxCursor(left, right) >= 0 ? left : right; } -function getOrCreateMemberSpawnProcessedMessageIds( - run: ProvisioningRun, - memberName: string -): Set { - const existing = run.memberSpawnProcessedLeadInboxMessageIdsByMember.get(memberName); - if (existing) { - return existing; - } - const created = new Set(); - run.memberSpawnProcessedLeadInboxMessageIdsByMember.set(memberName, created); - return created; -} - function isMemberSpawnHeartbeatTimestampNewer( previous: string | undefined, incoming: string | undefined @@ -2965,42 +2947,28 @@ export class TeamProvisioningService { } for (const [memberName, messages] of messagesByMember.entries()) { - const processedMessageIds = getOrCreateMemberSpawnProcessedMessageIds(run, memberName); const currentCursor = run.memberSpawnLeadInboxCursorByMember.get(memberName); - const newlyProcessedMessageIds: string[] = []; let nextCursor = currentCursor; for (const message of messages) { - if (processedMessageIds.has(message.messageId)) { - continue; - } - const messageCursor = toMemberSpawnInboxCursor(message); - const shouldApplySignal = - messageCursor == null || - currentCursor == null || - compareMemberSpawnInboxCursor(messageCursor, currentCursor) > 0; - - if (shouldApplySignal) { - this.applyLeadInboxSpawnSignal(run, memberName, message); - if (messageCursor) { - nextCursor = maxMemberSpawnInboxCursor(nextCursor, messageCursor); + const effectiveCursor = nextCursor ?? currentCursor; + if (messageCursor && effectiveCursor) { + if (compareMemberSpawnInboxCursor(messageCursor, effectiveCursor) <= 0) { + continue; } } - // Mark late out-of-order signals as seen so they cannot replay forever, but only - // let strictly newer cursors mutate the already-advanced live member state. - newlyProcessedMessageIds.push(message.messageId); + this.applyLeadInboxSpawnSignal(run, memberName, message); + if (messageCursor) { + nextCursor = maxMemberSpawnInboxCursor(nextCursor, messageCursor); + } } - if (newlyProcessedMessageIds.length === 0) { - continue; - } - - for (const messageId of newlyProcessedMessageIds) { - processedMessageIds.add(messageId); - } - if (nextCursor) { + if ( + nextCursor && + (currentCursor == null || compareMemberSpawnInboxCursor(nextCursor, currentCursor) > 0) + ) { run.memberSpawnLeadInboxCursorByMember.set(memberName, nextCursor); } } @@ -5127,7 +5095,6 @@ export class TeamProvisioningService { ), memberSpawnToolUseIds: new Map(), memberSpawnLeadInboxCursorByMember: new Map(), - memberSpawnProcessedLeadInboxMessageIdsByMember: new Map(), lastDeterministicBootstrapSeq: 0, lastMemberSpawnAuditAt: 0, lastMemberSpawnAuditConfigReadWarningAt: 0, @@ -5708,7 +5675,6 @@ export class TeamProvisioningService { ), memberSpawnToolUseIds: new Map(), memberSpawnLeadInboxCursorByMember: new Map(), - memberSpawnProcessedLeadInboxMessageIdsByMember: new Map(), lastDeterministicBootstrapSeq: 0, lastMemberSpawnAuditAt: 0, lastMemberSpawnAuditConfigReadWarningAt: 0, diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index 9d6d0f71..1ffcc8ed 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -1,4 +1,14 @@ -import { lazy, memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + lazy, + memo, + Suspense, + useCallback, + useEffect, + useId, + useMemo, + useRef, + useState, +} from 'react'; import { api } from '@renderer/api'; import { SessionContextPanel } from '@renderer/components/chat/SessionContextPanel/index'; @@ -1308,6 +1318,7 @@ export const TeamDetailView = ({ ); const leadSessionId = data?.config.leadSessionId ?? null; + const pendingReplyRefreshSourceId = useId(); const sessionHistoryKey = useMemo( () => (data?.config.sessionHistory ?? []).join('|'), [data?.config.sessionHistory] @@ -1320,14 +1331,21 @@ export const TeamDetailView = ({ const hasPendingReplies = Object.keys(pendingRepliesByMember).length > 0; syncTeamPendingReplyRefresh( teamName, + pendingReplyRefreshSourceId, Boolean(data?.isAlive) && hasPendingReplies, TEAM_PENDING_REPLY_REFRESH_DELAY_MS ); return () => { - syncTeamPendingReplyRefresh(teamName, false); + syncTeamPendingReplyRefresh(teamName, pendingReplyRefreshSourceId, false); }; - }, [data?.isAlive, pendingRepliesByMember, syncTeamPendingReplyRefresh, teamName]); + }, [ + data?.isAlive, + pendingRepliesByMember, + pendingReplyRefreshSourceId, + syncTeamPendingReplyRefresh, + teamName, + ]); useEffect(() => { if (!projectId) return; diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index f2b181e0..a510bd4c 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -54,7 +54,9 @@ import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react'; import { AdvancedCliSection } from './AdvancedCliSection'; import { OptionalSettingsSection } from './OptionalSettingsSection'; import { ProjectPathSelector } from './ProjectPathSelector'; +import { buildProviderPrepareModelCacheKey } from './providerPrepareCacheKey'; import { + buildReusableProviderPrepareModelResults, getProviderPrepareCachedSnapshot, type ProviderPrepareDiagnosticsModelResult, runProviderPrepareDiagnostics, @@ -125,14 +127,6 @@ function getProviderLabel(providerId: TeamProviderId): string { return getCatalogTeamProviderLabel(providerId) ?? 'Anthropic'; } -function buildPrepareModelCacheKey( - cwd: string, - providerId: TeamProviderId, - backendSummary: string | null | undefined -): string { - return `${cwd}::${providerId}::${backendSummary ?? ''}`; -} - function alignProvisioningChecks( existingChecks: ProvisioningProviderCheck[], providerIds: TeamProviderId[] @@ -644,7 +638,12 @@ export const CreateTeamDialog = ({ return Array.from(next); })(); const backendSummary = runtimeBackendSummaryByProviderRef.current.get(providerId) ?? null; - const cacheKey = buildPrepareModelCacheKey(effectiveCwd, providerId, backendSummary); + const cacheKey = buildProviderPrepareModelCacheKey({ + cwd: effectiveCwd, + providerId, + backendSummary, + limitContext, + }); const cachedModelResultsById = prepareModelResultsCacheRef.current.get(cacheKey) ?? {}; const cachedSnapshot = getProviderPrepareCachedSnapshot({ providerId, @@ -714,7 +713,7 @@ export const CreateTeamDialog = ({ } prepareModelResultsCacheRef.current.set( plan.cacheKey, - plan.prepResult.modelResultsById + buildReusableProviderPrepareModelResults(plan.prepResult.modelResultsById) ); checks = updateProviderCheck(checks, plan.providerId, { status: plan.prepResult.status, diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index 565ff163..ca366588 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -72,8 +72,10 @@ import { AdvancedCliSection } from './AdvancedCliSection'; import { EffortLevelSelector } from './EffortLevelSelector'; import { resolveLaunchDialogPrefill } from './launchDialogPrefill'; import { OptionalSettingsSection } from './OptionalSettingsSection'; +import { buildProviderPrepareModelCacheKey } from './providerPrepareCacheKey'; import { ProjectPathSelector } from './ProjectPathSelector'; import { + buildReusableProviderPrepareModelResults, getProviderPrepareCachedSnapshot, type ProviderPrepareDiagnosticsModelResult, runProviderPrepareDiagnostics, @@ -110,14 +112,6 @@ import type { UpdateSchedulePatch, } from '@shared/types'; -function buildPrepareModelCacheKey( - cwd: string, - providerId: TeamProviderId, - backendSummary: string | null | undefined -): string { - return `${cwd}::${providerId}::${backendSummary ?? ''}`; -} - function alignProvisioningChecks( existingChecks: ProvisioningProviderCheck[], providerIds: TeamProviderId[] @@ -933,7 +927,12 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen const providerPlans = selectedMemberProviders.map((providerId) => { const selectedModelChecks = selectedModelChecksByProvider.get(providerId) ?? []; const backendSummary = runtimeBackendSummaryByProviderRef.current.get(providerId) ?? null; - const cacheKey = buildPrepareModelCacheKey(effectiveCwd, providerId, backendSummary); + const cacheKey = buildProviderPrepareModelCacheKey({ + cwd: effectiveCwd, + providerId, + backendSummary, + limitContext, + }); const cachedModelResultsById = prepareModelResultsCacheRef.current.get(cacheKey) ?? {}; const cachedSnapshot = getProviderPrepareCachedSnapshot({ providerId, @@ -1001,7 +1000,10 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen } else if (plan.prepResult.status === 'notes') { anyNotes = true; } - prepareModelResultsCacheRef.current.set(plan.cacheKey, plan.prepResult.modelResultsById); + prepareModelResultsCacheRef.current.set( + plan.cacheKey, + buildReusableProviderPrepareModelResults(plan.prepResult.modelResultsById) + ); checks = updateProviderCheck(checks, plan.providerId, { status: plan.prepResult.status, backendSummary: plan.backendSummary, diff --git a/src/renderer/components/team/dialogs/providerPrepareCacheKey.ts b/src/renderer/components/team/dialogs/providerPrepareCacheKey.ts new file mode 100644 index 00000000..e67e1efa --- /dev/null +++ b/src/renderer/components/team/dialogs/providerPrepareCacheKey.ts @@ -0,0 +1,20 @@ +import type { TeamProviderId } from '@shared/types'; + +export function buildProviderPrepareModelCacheKey({ + cwd, + providerId, + backendSummary, + limitContext, +}: { + cwd: string; + providerId: TeamProviderId; + backendSummary: string | null | undefined; + limitContext: boolean; +}): string { + return [ + cwd, + providerId, + backendSummary ?? '', + limitContext ? 'limit-context:on' : 'limit-context:off', + ].join('::'); +} diff --git a/src/renderer/components/team/dialogs/providerPrepareDiagnostics.ts b/src/renderer/components/team/dialogs/providerPrepareDiagnostics.ts index cf1bb17d..21f67237 100644 --- a/src/renderer/components/team/dialogs/providerPrepareDiagnostics.ts +++ b/src/renderer/components/team/dialogs/providerPrepareDiagnostics.ts @@ -39,6 +39,14 @@ export interface ProviderPrepareDiagnosticsResult { modelResultsById: Record; } +export function buildReusableProviderPrepareModelResults( + modelResultsById: Record +): Record { + return Object.fromEntries( + Object.entries(modelResultsById).filter(([, result]) => result.status !== 'notes') + ); +} + function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } @@ -187,6 +195,29 @@ function getResultReason(modelId: string, result: TeamProvisioningPrepareResult) return null; } +function getModelScopedEntries(modelId: string, result: TeamProvisioningPrepareResult): string[] { + const escapedModelId = escapeRegExp(modelId); + const scopedPattern = new RegExp(`^Selected model ${escapedModelId}\\b`, 'i'); + return [...(result.details ?? []), ...(result.warnings ?? []), result.message] + .map((entry) => entry?.trim() ?? '') + .filter(Boolean) + .filter((entry) => scopedPattern.test(entry)); +} + +function getScopedModelReason(modelId: string, entries: string[]): string | null { + for (const entry of entries) { + const stripped = stripSelectedModelPrefix(modelId, entry); + if (!stripped) { + continue; + } + const normalized = normalizeModelReason(stripped); + if (normalized) { + return normalized; + } + } + return null; +} + function buildModelFailureLine( providerId: TeamProviderId, modelId: string, @@ -201,6 +232,72 @@ function createRuntimeDetailLines(result: TeamProvisioningPrepareResult): string return [...(result.details ?? []), ...(result.warnings ?? [])]; } +function resolveModelResultFromBatch( + providerId: TeamProviderId, + modelId: string, + result: TeamProvisioningPrepareResult, + isOnlyModel: boolean +): ProviderPrepareDiagnosticsModelResult { + const modelScopedEntries = getModelScopedEntries(modelId, result); + const normalizedReason = + getScopedModelReason(modelId, modelScopedEntries) ?? + (isOnlyModel ? normalizeModelReason(result.message) : null); + + const hasVerifiedLine = modelScopedEntries.some((entry) => + /selected model .* verified for launch\./i.test(entry) + ); + if (hasVerifiedLine) { + return { + status: 'ready', + line: buildModelSuccessLine(providerId, modelId), + warningLine: null, + }; + } + + const hasUnavailableLine = modelScopedEntries.some((entry) => + /selected model .* is unavailable\./i.test(entry) + ); + if (hasUnavailableLine || (!result.ready && isOnlyModel)) { + return { + status: 'failed', + line: buildModelFailureLine(providerId, modelId, 'unavailable', normalizedReason), + warningLine: null, + }; + } + + const hasVerificationWarningLine = modelScopedEntries.some((entry) => + /selected model .* could not be verified\./i.test(entry) + ); + if (hasVerificationWarningLine || ((result.warnings?.length ?? 0) > 0 && isOnlyModel)) { + const line = buildModelFailureLine(providerId, modelId, 'check failed', normalizedReason); + return { + status: 'notes', + line, + warningLine: line, + }; + } + + if (result.ready) { + return { + status: 'ready', + line: buildModelSuccessLine(providerId, modelId), + warningLine: null, + }; + } + + const line = buildModelFailureLine( + providerId, + modelId, + 'check failed', + normalizedReason ?? 'Model verification failed' + ); + return { + status: 'notes', + line, + warningLine: line, + }; +} + export async function runProviderPrepareDiagnostics({ cwd, providerId, @@ -289,71 +386,55 @@ export async function runProviderPrepareDiagnostics({ emitProgress(); - await Promise.all( - orderedModelIds - .filter((modelId) => !modelResultsById.has(modelId)) - .map(async (modelId) => { - try { - const modelResult = await prepareProvisioning( - cwd, - providerId, - [providerId], - [modelId], - limitContext - ); - if (!modelResult.ready) { - hasFailure = true; - const line = buildModelFailureLine( - providerId, - modelId, - 'unavailable', - getResultReason(modelId, modelResult) ?? normalizeModelReason(modelResult.message) - ); - modelLines.set(modelId, line); - modelResultsById.set(modelId, { - status: 'failed', - line, - warningLine: null, - }); - } else if ((modelResult.warnings?.length ?? 0) > 0) { - hasNotes = true; - const reason = getResultReason(modelId, modelResult); - const line = buildModelFailureLine(providerId, modelId, 'check failed', reason); - modelLines.set(modelId, line); - modelWarnings.push(line); - modelResultsById.set(modelId, { - status: 'notes', - line, - warningLine: line, - }); - } else { - const line = buildModelSuccessLine(providerId, modelId); - modelLines.set(modelId, line); - modelResultsById.set(modelId, { - status: 'ready', - line, - warningLine: null, - }); - } - } catch (error) { + const uncachedModelIds = orderedModelIds.filter((modelId) => !modelResultsById.has(modelId)); + if (uncachedModelIds.length > 0) { + try { + const batchedModelResult = await prepareProvisioning( + cwd, + providerId, + [providerId], + uncachedModelIds, + limitContext + ); + + for (const modelId of uncachedModelIds) { + const resolvedResult = resolveModelResultFromBatch( + providerId, + modelId, + batchedModelResult, + uncachedModelIds.length === 1 + ); + modelLines.set(modelId, resolvedResult.line); + modelResultsById.set(modelId, resolvedResult); + if (resolvedResult.status === 'failed') { + hasFailure = true; + } else if (resolvedResult.status === 'notes') { hasNotes = true; - const reason = normalizeModelReason( - error instanceof Error ? error.message.trim() : String(error).trim() - ); - const line = buildModelFailureLine(providerId, modelId, 'check failed', reason || null); - modelLines.set(modelId, line); - modelWarnings.push(line); - modelResultsById.set(modelId, { - status: 'notes', - line, - warningLine: line, - }); - } finally { - completedCount += 1; - emitProgress(); } - }) - ); + if (resolvedResult.warningLine) { + modelWarnings.push(resolvedResult.warningLine); + } + } + } catch (error) { + hasNotes = true; + const reason = normalizeModelReason( + error instanceof Error ? error.message.trim() : String(error).trim() + ); + for (const modelId of uncachedModelIds) { + const line = buildModelFailureLine(providerId, modelId, 'check failed', reason || null); + modelLines.set(modelId, line); + modelWarnings.push(line); + modelResultsById.set(modelId, { + status: 'notes', + line, + warningLine: line, + }); + } + } finally { + completedCount += uncachedModelIds.length; + emitProgress(); + } + } const dedupedWarnings = Array.from(new Set([...runtimeWarnings, ...modelWarnings])); const selectedModelResultsById = Object.fromEntries( diff --git a/src/renderer/components/team/members/MemberDetailDialog.tsx b/src/renderer/components/team/members/MemberDetailDialog.tsx index 495da40a..1a179cf9 100644 --- a/src/renderer/components/team/members/MemberDetailDialog.tsx +++ b/src/renderer/components/team/members/MemberDetailDialog.tsx @@ -4,10 +4,13 @@ import { Button } from '@renderer/components/ui/button'; import { Dialog, DialogContent, DialogFooter, DialogHeader } from '@renderer/components/ui/dialog'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs'; import { useMemberStats } from '@renderer/hooks/useMemberStats'; +import { useStore } from '@renderer/store'; +import { selectMemberMessagesForTeamMember } from '@renderer/store/slices/teamSlice'; import { isLeadMember } from '@shared/utils/leadDetection'; import { BarChart3, FileText, ListPlus, MessageSquare, UserMinus } from 'lucide-react'; import { MemberDetailHeader } from './MemberDetailHeader'; +import { buildMemberActivityEntries } from './memberActivityEntries'; import { MemberDetailStats } from './MemberDetailStats'; import { type MemberActivityFilter, type MemberDetailTab } from './memberDetailTypes'; import { MemberLogsTab } from './MemberLogsTab'; @@ -71,7 +74,21 @@ export const MemberDetailDialog = ({ () => (member ? tasks.filter((t) => t.owner === member.name) : []), [tasks, member] ); - const memberActivityCount = member?.messageCount ?? 0; + const memberMessages = useStore((state) => + selectMemberMessagesForTeamMember(state, teamName, member?.name ?? null) + ); + const memberActivityCount = useMemo(() => { + if (!member) { + return 0; + } + return buildMemberActivityEntries({ + teamName, + memberName: member.name, + members, + tasks, + messages: memberMessages, + }).length; + }, [member, memberMessages, members, tasks, teamName]); const inProgressTasks = useMemo( () => memberTasks.filter((t) => t.status === 'in_progress').length, diff --git a/src/renderer/components/team/members/MemberMessagesTab.tsx b/src/renderer/components/team/members/MemberMessagesTab.tsx index 75984c1a..301ac40c 100644 --- a/src/renderer/components/team/members/MemberMessagesTab.tsx +++ b/src/renderer/components/team/members/MemberMessagesTab.tsx @@ -1,6 +1,5 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; -import { buildInlineActivityEntries } from '@features/agent-graph/renderer'; import { ActivityItem } from '@renderer/components/team/activity/ActivityItem'; import { buildMessageContext, @@ -11,11 +10,11 @@ import { Button } from '@renderer/components/ui/button'; import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead'; import { useStore } from '@renderer/store'; import { selectMemberMessagesForTeamMember } from '@renderer/store/slices/teamSlice'; -import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering'; import { toMessageKey } from '@renderer/utils/teamMessageKey'; -import { isLeadMember } from '@shared/utils/leadDetection'; import { useShallow } from 'zustand/react/shallow'; +import { buildMemberActivityEntries } from './memberActivityEntries'; + import type { MemberActivityFilter } from './memberDetailTypes'; import type { TimelineItem } from '@renderer/components/team/activity/LeadThoughtsGroup'; import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types'; @@ -56,13 +55,6 @@ export const MemberMessagesTab = ({ })) ); const { readSet } = useTeamMessagesRead(teamName); - const leadId = `lead:${teamName}`; - const leadName = useMemo( - () => members.find((candidate) => isLeadMember(candidate))?.name ?? `${teamName}-lead`, - [members, teamName] - ); - const ownerNodeId = memberName === leadName ? leadId : `member:${teamName}:${memberName}`; - const ownerNodeIds = useMemo(() => new Set([leadId, ownerNodeId]), [leadId, ownerNodeId]); const taskMap = useMemo(() => new Map(tasks.map((task) => [task.id, task])), [tasks]); const messageContext = useMemo(() => buildMessageContext(members), [members]); @@ -80,45 +72,34 @@ export const MemberMessagesTab = ({ const loading = (messagesState?.loadingHead ?? false) || (messagesState?.loadingOlder ?? false); const hasMore = messagesState?.hasMore ?? false; - const filteredMessages = useMemo( - () => - filterTeamMessages(messages, { - timeWindow: null, - filter: { from: new Set(), to: new Set(), showNoise: true }, - searchQuery: '', - }), - [messages] - ); - const activityEntries = useMemo(() => { - const entriesByOwner = buildInlineActivityEntries({ - data: { - members, - tasks, - messages: filteredMessages, - }, + return buildMemberActivityEntries({ teamName, - leadId, - leadName, - ownerNodeIds, + memberName, + members, + tasks, + messages, }); - return (entriesByOwner.get(ownerNodeId) ?? []).slice(0, MAX_MESSAGES); - }, [filteredMessages, leadId, leadName, members, ownerNodeId, ownerNodeIds, tasks, teamName]); + }, [memberName, members, messages, tasks, teamName]); + const visibleActivityEntries = useMemo( + () => activityEntries.slice(0, MAX_MESSAGES), + [activityEntries] + ); const displayEntries = useMemo(() => { switch (activityFilter) { case 'messages': - return activityEntries.filter( + return visibleActivityEntries.filter( (entry) => entry.message.messageKind !== 'task_comment_notification' ); case 'comments': - return activityEntries.filter( + return visibleActivityEntries.filter( (entry) => entry.message.messageKind === 'task_comment_notification' ); default: - return activityEntries; + return visibleActivityEntries; } - }, [activityEntries, activityFilter]); + }, [activityFilter, visibleActivityEntries]); const expandedItemsByKey = useMemo(() => { const items = new Map(); @@ -156,9 +137,10 @@ export const MemberMessagesTab = ({ ? hasMore ? 'No loaded messages for this member yet' : 'No messages with this member' - : 'No activity with this member'; - const canLoadOlderMessages = - hasMore && activityFilter !== 'comments' && displayEntries.length > 0; + : hasMore + ? 'No loaded activity for this member yet' + : 'No activity with this member'; + const canLoadOlderMessages = hasMore && activityFilter !== 'comments'; return (
diff --git a/src/renderer/components/team/members/memberActivityEntries.ts b/src/renderer/components/team/members/memberActivityEntries.ts new file mode 100644 index 00000000..c935e7e4 --- /dev/null +++ b/src/renderer/components/team/members/memberActivityEntries.ts @@ -0,0 +1,42 @@ +import { buildInlineActivityEntries } from '@features/agent-graph/renderer'; +import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering'; +import { isLeadMember } from '@shared/utils/leadDetection'; + +import type { InlineActivityEntry } from '@features/agent-graph/core/domain/buildInlineActivityEntries'; +import type { InboxMessage, ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types'; + +export function buildMemberActivityEntries({ + teamName, + memberName, + members, + tasks, + messages, +}: { + teamName: string; + memberName: string; + members: ResolvedTeamMember[]; + tasks: TeamTaskWithKanban[]; + messages: InboxMessage[]; +}): InlineActivityEntry[] { + const filteredMessages = filterTeamMessages(messages, { + timeWindow: null, + filter: { from: new Set(), to: new Set(), showNoise: true }, + searchQuery: '', + }); + const leadId = `lead:${teamName}`; + const leadName = members.find((candidate) => isLeadMember(candidate))?.name ?? `${teamName}-lead`; + const ownerNodeId = memberName === leadName ? leadId : `member:${teamName}:${memberName}`; + const ownerNodeIds = new Set([leadId, ownerNodeId]); + const entriesByOwner = buildInlineActivityEntries({ + data: { + members, + tasks, + messages: filteredMessages, + }, + teamName, + leadId, + leadName, + ownerNodeIds, + }); + return entriesByOwner.get(ownerNodeId) ?? []; +} diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index 5d5c9e45..f185c263 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -79,7 +79,7 @@ const TEAM_REFRESH_BURST_WARN_COUNT = 5; const TEAM_REFRESH_WARN_THROTTLE_MS = 2_000; const MEMBER_SPAWN_UI_EQUAL_WARN_THROTTLE_MS = 2_000; const inFlightTeamDataRequests = new Map>(); -const inFlightRefreshTeamDataCalls = new Set(); +const inFlightRefreshTeamDataCalls = new Map>(); const pendingFreshTeamDataRefreshes = new Set(); const inFlightTeamMessagesHeadRequests = new Map>(); const inFlightTeamMessagesOlderRequests = new Map>(); @@ -91,8 +91,9 @@ const pendingFreshTeamMessagesHeadRefreshes = new Set(); const inFlightTeamMemberActivityMetaRequests = new Map>(); const pendingFreshTeamMemberActivityMetaRefreshes = new Set(); const pendingTeamPendingReplyRefreshTimers = new Map>(); -const activeTeamPendingReplyWaits = new Set(); +const activeTeamPendingReplyWaitSourceIdsByTeam = new Map>(); const lastResolvedTeamDataRefreshAtByTeam = new Map(); +const teamLocalStateEpochByTeam = new Map(); let inFlightGlobalTasksRefresh: Promise | null = null; let pendingFreshGlobalTasksRefresh = false; const memberSpawnStatusesIpcBackoffUntilByTeam = new Map(); @@ -119,7 +120,7 @@ interface TeamGraphLayoutSessionState { export function isTeamDataRefreshPending(teamName: string): boolean { return ( inFlightTeamDataRequests.has(teamName) || - inFlightRefreshTeamDataCalls.has(teamName) || + (inFlightRefreshTeamDataCalls.get(teamName)?.size ?? 0) > 0 || pendingFreshTeamDataRefreshes.has(teamName) ); } @@ -129,11 +130,15 @@ export function getLastResolvedTeamDataRefreshAt(teamName: string): number | und } export function hasActiveTeamPendingReplyWait(teamName: string): boolean { - return activeTeamPendingReplyWaits.has(teamName); + return (activeTeamPendingReplyWaitSourceIdsByTeam.get(teamName)?.size ?? 0) > 0; } export function getActiveTeamPendingReplyWaits(): Set { - return new Set(activeTeamPendingReplyWaits); + return new Set( + Array.from(activeTeamPendingReplyWaitSourceIdsByTeam.entries()) + .filter(([, sourceIds]) => sourceIds.size > 0) + .map(([teamName]) => teamName) + ); } export function __resetTeamSliceModuleStateForTests(): void { @@ -150,8 +155,9 @@ export function __resetTeamSliceModuleStateForTests(): void { clearTimeout(timer); } pendingTeamPendingReplyRefreshTimers.clear(); - activeTeamPendingReplyWaits.clear(); + activeTeamPendingReplyWaitSourceIdsByTeam.clear(); lastResolvedTeamDataRefreshAtByTeam.clear(); + teamLocalStateEpochByTeam.clear(); memberSpawnStatusesIpcBackoffUntilByTeam.clear(); teamRefreshBurstDiagnostics.clear(); memberSpawnUiEqualLastWarnAtByTeam.clear(); @@ -161,6 +167,274 @@ export function __resetTeamSliceModuleStateForTests(): void { memberMessagesSelectorCache.clear(); } +function clearTeamScopedSelectorCaches(teamName: string): void { + resolvedMembersSelectorCache.delete(teamName); + mergedMessagesSelectorCache.delete(teamName); + + const teamScopedPrefix = `${teamName}:`; + for (const key of resolvedMemberSelectorCache.keys()) { + if (key.startsWith(teamScopedPrefix)) { + resolvedMemberSelectorCache.delete(key); + } + } + for (const key of memberMessagesSelectorCache.keys()) { + if (key.startsWith(teamScopedPrefix)) { + memberMessagesSelectorCache.delete(key); + } + } +} + +function clearTeamScopedTransientState(teamName: string): void { + inFlightTeamDataRequests.delete(teamName); + inFlightRefreshTeamDataCalls.delete(teamName); + pendingFreshTeamDataRefreshes.delete(teamName); + inFlightTeamMessagesHeadRequests.delete(teamName); + inFlightTeamMessagesOlderRequests.delete(teamName); + queuedTeamMessagesHeadRefreshesAfterOlder.delete(teamName); + pendingFreshTeamMessagesHeadRefreshes.delete(teamName); + inFlightTeamMemberActivityMetaRequests.delete(teamName); + pendingFreshTeamMemberActivityMetaRefreshes.delete(teamName); + lastResolvedTeamDataRefreshAtByTeam.delete(teamName); + memberSpawnStatusesIpcBackoffUntilByTeam.delete(teamName); + teamRefreshBurstDiagnostics.delete(teamName); + memberSpawnUiEqualLastWarnAtByTeam.delete(teamName); + clearTeamScopedSelectorCaches(teamName); +} + +function collectTeamScopedVisibleLoadingResets( + state: Pick< + TeamSlice, + 'teamMessagesByName' | 'selectedTeamName' | 'selectedTeamLoading' | 'selectedTeamError' + >, + teamName: string +): Partial { + const nextTeamMessagesEntry = state.teamMessagesByName[teamName]; + const nextTeamMessagesByName = + nextTeamMessagesEntry && + (nextTeamMessagesEntry.loadingHead || nextTeamMessagesEntry.loadingOlder) + ? { + ...state.teamMessagesByName, + [teamName]: { + ...nextTeamMessagesEntry, + loadingHead: false, + loadingOlder: false, + }, + } + : null; + + const shouldResetSelectedSurface = + state.selectedTeamName === teamName && + (state.selectedTeamLoading || state.selectedTeamError != null); + + return { + ...(nextTeamMessagesByName ? { teamMessagesByName: nextTeamMessagesByName } : {}), + ...(shouldResetSelectedSurface + ? { + selectedTeamLoading: false, + selectedTeamError: null, + } + : {}), + }; +} + +function omitTeamKey(record: Record, teamName: string): Record | null { + if (!(teamName in record)) { + return null; + } + const next = { ...record }; + delete next[teamName]; + return next; +} + +function collectTeamScopedStateRemovals( + state: Pick< + TeamSlice, + | 'provisioningRuns' + | 'teamDataCacheByName' + | 'teamMessagesByName' + | 'memberActivityMetaByTeam' + | 'provisioningSnapshotByTeam' + | 'currentProvisioningRunIdByTeam' + | 'currentRuntimeRunIdByTeam' + | 'provisioningStartedAtFloorByTeam' + | 'leadActivityByTeam' + | 'leadContextByTeam' + | 'activeToolsByTeam' + | 'finishedVisibleByTeam' + | 'toolHistoryByTeam' + | 'memberSpawnStatusesByTeam' + | 'memberSpawnSnapshotsByTeam' + | 'provisioningErrorByTeam' + >, + teamName: string +): Partial { + const nextProvisioningRuns = Object.fromEntries( + Object.entries(state.provisioningRuns).filter(([, run]) => run.teamName !== teamName) + ) as Record; + const nextTeamDataCache = omitTeamKey(state.teamDataCacheByName, teamName); + const nextTeamMessages = omitTeamKey(state.teamMessagesByName, teamName); + const nextMemberActivityMeta = omitTeamKey(state.memberActivityMetaByTeam, teamName); + const nextProvisioningSnapshot = omitTeamKey(state.provisioningSnapshotByTeam, teamName); + const nextCurrentProvisioningRunId = omitTeamKey(state.currentProvisioningRunIdByTeam, teamName); + const nextCurrentRuntimeRunId = omitTeamKey(state.currentRuntimeRunIdByTeam, teamName); + const nextProvisioningStartedAtFloor = omitTeamKey( + state.provisioningStartedAtFloorByTeam, + teamName + ); + const nextLeadActivity = omitTeamKey(state.leadActivityByTeam, teamName); + const nextLeadContext = omitTeamKey(state.leadContextByTeam, teamName); + const nextActiveTools = omitTeamKey(state.activeToolsByTeam, teamName); + const nextFinishedVisible = omitTeamKey(state.finishedVisibleByTeam, teamName); + const nextToolHistory = omitTeamKey(state.toolHistoryByTeam, teamName); + const nextMemberSpawnStatuses = omitTeamKey(state.memberSpawnStatusesByTeam, teamName); + const nextMemberSpawnSnapshots = omitTeamKey(state.memberSpawnSnapshotsByTeam, teamName); + const nextProvisioningErrors = omitTeamKey(state.provisioningErrorByTeam, teamName); + + return { + ...(Object.keys(nextProvisioningRuns).length !== Object.keys(state.provisioningRuns).length + ? { provisioningRuns: nextProvisioningRuns } + : {}), + ...(nextTeamDataCache ? { teamDataCacheByName: nextTeamDataCache } : {}), + ...(nextTeamMessages ? { teamMessagesByName: nextTeamMessages } : {}), + ...(nextMemberActivityMeta ? { memberActivityMetaByTeam: nextMemberActivityMeta } : {}), + ...(nextProvisioningSnapshot ? { provisioningSnapshotByTeam: nextProvisioningSnapshot } : {}), + ...(nextCurrentProvisioningRunId + ? { currentProvisioningRunIdByTeam: nextCurrentProvisioningRunId } + : {}), + ...(nextCurrentRuntimeRunId ? { currentRuntimeRunIdByTeam: nextCurrentRuntimeRunId } : {}), + ...(nextProvisioningStartedAtFloor + ? { provisioningStartedAtFloorByTeam: nextProvisioningStartedAtFloor } + : {}), + ...(nextLeadActivity ? { leadActivityByTeam: nextLeadActivity } : {}), + ...(nextLeadContext ? { leadContextByTeam: nextLeadContext } : {}), + ...(nextActiveTools ? { activeToolsByTeam: nextActiveTools } : {}), + ...(nextFinishedVisible ? { finishedVisibleByTeam: nextFinishedVisible } : {}), + ...(nextToolHistory ? { toolHistoryByTeam: nextToolHistory } : {}), + ...(nextMemberSpawnStatuses ? { memberSpawnStatusesByTeam: nextMemberSpawnStatuses } : {}), + ...(nextMemberSpawnSnapshots ? { memberSpawnSnapshotsByTeam: nextMemberSpawnSnapshots } : {}), + ...(nextProvisioningErrors ? { provisioningErrorByTeam: nextProvisioningErrors } : {}), + }; +} + +function buildTeamScopedProgressTombstones( + state: Pick< + TeamSlice, + | 'currentProvisioningRunIdByTeam' + | 'currentRuntimeRunIdByTeam' + | 'ignoredProvisioningRunIds' + | 'ignoredRuntimeRunIds' + | 'provisioningStartedAtFloorByTeam' + >, + teamName: string, + floor: string +): Pick< + TeamSlice, + 'ignoredProvisioningRunIds' | 'ignoredRuntimeRunIds' | 'provisioningStartedAtFloorByTeam' +> { + const nextIgnoredProvisioningRunIds = { ...state.ignoredProvisioningRunIds }; + const nextIgnoredRuntimeRunIds = { ...state.ignoredRuntimeRunIds }; + + const currentProvisioningRunId = state.currentProvisioningRunIdByTeam[teamName]; + const currentRuntimeRunId = state.currentRuntimeRunIdByTeam[teamName]; + if (currentProvisioningRunId) { + nextIgnoredProvisioningRunIds[currentProvisioningRunId] = teamName; + } + if (currentRuntimeRunId) { + nextIgnoredRuntimeRunIds[currentRuntimeRunId] = teamName; + } + + return { + ignoredProvisioningRunIds: nextIgnoredProvisioningRunIds, + ignoredRuntimeRunIds: nextIgnoredRuntimeRunIds, + provisioningStartedAtFloorByTeam: { + ...state.provisioningStartedAtFloorByTeam, + [teamName]: floor, + }, + }; +} + +function captureTeamLocalStateEpoch(teamName: string): number { + return teamLocalStateEpochByTeam.get(teamName) ?? 0; +} + +function isTeamLocalStateEpochCurrent(teamName: string, epoch: number): boolean { + return captureTeamLocalStateEpoch(teamName) === epoch; +} + +function invalidateTeamLocalStateEpoch(teamName: string): void { + teamLocalStateEpochByTeam.set(teamName, captureTeamLocalStateEpoch(teamName) + 1); +} + +function beginInFlightTeamDataRefresh(teamName: string): symbol { + const token = Symbol(teamName); + const existing = inFlightRefreshTeamDataCalls.get(teamName); + if (existing) { + existing.add(token); + return token; + } + inFlightRefreshTeamDataCalls.set(teamName, new Set([token])); + return token; +} + +function endInFlightTeamDataRefresh(teamName: string, token: symbol): void { + const existing = inFlightRefreshTeamDataCalls.get(teamName); + if (!existing) { + return; + } + existing.delete(token); + if (existing.size === 0) { + inFlightRefreshTeamDataCalls.delete(teamName); + } +} + +export function __getTeamScopedTransientStateForTests(teamName: string): { + hasResolvedMembersSelector: boolean; + resolvedMemberSelectorCount: number; + hasMergedMessagesSelector: boolean; + memberMessagesSelectorCount: number; + hasPendingFreshTeamDataRefresh: boolean; + hasQueuedHeadRefreshAfterOlder: boolean; + hasPendingFreshMessagesHeadRefresh: boolean; + hasPendingFreshMemberActivityMetaRefresh: boolean; + hasLastResolvedTeamDataRefresh: boolean; + hasCurrentLocalStateEpoch: boolean; + hasMemberSpawnStatusesIpcBackoff: boolean; + hasTeamRefreshBurstDiagnostics: boolean; + hasMemberSpawnUiEqualLastWarn: boolean; +} { + const teamScopedPrefix = `${teamName}:`; + let resolvedMemberSelectorCount = 0; + let memberMessagesSelectorCount = 0; + + for (const key of resolvedMemberSelectorCache.keys()) { + if (key.startsWith(teamScopedPrefix)) { + resolvedMemberSelectorCount += 1; + } + } + for (const key of memberMessagesSelectorCache.keys()) { + if (key.startsWith(teamScopedPrefix)) { + memberMessagesSelectorCount += 1; + } + } + + return { + hasResolvedMembersSelector: resolvedMembersSelectorCache.has(teamName), + resolvedMemberSelectorCount, + hasMergedMessagesSelector: mergedMessagesSelectorCache.has(teamName), + memberMessagesSelectorCount, + hasPendingFreshTeamDataRefresh: pendingFreshTeamDataRefreshes.has(teamName), + hasQueuedHeadRefreshAfterOlder: queuedTeamMessagesHeadRefreshesAfterOlder.has(teamName), + hasPendingFreshMessagesHeadRefresh: pendingFreshTeamMessagesHeadRefreshes.has(teamName), + hasPendingFreshMemberActivityMetaRefresh: + pendingFreshTeamMemberActivityMetaRefreshes.has(teamName), + hasLastResolvedTeamDataRefresh: lastResolvedTeamDataRefreshAtByTeam.has(teamName), + hasCurrentLocalStateEpoch: teamLocalStateEpochByTeam.has(teamName), + hasMemberSpawnStatusesIpcBackoff: memberSpawnStatusesIpcBackoffUntilByTeam.has(teamName), + hasTeamRefreshBurstDiagnostics: teamRefreshBurstDiagnostics.has(teamName), + hasMemberSpawnUiEqualLastWarn: memberSpawnUiEqualLastWarnAtByTeam.has(teamName), + }; +} + function nowIso(): string { return new Date().toISOString(); } @@ -665,12 +939,32 @@ function clearPendingReplyRefreshTimer(teamName: string): void { pendingTeamPendingReplyRefreshTimers.delete(teamName); } -function setPendingReplyRefreshEnabled(teamName: string, enabled: boolean): void { +function clearPendingReplyRefreshWaits(teamName: string): void { + activeTeamPendingReplyWaitSourceIdsByTeam.delete(teamName); +} + +function setPendingReplyRefreshEnabled( + teamName: string, + sourceId: string, + enabled: boolean +): boolean { if (enabled) { - activeTeamPendingReplyWaits.add(teamName); - return; + const existing = activeTeamPendingReplyWaitSourceIdsByTeam.get(teamName) ?? new Set(); + existing.add(sourceId); + activeTeamPendingReplyWaitSourceIdsByTeam.set(teamName, existing); + return true; } - activeTeamPendingReplyWaits.delete(teamName); + + const existing = activeTeamPendingReplyWaitSourceIdsByTeam.get(teamName); + if (!existing) { + return false; + } + existing.delete(sourceId); + if (existing.size === 0) { + activeTeamPendingReplyWaitSourceIdsByTeam.delete(teamName); + return false; + } + return true; } function getCanonicalHeadSlice( @@ -1738,7 +2032,12 @@ export interface TeamSlice { refreshTeamMessagesHead: (teamName: string) => Promise; loadOlderTeamMessages: (teamName: string) => Promise; refreshMemberActivityMeta: (teamName: string) => Promise; - syncTeamPendingReplyRefresh: (teamName: string, enabled: boolean, delayMs?: number) => void; + syncTeamPendingReplyRefresh: ( + teamName: string, + sourceId: string, + enabled: boolean, + delayMs?: number + ) => void; sendTeamMessage: (teamName: string, request: SendMessageRequest) => Promise; crossTeamTargets: { teamName: string; @@ -2059,17 +2358,9 @@ export const createTeamSlice: StateCreator = (set, ...prev.currentRuntimeRunIdByTeam, [teamName]: snapshot.runId, }; - const hasIgnoredRuntimeEntriesForTeam = Object.values(prev.ignoredRuntimeRunIds).some( - (ignoredTeamName) => ignoredTeamName === teamName - ); - const nextIgnoredRuntimeRunIds = - snapshot.runId == null || !hasIgnoredRuntimeEntriesForTeam - ? prev.ignoredRuntimeRunIds - : Object.fromEntries( - Object.entries(prev.ignoredRuntimeRunIds).filter( - ([, ignoredTeamName]) => ignoredTeamName !== teamName - ) - ); + // Keep same-team ignored runtime tombstones intact here. + // Member-spawn snapshots do not carry a run start time, so clearing older + // ignored ids can reopen stale zombie snapshots during create/launch churn. const previousSnapshot = prev.memberSpawnSnapshotsByTeam[teamName]; const snapshotChanged = !areMemberSpawnSnapshotsSemanticallyEqual( previousSnapshot, @@ -2078,22 +2369,17 @@ export const createTeamSlice: StateCreator = (set, if (!snapshotChanged) { maybeLogMemberSpawnUiEqualSuppressed(teamName, snapshot.runId); - if ( - nextCurrentRuntimeRunIdByTeam === prev.currentRuntimeRunIdByTeam && - nextIgnoredRuntimeRunIds === prev.ignoredRuntimeRunIds - ) { + if (nextCurrentRuntimeRunIdByTeam === prev.currentRuntimeRunIdByTeam) { return {}; } return { currentRuntimeRunIdByTeam: nextCurrentRuntimeRunIdByTeam, - ignoredRuntimeRunIds: nextIgnoredRuntimeRunIds, }; } return { currentRuntimeRunIdByTeam: nextCurrentRuntimeRunIdByTeam, - ignoredRuntimeRunIds: nextIgnoredRuntimeRunIds, memberSpawnStatusesByTeam: { ...prev.memberSpawnStatusesByTeam, [teamName]: snapshot.statuses, @@ -2837,6 +3123,7 @@ export const createTeamSlice: StateCreator = (set, selectTeam: async (teamName: string, opts) => { const startedAt = performance.now(); + const teamStateEpoch = captureTeamLocalStateEpoch(teamName); const allowReloadWhileProvisioning = opts?.allowReloadWhileProvisioning === true; // Guard: prevent duplicate in-flight fetches for the same team. // GlobalTaskDetailDialog + tab navigation can call selectTeam() in quick succession. @@ -2865,6 +3152,9 @@ export const createTeamSlice: StateCreator = (set, try { const data = await fetchTeamDataDeduped(teamName); + if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) { + return; + } const ipcMs = performance.now() - startedAt; // Stale check: user may have switched to another team during the async call if (get().selectedTeamName !== teamName || get().selectedTeamLoadNonce !== requestNonce) { @@ -2993,6 +3283,9 @@ export const createTeamSlice: StateCreator = (set, } } } catch (error) { + if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) { + return; + } // If provisioning is in progress for this team, stay in loading state; // file watcher / progress callback will refresh once config is written. const currentState = get(); @@ -3041,7 +3334,8 @@ export const createTeamSlice: StateCreator = (set, refreshTeamData: async (teamName: string, opts?: RefreshTeamDataOptions) => { const startedAt = performance.now(); - inFlightRefreshTeamDataCalls.add(teamName); + const teamStateEpoch = captureTeamLocalStateEpoch(teamName); + const refreshToken = beginInFlightTeamDataRefresh(teamName); // Silent refresh — update data without showing loading skeleton. // Only selectTeam() sets loading: true (for initial load). const reusedInFlightRequest = @@ -3058,6 +3352,9 @@ export const createTeamSlice: StateCreator = (set, const data = opts?.withDedup ? await fetchTeamDataDeduped(teamName) : await fetchTeamDataFresh(teamName); + if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) { + return; + } const ipcMs = performance.now() - startedAt; const projectedTeamData = previousData ? { @@ -3124,6 +3421,9 @@ export const createTeamSlice: StateCreator = (set, burstCount, }); } catch (error) { + if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) { + return; + } const msg = error instanceof IpcError ? error.message @@ -3182,7 +3482,7 @@ export const createTeamSlice: StateCreator = (set, } set({ selectedTeamError: msg }); } finally { - inFlightRefreshTeamDataCalls.delete(teamName); + endInFlightTeamDataRefresh(teamName, refreshToken); if (reusedInFlightRequest && pendingFreshTeamDataRefreshes.delete(teamName)) { void get().refreshTeamData(teamName); } @@ -3202,10 +3502,24 @@ export const createTeamSlice: StateCreator = (set, const existingOlderRequest = inFlightTeamMessagesOlderRequests.get(teamName); if (existingOlderRequest) { + const queuedEpoch = captureTeamLocalStateEpoch(teamName); const queuedRequest: Promise = existingOlderRequest .then(() => { + if (!isTeamLocalStateEpochCurrent(teamName, queuedEpoch)) { + return { + feedChanged: false, + headChanged: false, + feedRevision: null, + }; + } if (queuedTeamMessagesHeadRefreshesAfterOlder.get(teamName) === queuedRequest) { queuedTeamMessagesHeadRefreshesAfterOlder.delete(teamName); + } else { + return { + feedChanged: false, + headChanged: false, + feedRevision: null, + }; } return get().refreshTeamMessagesHead(teamName); }) @@ -3218,7 +3532,9 @@ export const createTeamSlice: StateCreator = (set, return queuedRequest; } - const request = (async (): Promise => { + let request!: Promise; + request = (async (): Promise => { + const teamStateEpoch = captureTeamLocalStateEpoch(teamName); set((state) => ({ teamMessagesByName: { ...state.teamMessagesByName, @@ -3233,6 +3549,13 @@ export const createTeamSlice: StateCreator = (set, const page = await unwrapIpc('team:getMessagesPage', () => api.teams.getMessagesPage(teamName, { limit: 50 }) ); + if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) { + return { + feedChanged: false, + headChanged: false, + feedRevision: null, + }; + } const previousEntry = getTeamMessagesCacheEntry(get(), teamName); const feedChanged = @@ -3282,6 +3605,13 @@ export const createTeamSlice: StateCreator = (set, feedRevision: page.feedRevision, }; } catch (error) { + if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) { + return { + feedChanged: false, + headChanged: false, + feedRevision: null, + }; + } set((state) => ({ teamMessagesByName: { ...state.teamMessagesByName, @@ -3293,9 +3623,11 @@ export const createTeamSlice: StateCreator = (set, })); throw error; } finally { - inFlightTeamMessagesHeadRequests.delete(teamName); - if (pendingFreshTeamMessagesHeadRefreshes.delete(teamName)) { - void get().refreshTeamMessagesHead(teamName); + if (inFlightTeamMessagesHeadRequests.get(teamName) === request) { + inFlightTeamMessagesHeadRequests.delete(teamName); + if (pendingFreshTeamMessagesHeadRefreshes.delete(teamName)) { + void get().refreshTeamMessagesHead(teamName); + } } } })(); @@ -3305,6 +3637,7 @@ export const createTeamSlice: StateCreator = (set, }, loadOlderTeamMessages: async (teamName: string) => { + const requestedEpoch = captureTeamLocalStateEpoch(teamName); const existingRequest = inFlightTeamMessagesOlderRequests.get(teamName); if (existingRequest) { return existingRequest; @@ -3313,11 +3646,17 @@ export const createTeamSlice: StateCreator = (set, const existingHeadRequest = inFlightTeamMessagesHeadRequests.get(teamName); if (existingHeadRequest) { await existingHeadRequest; + if (!isTeamLocalStateEpochCurrent(teamName, requestedEpoch)) { + return; + } } let entry = getTeamMessagesCacheEntry(get(), teamName); if (!entry.headHydrated) { await get().refreshTeamMessagesHead(teamName); + if (!isTeamLocalStateEpochCurrent(teamName, requestedEpoch)) { + return; + } entry = getTeamMessagesCacheEntry(get(), teamName); } @@ -3325,7 +3664,9 @@ export const createTeamSlice: StateCreator = (set, return; } - const request = (async (): Promise => { + let request!: Promise; + request = (async (): Promise => { + const teamStateEpoch = captureTeamLocalStateEpoch(teamName); set((state) => ({ teamMessagesByName: { ...state.teamMessagesByName, @@ -3344,6 +3685,9 @@ export const createTeamSlice: StateCreator = (set, limit: 50, }) ); + if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) { + return; + } const current = getTeamMessagesCacheEntry(get(), teamName); if (current.feedRevision !== baseFeedRevision) { @@ -3392,6 +3736,9 @@ export const createTeamSlice: StateCreator = (set, }; }); } catch { + if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) { + return; + } set((state) => ({ teamMessagesByName: { ...state.teamMessagesByName, @@ -3402,7 +3749,9 @@ export const createTeamSlice: StateCreator = (set, }, })); } finally { - inFlightTeamMessagesOlderRequests.delete(teamName); + if (inFlightTeamMessagesOlderRequests.get(teamName) === request) { + inFlightTeamMessagesOlderRequests.delete(teamName); + } } })(); @@ -3422,11 +3771,16 @@ export const createTeamSlice: StateCreator = (set, return existingRequest; } - const request = (async (): Promise => { + let request!: Promise; + request = (async (): Promise => { + const teamStateEpoch = captureTeamLocalStateEpoch(teamName); try { const meta = await unwrapIpc('team:getMemberActivityMeta', () => api.teams.getMemberActivityMeta(teamName) ); + if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) { + return; + } set((state) => { const currentFeedRevision = getTeamMessagesCacheEntry(state, teamName).feedRevision; @@ -3458,10 +3812,17 @@ export const createTeamSlice: StateCreator = (set, }, }; }); + } catch (error) { + if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) { + return; + } + throw error; } finally { - inFlightTeamMemberActivityMetaRequests.delete(teamName); - if (pendingFreshTeamMemberActivityMetaRefreshes.delete(teamName)) { - void get().refreshMemberActivityMeta(teamName); + if (inFlightTeamMemberActivityMetaRequests.get(teamName) === request) { + inFlightTeamMemberActivityMetaRequests.delete(teamName); + if (pendingFreshTeamMemberActivityMetaRefreshes.delete(teamName)) { + void get().refreshMemberActivityMeta(teamName); + } } } })(); @@ -3470,10 +3831,15 @@ export const createTeamSlice: StateCreator = (set, return request; }, - syncTeamPendingReplyRefresh: (teamName: string, enabled: boolean, delayMs = 10_000) => { + syncTeamPendingReplyRefresh: ( + teamName: string, + sourceId: string, + enabled: boolean, + delayMs = 10_000 + ) => { clearPendingReplyRefreshTimer(teamName); - setPendingReplyRefreshEnabled(teamName, enabled); - if (!enabled) { + const shouldKeepRefreshActive = setPendingReplyRefreshEnabled(teamName, sourceId, enabled); + if (!shouldKeepRefreshActive) { return; } @@ -3770,42 +4136,26 @@ export const createTeamSlice: StateCreator = (set, deleteTeam: async (teamName: string) => { await unwrapIpc('team:deleteTeam', () => api.teams.deleteTeam(teamName)); + invalidateTeamLocalStateEpoch(teamName); clearPendingReplyRefreshTimer(teamName); - setPendingReplyRefreshEnabled(teamName, false); + clearPendingReplyRefreshWaits(teamName); + clearTeamScopedTransientState(teamName); set((state) => { - const nextCache = state.teamDataCacheByName[teamName] - ? { ...state.teamDataCacheByName } - : null; - const nextMessageCache = state.teamMessagesByName[teamName] - ? { ...state.teamMessagesByName } - : null; - const nextActivityMeta = state.memberActivityMetaByTeam[teamName] - ? { ...state.memberActivityMetaByTeam } - : null; - if (nextCache) { - delete nextCache[teamName]; - } - if (nextMessageCache) { - delete nextMessageCache[teamName]; - } - if (nextActivityMeta) { - delete nextActivityMeta[teamName]; - } + const clearedState = collectTeamScopedStateRemovals(state, teamName); + const tombstones = buildTeamScopedProgressTombstones(state, teamName, nowIso()); if (state.selectedTeamName === teamName) { return { selectedTeamName: null, selectedTeamData: null, selectedTeamLoading: false, selectedTeamError: null, - ...(nextCache ? { teamDataCacheByName: nextCache } : {}), - ...(nextMessageCache ? { teamMessagesByName: nextMessageCache } : {}), - ...(nextActivityMeta ? { memberActivityMetaByTeam: nextActivityMeta } : {}), + ...clearedState, + ...tombstones, }; } return { - ...(nextCache ? { teamDataCacheByName: nextCache } : {}), - ...(nextMessageCache ? { teamMessagesByName: nextMessageCache } : {}), - ...(nextActivityMeta ? { memberActivityMetaByTeam: nextActivityMeta } : {}), + ...clearedState, + ...tombstones, }; }); await get().fetchTeams(); @@ -3814,32 +4164,20 @@ export const createTeamSlice: StateCreator = (set, restoreTeam: async (teamName: string) => { await unwrapIpc('team:restoreTeam', () => api.teams.restoreTeam(teamName)); + invalidateTeamLocalStateEpoch(teamName); clearPendingReplyRefreshTimer(teamName); - setPendingReplyRefreshEnabled(teamName, false); + clearPendingReplyRefreshWaits(teamName); + clearTeamScopedTransientState(teamName); set((state) => { - const hasSnapshot = Boolean(state.teamDataCacheByName[teamName]); - const hasMessages = Boolean(state.teamMessagesByName[teamName]); - const hasMeta = Boolean(state.memberActivityMetaByTeam[teamName]); - if (!hasSnapshot && !hasMessages && !hasMeta) { - return {}; + const clearedState = collectTeamScopedStateRemovals(state, teamName); + const tombstones = buildTeamScopedProgressTombstones(state, teamName, nowIso()); + if (Object.keys(clearedState).length === 0) { + return tombstones; } - const nextState: Partial = {}; - if (hasSnapshot) { - const nextCache = { ...state.teamDataCacheByName }; - delete nextCache[teamName]; - nextState.teamDataCacheByName = nextCache; - } - if (hasMessages) { - const nextMessages = { ...state.teamMessagesByName }; - delete nextMessages[teamName]; - nextState.teamMessagesByName = nextMessages; - } - if (hasMeta) { - const nextMeta = { ...state.memberActivityMetaByTeam }; - delete nextMeta[teamName]; - nextState.memberActivityMetaByTeam = nextMeta; - } - return nextState; + return { + ...clearedState, + ...tombstones, + }; }); await get().fetchTeams(); await get().fetchAllTasks(); @@ -3847,35 +4185,28 @@ export const createTeamSlice: StateCreator = (set, permanentlyDeleteTeam: async (teamName: string) => { await unwrapIpc('team:permanentlyDeleteTeam', () => api.teams.permanentlyDeleteTeam(teamName)); + invalidateTeamLocalStateEpoch(teamName); clearPendingReplyRefreshTimer(teamName); - setPendingReplyRefreshEnabled(teamName, false); + clearPendingReplyRefreshWaits(teamName); + clearTeamScopedTransientState(teamName); const state = get(); - const nextCache = { ...state.teamDataCacheByName }; - const nextMessages = { ...state.teamMessagesByName }; - const nextMeta = { ...state.memberActivityMetaByTeam }; - delete nextCache[teamName]; - delete nextMessages[teamName]; - delete nextMeta[teamName]; + const clearedState = collectTeamScopedStateRemovals(state, teamName); + const tombstones = buildTeamScopedProgressTombstones(state, teamName, nowIso()); if (state.selectedTeamName === teamName) { set({ selectedTeamName: null, selectedTeamData: null, selectedTeamError: null, - teamDataCacheByName: nextCache, - teamMessagesByName: nextMessages, - memberActivityMetaByTeam: nextMeta, + ...clearedState, + ...tombstones, }); - } else if (state.teamDataCacheByName[teamName]) { + } else if (Object.keys(clearedState).length > 0) { set({ - teamDataCacheByName: nextCache, - teamMessagesByName: nextMessages, - memberActivityMetaByTeam: nextMeta, - }); - } else if (state.teamMessagesByName[teamName] || state.memberActivityMetaByTeam[teamName]) { - set({ - teamMessagesByName: nextMessages, - memberActivityMetaByTeam: nextMeta, + ...clearedState, + ...tombstones, }); + } else { + set(tombstones); } await get().fetchTeams(); await get().fetchAllTasks(); @@ -3884,6 +4215,10 @@ export const createTeamSlice: StateCreator = (set, createTeam: async (request: TeamCreateRequest) => { // Ensure provisioning progress subscription is active (defensive). get().subscribeProvisioningProgress(); + invalidateTeamLocalStateEpoch(request.teamName); + clearPendingReplyRefreshTimer(request.teamName); + clearPendingReplyRefreshWaits(request.teamName); + clearTeamScopedTransientState(request.teamName); // Establish a per-team floor so late events from a previous run can't override UI. const floor = nowIso(); @@ -3917,25 +4252,13 @@ export const createTeamSlice: StateCreator = (set, const nextRuntimeRunIdByTeam = { ...state.currentRuntimeRunIdByTeam }; const previousRuntimeRunId = nextRuntimeRunIdByTeam[request.teamName]; delete nextRuntimeRunIdByTeam[request.teamName]; - const nextIgnoredRunIds = Object.fromEntries( - Object.entries(state.ignoredProvisioningRunIds).filter( - ([, teamName]) => teamName !== request.teamName - ) - ); const nextIgnoredRuntimeRunIds = previousRuntimeRunId ? { - ...Object.fromEntries( - Object.entries(state.ignoredRuntimeRunIds).filter( - ([, teamName]) => teamName !== request.teamName - ) - ), + ...state.ignoredRuntimeRunIds, [previousRuntimeRunId]: request.teamName, } - : Object.fromEntries( - Object.entries(state.ignoredRuntimeRunIds).filter( - ([, teamName]) => teamName !== request.teamName - ) - ); + : state.ignoredRuntimeRunIds; + const visibleLoadingResets = collectTeamScopedVisibleLoadingResets(state, request.teamName); return { provisioningRuns: cleaned, provisioningErrorByTeam: nextErrors, @@ -3945,8 +4268,9 @@ export const createTeamSlice: StateCreator = (set, finishedVisibleByTeam: nextFinishedVisible, toolHistoryByTeam: nextToolHistory, currentRuntimeRunIdByTeam: nextRuntimeRunIdByTeam, - ignoredProvisioningRunIds: nextIgnoredRunIds, + ignoredProvisioningRunIds: state.ignoredProvisioningRunIds, ignoredRuntimeRunIds: nextIgnoredRuntimeRunIds, + ...visibleLoadingResets, }; }); @@ -4038,11 +4362,6 @@ export const createTeamSlice: StateCreator = (set, ...state.currentRuntimeRunIdByTeam, [request.teamName]: response.runId, }, - ignoredRuntimeRunIds: Object.fromEntries( - Object.entries(state.ignoredRuntimeRunIds).filter( - ([, teamName]) => teamName !== request.teamName - ) - ), }; }); try { @@ -4082,6 +4401,10 @@ export const createTeamSlice: StateCreator = (set, launchTeam: async (request: TeamLaunchRequest) => { // Ensure provisioning progress subscription is active (defensive). get().subscribeProvisioningProgress(); + invalidateTeamLocalStateEpoch(request.teamName); + clearPendingReplyRefreshTimer(request.teamName); + clearPendingReplyRefreshWaits(request.teamName); + clearTeamScopedTransientState(request.teamName); // Establish a per-team floor so late events from a previous run can't override UI. const floor = nowIso(); @@ -4115,25 +4438,13 @@ export const createTeamSlice: StateCreator = (set, const nextRuntimeRunIdByTeam = { ...state.currentRuntimeRunIdByTeam }; const previousRuntimeRunId = nextRuntimeRunIdByTeam[request.teamName]; delete nextRuntimeRunIdByTeam[request.teamName]; - const nextIgnoredRunIds = Object.fromEntries( - Object.entries(state.ignoredProvisioningRunIds).filter( - ([, teamName]) => teamName !== request.teamName - ) - ); const nextIgnoredRuntimeRunIds = previousRuntimeRunId ? { - ...Object.fromEntries( - Object.entries(state.ignoredRuntimeRunIds).filter( - ([, teamName]) => teamName !== request.teamName - ) - ), + ...state.ignoredRuntimeRunIds, [previousRuntimeRunId]: request.teamName, } - : Object.fromEntries( - Object.entries(state.ignoredRuntimeRunIds).filter( - ([, teamName]) => teamName !== request.teamName - ) - ); + : state.ignoredRuntimeRunIds; + const visibleLoadingResets = collectTeamScopedVisibleLoadingResets(state, request.teamName); return { provisioningRuns: cleaned, provisioningErrorByTeam: nextErrors, @@ -4143,8 +4454,9 @@ export const createTeamSlice: StateCreator = (set, finishedVisibleByTeam: nextFinishedVisible, toolHistoryByTeam: nextToolHistory, currentRuntimeRunIdByTeam: nextRuntimeRunIdByTeam, - ignoredProvisioningRunIds: nextIgnoredRunIds, + ignoredProvisioningRunIds: state.ignoredProvisioningRunIds, ignoredRuntimeRunIds: nextIgnoredRuntimeRunIds, + ...visibleLoadingResets, }; }); @@ -4218,11 +4530,6 @@ export const createTeamSlice: StateCreator = (set, ...state.currentRuntimeRunIdByTeam, [request.teamName]: response.runId, }, - ignoredRuntimeRunIds: Object.fromEntries( - Object.entries(state.ignoredRuntimeRunIds).filter( - ([, teamName]) => teamName !== request.teamName - ) - ), }; }); try { @@ -4418,11 +4725,6 @@ export const createTeamSlice: StateCreator = (set, ...state.currentRuntimeRunIdByTeam, [progress.teamName]: progress.runId, }, - ignoredRuntimeRunIds: Object.fromEntries( - Object.entries(state.ignoredRuntimeRunIds).filter( - ([, teamName]) => teamName !== progress.teamName - ) - ), provisioningErrorByTeam: nextErrors, provisioningSnapshotByTeam: nextSnapshots, }; diff --git a/test/main/ipc/teams.test.ts b/test/main/ipc/teams.test.ts index 4a2ef3a7..fb484189 100644 --- a/test/main/ipc/teams.test.ts +++ b/test/main/ipc/teams.test.ts @@ -809,17 +809,20 @@ describe('ipc teams handlers', () => { expect(service.getMessageFeed).not.toHaveBeenCalled(); }); - it('rejects TEAM_GET_DATA fallback in packaged runtime when worker is unavailable', async () => { + it('falls back TEAM_GET_DATA to the main thread in packaged runtime when worker is unavailable', async () => { const electron = await import('electron'); mockTeamDataWorkerClient.isAvailable.mockReturnValue(false); (electron.app as { isPackaged: boolean }).isPackaged = true; const handler = handlers.get(TEAM_GET_DATA)!; - const result = (await handler({} as never, 'my-team')) as { success: boolean; error?: string }; + const result = (await handler({} as never, 'my-team')) as { + success: boolean; + data?: { teamName: string }; + }; - expect(result.success).toBe(false); - expect(result.error).toContain('TEAM_DATA_WORKER_UNAVAILABLE'); - expect(service.getTeamData).not.toHaveBeenCalled(); + expect(result.success).toBe(true); + expect(result.data?.teamName).toBe('my-team'); + expect(service.getTeamData).toHaveBeenCalledWith('my-team'); vi.mocked(console.error).mockClear(); (electron.app as { isPackaged: boolean }).isPackaged = false; @@ -894,7 +897,7 @@ describe('ipc teams handlers', () => { expect(service.getMessageFeed).not.toHaveBeenCalled(); }); - it('rejects heavy TEAM_GET_MESSAGES_PAGE fallback in packaged runtime when worker is unavailable', async () => { + it('falls back TEAM_GET_MESSAGES_PAGE to the main thread in packaged runtime when worker is unavailable', async () => { const electron = await import('electron'); mockTeamDataWorkerClient.isAvailable.mockReturnValue(false); (electron.app as { isPackaged: boolean }).isPackaged = true; @@ -902,11 +905,14 @@ describe('ipc teams handlers', () => { const handler = handlers.get(TEAM_GET_MESSAGES_PAGE)!; const result = (await handler({} as never, 'my-team', { limit: 50, - })) as { success: boolean; error?: string }; + })) as { success: boolean; data?: { feedRevision: string } }; - expect(result.success).toBe(false); - expect(result.error).toContain('TEAM_DATA_WORKER_UNAVAILABLE'); - expect(service.getMessagesPage).not.toHaveBeenCalled(); + expect(result.success).toBe(true); + expect(result.data?.feedRevision).toBe('rev-1'); + expect(service.getMessagesPage).toHaveBeenCalledWith('my-team', { + cursor: undefined, + limit: 50, + }); vi.mocked(console.error).mockClear(); (electron.app as { isPackaged: boolean }).isPackaged = false; @@ -940,17 +946,20 @@ describe('ipc teams handlers', () => { expect(service.getMemberActivityMeta).not.toHaveBeenCalled(); }); - it('rejects heavy TEAM_GET_MEMBER_ACTIVITY_META fallback in packaged runtime when worker is unavailable', async () => { + it('falls back TEAM_GET_MEMBER_ACTIVITY_META to the main thread in packaged runtime when worker is unavailable', async () => { const electron = await import('electron'); mockTeamDataWorkerClient.isAvailable.mockReturnValue(false); (electron.app as { isPackaged: boolean }).isPackaged = true; const handler = handlers.get(TEAM_GET_MEMBER_ACTIVITY_META)!; - const result = (await handler({} as never, 'my-team')) as { success: boolean; error?: string }; + const result = (await handler({} as never, 'my-team')) as { + success: boolean; + data?: { feedRevision: string }; + }; - expect(result.success).toBe(false); - expect(result.error).toContain('TEAM_DATA_WORKER_UNAVAILABLE'); - expect(service.getMemberActivityMeta).not.toHaveBeenCalled(); + expect(result.success).toBe(true); + expect(result.data?.feedRevision).toBe('rev-1'); + expect(service.getMemberActivityMeta).toHaveBeenCalledWith('my-team'); vi.mocked(console.error).mockClear(); (electron.app as { isPackaged: boolean }).isPackaged = false; diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index 7049185b..45993bd3 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -169,7 +169,6 @@ function createMemberSpawnRun(params?: { expectedMembers?: string[]; memberSpawnStatuses?: Map>; memberSpawnLeadInboxCursorByMember?: Map; - memberSpawnProcessedLeadInboxMessageIdsByMember?: Map>; }) { const teamName = params?.teamName ?? 'member-spawn-team'; const expectedMembers = params?.expectedMembers ?? ['alice']; @@ -196,8 +195,6 @@ function createMemberSpawnRun(params?: { memberSpawnToolUseIds: new Map(), memberSpawnLeadInboxCursorByMember: params?.memberSpawnLeadInboxCursorByMember ?? new Map(), - memberSpawnProcessedLeadInboxMessageIdsByMember: - params?.memberSpawnProcessedLeadInboxMessageIdsByMember ?? new Map(), provisioningOutputParts: [], activeToolCalls: new Map(), isLaunch: false, @@ -986,9 +983,6 @@ describe('TeamProvisioningService', () => { const svc = new TeamProvisioningService(); const run = createMemberSpawnRun({ startedAt: '2026-04-16T09:00:00.000Z', - memberSpawnProcessedLeadInboxMessageIdsByMember: new Map([ - ['alice', new Set(['msg-1', 'msg-2'])], - ]), memberSpawnLeadInboxCursorByMember: new Map([ [ 'alice', @@ -1053,9 +1047,6 @@ describe('TeamProvisioningService', () => { timestamp: '2026-04-16T10:00:00.000Z', messageId: 'msg-1', }); - expect(run.memberSpawnProcessedLeadInboxMessageIdsByMember.get('alice')).toEqual( - new Set(['msg-1']) - ); }); it('ignores teammate lead inbox signals that predate the current run', async () => { @@ -1080,7 +1071,6 @@ describe('TeamProvisioningService', () => { expect(applySignalSpy).not.toHaveBeenCalled(); expect(run.memberSpawnLeadInboxCursorByMember.size).toBe(0); - expect(run.memberSpawnProcessedLeadInboxMessageIdsByMember.size).toBe(0); expect(run.memberSpawnStatuses.get('alice')).toMatchObject({ status: 'waiting', launchState: 'runtime_pending_bootstrap', @@ -1088,7 +1078,7 @@ describe('TeamProvisioningService', () => { }); }); - it('marks an unseen older lead inbox signal as processed without replaying older state', async () => { + it('ignores an unseen older lead inbox signal without replaying older state', async () => { const latestHeartbeatAt = '2026-04-16T10:05:00.000Z'; const existingEntry = createMemberSpawnStatusEntry({ status: 'online', @@ -1101,9 +1091,6 @@ describe('TeamProvisioningService', () => { const run = createMemberSpawnRun({ startedAt: '2026-04-16T09:00:00.000Z', memberSpawnStatuses: new Map([['alice', existingEntry]]), - memberSpawnProcessedLeadInboxMessageIdsByMember: new Map([ - ['alice', new Set(['msg-3'])], - ]), memberSpawnLeadInboxCursorByMember: new Map([ [ 'alice', @@ -1139,9 +1126,6 @@ describe('TeamProvisioningService', () => { expect(applySignalSpy).not.toHaveBeenCalled(); expect(run.memberSpawnStatuses.get('alice')).toBe(existingEntry); - expect( - run.memberSpawnProcessedLeadInboxMessageIdsByMember.get('alice')?.has('msg-2b') - ).toBe(true); expect(run.memberSpawnLeadInboxCursorByMember.get('alice')).toEqual({ timestamp: latestHeartbeatAt, messageId: 'msg-3', @@ -1165,9 +1149,6 @@ describe('TeamProvisioningService', () => { }), ], ]), - memberSpawnProcessedLeadInboxMessageIdsByMember: new Map([ - ['alice', new Set(['msg-1'])], - ]), memberSpawnLeadInboxCursorByMember: new Map([ [ 'alice', @@ -1202,17 +1183,11 @@ describe('TeamProvisioningService', () => { timestamp: '2026-04-16T10:01:00.000Z', messageId: 'msg-2', }); - expect(run.memberSpawnProcessedLeadInboxMessageIdsByMember.get('alice')).toEqual( - new Set(['msg-1', 'msg-2']) - ); }); it('applies an unseen same-timestamp signal with a greater messageId and advances the cursor', async () => { const run = createMemberSpawnRun({ startedAt: '2026-04-16T09:00:00.000Z', - memberSpawnProcessedLeadInboxMessageIdsByMember: new Map([ - ['alice', new Set(['msg-2'])], - ]), memberSpawnLeadInboxCursorByMember: new Map([ [ 'alice', @@ -1256,9 +1231,6 @@ describe('TeamProvisioningService', () => { timestamp: '2026-04-16T10:00:00.000Z', messageId: 'msg-3', }); - expect( - run.memberSpawnProcessedLeadInboxMessageIdsByMember.get('alice') - ).toEqual(new Set(['msg-2', 'msg-3'])); }); it('does not bump lastHeartbeatAt for an equal heartbeat timestamp', () => { diff --git a/test/renderer/components/team/dialogs/providerPrepareCacheKey.test.ts b/test/renderer/components/team/dialogs/providerPrepareCacheKey.test.ts new file mode 100644 index 00000000..46a7c78d --- /dev/null +++ b/test/renderer/components/team/dialogs/providerPrepareCacheKey.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; + +import { buildProviderPrepareModelCacheKey } from '@renderer/components/team/dialogs/providerPrepareCacheKey'; + +describe('buildProviderPrepareModelCacheKey', () => { + it('separates limit-context variants for the same provider runtime', () => { + const sharedInput = { + cwd: '/tmp/project', + providerId: 'anthropic' as const, + backendSummary: 'Claude Code', + }; + + expect( + buildProviderPrepareModelCacheKey({ + ...sharedInput, + limitContext: false, + }) + ).not.toBe( + buildProviderPrepareModelCacheKey({ + ...sharedInput, + limitContext: true, + }) + ); + }); + + it('still reuses cache for identical runtime conditions', () => { + const input = { + cwd: '/tmp/project', + providerId: 'codex' as const, + backendSummary: 'Default adapter', + limitContext: false, + }; + + expect(buildProviderPrepareModelCacheKey(input)).toBe(buildProviderPrepareModelCacheKey(input)); + }); +}); diff --git a/test/renderer/components/team/dialogs/providerPrepareDiagnostics.test.ts b/test/renderer/components/team/dialogs/providerPrepareDiagnostics.test.ts index ecd70446..af685938 100644 --- a/test/renderer/components/team/dialogs/providerPrepareDiagnostics.test.ts +++ b/test/renderer/components/team/dialogs/providerPrepareDiagnostics.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it, vi } from 'vitest'; -import { runProviderPrepareDiagnostics } from '@renderer/components/team/dialogs/providerPrepareDiagnostics'; +import { + buildReusableProviderPrepareModelResults, + runProviderPrepareDiagnostics, +} from '@renderer/components/team/dialogs/providerPrepareDiagnostics'; import { DEFAULT_PROVIDER_MODEL_SELECTION } from '@shared/utils/providerModelSelection'; import type { TeamProvisioningPrepareResult } from '@shared/types'; @@ -17,6 +20,39 @@ function createDeferred(): { } describe('runProviderPrepareDiagnostics', () => { + it('does not keep transient note results in the reusable cache', () => { + expect( + buildReusableProviderPrepareModelResults({ + 'gpt-5.4': { + status: 'ready', + line: '5.4 - verified', + warningLine: null, + }, + 'gpt-5.3-codex': { + status: 'notes', + line: '5.3 Codex - check failed - Model verification timed out', + warningLine: '5.3 Codex - check failed - Model verification timed out', + }, + 'gpt-5.2-codex': { + status: 'failed', + line: '5.2 Codex - unavailable - Not available with Codex ChatGPT subscription', + warningLine: null, + }, + }) + ).toEqual({ + 'gpt-5.4': { + status: 'ready', + line: '5.4 - verified', + warningLine: null, + }, + 'gpt-5.2-codex': { + status: 'failed', + line: '5.2 Codex - unavailable - Not available with Codex ChatGPT subscription', + warningLine: null, + }, + }); + }); + it('returns a failed provider result immediately when runtime preflight fails', async () => { const prepareProvisioning = vi.fn< ( @@ -42,9 +78,8 @@ describe('runProviderPrepareDiagnostics', () => { expect(prepareProvisioning).toHaveBeenCalledTimes(1); }); - it('emits per-model progress updates and keeps failures scoped to the affected model', async () => { - const deferred54 = createDeferred(); - const deferred52 = createDeferred(); + it('batches uncached model probes per provider and keeps failures scoped to the affected model', async () => { + const deferredBatch = createDeferred(); const progressUpdates: Array<{ details: string[]; completedCount: number; totalCount: number }> = []; @@ -62,10 +97,8 @@ describe('runProviderPrepareDiagnostics', () => { message: 'CLI is warmed up and ready to launch', }); } - if (selectedModels[0] === 'gpt-5.4') { - return deferred54.promise; - } - return deferred52.promise; + expect(selectedModels).toEqual(['gpt-5.4', 'gpt-5.2-codex']); + return deferredBatch.promise; }); const resultPromise = runProviderPrepareDiagnostics({ @@ -83,24 +116,13 @@ describe('runProviderPrepareDiagnostics', () => { details: ['5.4 - checking...', '5.2 Codex - checking...'], }); - deferred54.resolve({ - ready: true, - message: 'CLI is warmed up and ready to launch', - details: ['Selected model gpt-5.4 verified for launch.'], - }); - await Promise.resolve(); - await Promise.resolve(); - - expect(progressUpdates.at(-1)).toEqual({ - completedCount: 1, - totalCount: 2, - details: ['5.4 - verified', '5.2 Codex - checking...'], - }); - - deferred52.resolve({ + deferredBatch.resolve({ ready: false, - message: + message: 'Some provider runtimes are not ready', + details: ['Selected model gpt-5.4 verified for launch.'], + warnings: [ "Selected model gpt-5.2-codex is unavailable. The 'gpt-5.2-codex' model is not supported when using Codex with a ChatGPT account.", + ], }); const result = await resultPromise; @@ -117,6 +139,7 @@ describe('runProviderPrepareDiagnostics', () => { '5.2 Codex - unavailable - Not available with Codex ChatGPT subscription', ], }); + expect(prepareProvisioning).toHaveBeenCalledTimes(2); }); it('normalizes raw Codex API error envelopes into a clean model reason', async () => { diff --git a/test/renderer/components/team/members/MemberDetailDialog.test.ts b/test/renderer/components/team/members/MemberDetailDialog.test.ts new file mode 100644 index 00000000..cfc8d56b --- /dev/null +++ b/test/renderer/components/team/members/MemberDetailDialog.test.ts @@ -0,0 +1,213 @@ +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useStore } from '@renderer/store'; + +import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types'; + +vi.mock('@renderer/hooks/useMemberStats', () => ({ + useMemberStats: () => ({ + stats: null, + loading: false, + error: null, + }), +})); + +vi.mock('@renderer/components/ui/button', () => ({ + Button: ({ + children, + onClick, + }: { + children: React.ReactNode; + onClick?: () => void; + }) => + React.createElement( + 'button', + { + type: 'button', + onClick, + }, + children + ), +})); + +vi.mock('@renderer/components/ui/dialog', () => ({ + Dialog: ({ children }: { children: React.ReactNode }) => React.createElement('div', null, children), + DialogContent: ({ children }: { children: React.ReactNode }) => + React.createElement('div', null, children), + DialogFooter: ({ children }: { children: React.ReactNode }) => + React.createElement('div', null, children), + DialogHeader: ({ children }: { children: React.ReactNode }) => + React.createElement('div', null, children), +})); + +vi.mock('@renderer/components/ui/tabs', () => { + let currentValue = ''; + let currentOnValueChange: ((value: string) => void) | null = null; + + return { + Tabs: ({ + children, + value, + onValueChange, + }: { + children: React.ReactNode; + value: string; + onValueChange?: (value: string) => void; + }) => { + currentValue = value; + currentOnValueChange = onValueChange ?? null; + return React.createElement('div', { 'data-tabs-value': value }, children); + }, + TabsList: ({ children }: { children: React.ReactNode }) => React.createElement('div', null, children), + TabsTrigger: ({ + children, + value, + }: { + children: React.ReactNode; + value: string; + }) => + React.createElement( + 'button', + { + type: 'button', + 'data-state': currentValue === value ? 'active' : 'inactive', + onClick: () => currentOnValueChange?.(value), + }, + children + ), + TabsContent: ({ + children, + value, + }: { + children: React.ReactNode; + value: string; + }) => (currentValue === value ? React.createElement('div', null, children) : null), + }; +}); + +vi.mock('@renderer/components/team/members/MemberDetailHeader', () => ({ + MemberDetailHeader: () => React.createElement('div', null, 'header'), +})); + +vi.mock('@renderer/components/team/members/MemberDetailStats', () => ({ + MemberDetailStats: ({ activityCount }: { activityCount: number }) => + React.createElement('div', { 'data-testid': 'member-detail-stats' }, `activity-count:${activityCount}`), +})); + +vi.mock('@renderer/components/team/members/MemberTasksTab', () => ({ + MemberTasksTab: () => React.createElement('div', null, 'tasks-tab'), +})); + +vi.mock('@renderer/components/team/members/MemberMessagesTab', () => ({ + MemberMessagesTab: () => React.createElement('div', null, 'activity-tab'), +})); + +vi.mock('@renderer/components/team/members/MemberStatsTab', () => ({ + MemberStatsTab: () => React.createElement('div', null, 'stats-tab'), +})); + +vi.mock('@renderer/components/team/members/MemberLogsTab', () => ({ + MemberLogsTab: () => React.createElement('div', null, 'logs-tab'), +})); + +import { MemberDetailDialog } from '@renderer/components/team/members/MemberDetailDialog'; + +describe('MemberDetailDialog activity count', () => { + beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + useStore.setState({ + teamMessagesByName: { + 'demo-team': { + canonicalMessages: [], + optimisticMessages: [], + feedRevision: 'rev-empty', + nextCursor: null, + hasMore: false, + lastFetchedAt: null, + loadingHead: false, + loadingOlder: false, + headHydrated: true, + }, + }, + } as never); + }); + + afterEach(() => { + document.body.innerHTML = ''; + useStore.setState({ teamMessagesByName: {} } as never); + vi.unstubAllGlobals(); + }); + + it('counts task comments in the Activity badge even when messageCount is zero', async () => { + const member: ResolvedTeamMember = { + name: 'jack', + status: 'active', + currentTaskId: null, + taskCount: 1, + lastActiveAt: null, + messageCount: 0, + }; + const members: ResolvedTeamMember[] = [ + { + name: 'team-lead', + status: 'active', + currentTaskId: null, + taskCount: 0, + lastActiveAt: null, + messageCount: 0, + agentType: 'team-lead', + }, + member, + ]; + const tasks: TeamTaskWithKanban[] = [ + { + id: 'task-1', + displayId: '#1', + subject: 'Review patch', + owner: 'jack', + status: 'in_progress', + comments: [ + { + id: 'comment-1', + author: 'jack', + text: 'Left a review note', + createdAt: '2026-04-17T10:00:00.000Z', + type: 'regular', + }, + ], + reviewState: 'none', + } as TeamTaskWithKanban, + ]; + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(MemberDetailDialog, { + open: true, + member, + teamName: 'demo-team', + members, + tasks, + onClose: () => undefined, + onSendMessage: () => undefined, + onAssignTask: () => undefined, + onTaskClick: () => undefined, + }) + ); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('activity-count:1'); + expect(host.textContent).toContain('Activity1'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); +}); diff --git a/test/renderer/components/team/members/MemberMessagesTab.test.ts b/test/renderer/components/team/members/MemberMessagesTab.test.ts index 871e86e7..c8cfad60 100644 --- a/test/renderer/components/team/members/MemberMessagesTab.test.ts +++ b/test/renderer/components/team/members/MemberMessagesTab.test.ts @@ -194,7 +194,7 @@ describe('MemberMessagesTab', () => { }); }); - it('hides load older messages when the member has no visible activity', async () => { + it('shows load older messages when older pages may still contain this member activity', async () => { getMessagesPage.mockResolvedValue({ messages: [ { @@ -282,8 +282,8 @@ describe('MemberMessagesTab', () => { }); expect(getMessagesPage).not.toHaveBeenCalled(); - expect(host.textContent).toContain('No activity with this member'); - expect(host.textContent).not.toContain('Load older messages'); + expect(host.textContent).toContain('No loaded activity for this member yet'); + expect(host.textContent).toContain('Load older messages'); await act(async () => { root.unmount(); diff --git a/test/renderer/store/teamChangeThrottle.test.ts b/test/renderer/store/teamChangeThrottle.test.ts index 8abe8f90..47fd6c9c 100644 --- a/test/renderer/store/teamChangeThrottle.test.ts +++ b/test/renderer/store/teamChangeThrottle.test.ts @@ -219,7 +219,7 @@ describe('team change throttling', () => { }); it('lead-message refreshes hidden teams with an active pending-reply wait state', async () => { - useStore.getState().syncTeamPendingReplyRefresh('other-team', true, 60_000); + useStore.getState().syncTeamPendingReplyRefresh('other-team', 'tab-hidden', true, 60_000); useStore.setState({ paneLayout: { focusedPaneId: 'p1', @@ -304,7 +304,7 @@ describe('team change throttling', () => { }); it('fallback polling refreshes hidden teams with an active pending-reply wait state', async () => { - useStore.getState().syncTeamPendingReplyRefresh('other-team', true, 60_000); + useStore.getState().syncTeamPendingReplyRefresh('other-team', 'tab-hidden', true, 60_000); const refreshTeamMessagesHeadSpy = vi.spyOn(useStore.getState(), 'refreshTeamMessagesHead'); const refreshMemberActivityMetaSpy = vi.spyOn(useStore.getState(), 'refreshMemberActivityMeta'); diff --git a/test/renderer/store/teamSlice.test.ts b/test/renderer/store/teamSlice.test.ts index 48b18f6b..aa211380 100644 --- a/test/renderer/store/teamSlice.test.ts +++ b/test/renderer/store/teamSlice.test.ts @@ -2,11 +2,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { create } from 'zustand'; import { + __getTeamScopedTransientStateForTests, __resetTeamSliceModuleStateForTests, createTeamSlice, - selectResolvedMemberForTeamName, + getActiveTeamPendingReplyWaits, + hasActiveTeamPendingReplyWait, getCurrentProvisioningProgressForTeam, selectMemberMessagesForTeamMember, + selectResolvedMemberForTeamName, selectResolvedMembersForTeamName, } from '../../../src/renderer/store/slices/teamSlice'; @@ -16,6 +19,7 @@ const hoisted = vi.hoisted(() => ({ getMessagesPage: vi.fn(), getMemberActivityMeta: vi.fn(), createTeam: vi.fn(), + launchTeam: vi.fn(), getProvisioningStatus: vi.fn(), getMemberSpawnStatuses: vi.fn(), cancelProvisioning: vi.fn(), @@ -37,6 +41,7 @@ vi.mock('@renderer/api', () => ({ getMessagesPage: hoisted.getMessagesPage, getMemberActivityMeta: hoisted.getMemberActivityMeta, createTeam: hoisted.createTeam, + launchTeam: hoisted.launchTeam, getProvisioningStatus: hoisted.getProvisioningStatus, getMemberSpawnStatuses: hoisted.getMemberSpawnStatuses, cancelProvisioning: hoisted.cancelProvisioning, @@ -186,6 +191,7 @@ describe('teamSlice actions', () => { hoisted.requestReview.mockResolvedValue(undefined); hoisted.updateKanban.mockResolvedValue(undefined); hoisted.createTeam.mockResolvedValue({ runId: 'run-1' }); + hoisted.launchTeam.mockResolvedValue({ runId: 'run-1' }); hoisted.invalidateTaskChangeSummaries.mockResolvedValue(undefined); hoisted.getProvisioningStatus.mockResolvedValue({ runId: 'run-1', @@ -985,6 +991,169 @@ describe('teamSlice actions', () => { ).toEqual(['msg-4', 'msg-3', 'msg-2', 'msg-1']); }); + it('drops a queued head refresh behind an older-page load when launch invalidates the team epoch', async () => { + const store = createSliceStore(); + const olderRequest = createDeferredPromise<{ + messages: Array<{ + from: string; + text: string; + timestamp: string; + read: boolean; + source: string; + messageId: string; + }>; + nextCursor: string | null; + hasMore: boolean; + feedRevision: string; + }>(); + + store.setState({ + teamMessagesByName: { + 'my-team': { + canonicalMessages: [ + { + from: 'team-lead', + text: 'Head 1', + timestamp: '2026-03-20T08:00:02.000Z', + read: true, + source: 'lead_session', + messageId: 'msg-3', + }, + { + from: 'alice', + text: 'Head 0', + timestamp: '2026-03-20T08:00:01.000Z', + read: true, + source: 'inbox', + messageId: 'msg-2', + }, + ], + optimisticMessages: [], + feedRevision: 'rev-1', + nextCursor: 'cursor-older', + hasMore: true, + lastFetchedAt: 123, + loadingHead: false, + loadingOlder: false, + headHydrated: true, + }, + }, + }); + + hoisted.getMessagesPage.mockImplementationOnce(() => olderRequest.promise); + + const olderPromise = store.getState().loadOlderTeamMessages('my-team'); + const queuedHeadPromise = store.getState().refreshTeamMessagesHead('my-team'); + + await Promise.resolve(); + await store.getState().launchTeam({ + teamName: 'my-team', + cwd: '/tmp/project', + }); + + olderRequest.resolve({ + messages: [ + { + from: 'bob', + text: 'Older tail', + timestamp: '2026-03-20T08:00:00.000Z', + read: true, + source: 'inbox', + messageId: 'msg-1', + }, + ], + nextCursor: null, + hasMore: false, + feedRevision: 'rev-1', + }); + + await olderPromise; + await expect(queuedHeadPromise).resolves.toEqual({ + feedChanged: false, + headChanged: false, + feedRevision: null, + }); + + expect(hoisted.getMessagesPage).toHaveBeenCalledTimes(1); + expect(store.getState().teamMessagesByName['my-team']?.feedRevision).toBe('rev-1'); + }); + + it('does not continue an older-page fetch with a stale cursor after launch invalidates while waiting for head refresh', async () => { + const store = createSliceStore(); + const headRequest = createDeferredPromise<{ + messages: Array<{ + from: string; + text: string; + timestamp: string; + read: boolean; + source: string; + messageId: string; + }>; + nextCursor: string | null; + hasMore: boolean; + feedRevision: string; + }>(); + + store.setState({ + teamMessagesByName: { + 'my-team': { + canonicalMessages: [ + { + from: 'team-lead', + text: 'Head 1', + timestamp: '2026-03-20T08:00:02.000Z', + read: true, + source: 'lead_session', + messageId: 'msg-3', + }, + ], + optimisticMessages: [], + feedRevision: 'rev-1', + nextCursor: 'cursor-older', + hasMore: true, + lastFetchedAt: 123, + loadingHead: false, + loadingOlder: false, + headHydrated: true, + }, + }, + }); + + hoisted.getMessagesPage.mockImplementationOnce(() => headRequest.promise); + + const headPromise = store.getState().refreshTeamMessagesHead('my-team'); + const olderPromise = store.getState().loadOlderTeamMessages('my-team'); + + await Promise.resolve(); + await store.getState().launchTeam({ + teamName: 'my-team', + cwd: '/tmp/project', + }); + + headRequest.resolve({ + messages: [ + { + from: 'team-lead', + text: 'Fresh head', + timestamp: '2026-03-20T08:00:03.000Z', + read: true, + source: 'lead_session', + messageId: 'msg-4', + }, + ], + nextCursor: 'cursor-head', + hasMore: true, + feedRevision: 'rev-2', + }); + + await headPromise; + await olderPromise; + + expect(hoisted.getMessagesPage).toHaveBeenCalledTimes(1); + expect(store.getState().teamMessagesByName['my-team']?.feedRevision).toBe('rev-1'); + expect(store.getState().teamMessagesByName['my-team']?.loadingOlder).toBe(false); + }); + it('schedules pending-reply refresh through store-owned timers', async () => { vi.useFakeTimers(); try { @@ -1000,7 +1169,7 @@ describe('teamSlice actions', () => { .spyOn(store.getState(), 'refreshMemberActivityMeta') .mockResolvedValue(undefined); - store.getState().syncTeamPendingReplyRefresh('my-team', true, 1_000); + store.getState().syncTeamPendingReplyRefresh('my-team', 'tab-a', true, 1_000); await vi.advanceTimersByTimeAsync(999); expect(refreshTeamMessagesHeadSpy).not.toHaveBeenCalled(); @@ -1009,8 +1178,8 @@ describe('teamSlice actions', () => { expect(refreshTeamMessagesHeadSpy).toHaveBeenCalledTimes(1); expect(refreshMemberActivityMetaSpy).toHaveBeenCalledTimes(1); - store.getState().syncTeamPendingReplyRefresh('my-team', true, 1_000); - store.getState().syncTeamPendingReplyRefresh('my-team', false); + store.getState().syncTeamPendingReplyRefresh('my-team', 'tab-a', true, 1_000); + store.getState().syncTeamPendingReplyRefresh('my-team', 'tab-a', false); await vi.advanceTimersByTimeAsync(1_000); expect(refreshTeamMessagesHeadSpy).toHaveBeenCalledTimes(1); @@ -1019,6 +1188,22 @@ describe('teamSlice actions', () => { } }); + it('keeps pending-reply refresh ownership active while another source still waits for the same team', () => { + const store = createSliceStore(); + + store.getState().syncTeamPendingReplyRefresh('my-team', 'tab-a', true, 1_000); + store.getState().syncTeamPendingReplyRefresh('my-team', 'tab-b', true, 1_000); + store.getState().syncTeamPendingReplyRefresh('my-team', 'tab-b', false); + + expect(hasActiveTeamPendingReplyWait('my-team')).toBe(true); + expect(getActiveTeamPendingReplyWaits()).toEqual(new Set(['my-team'])); + + store.getState().syncTeamPendingReplyRefresh('my-team', 'tab-a', false); + + expect(hasActiveTeamPendingReplyWait('my-team')).toBe(false); + expect(getActiveTeamPendingReplyWaits().size).toBe(0); + }); + it('single-flights concurrent member activity refreshes and re-fetches after feed revision changes', async () => { const store = createSliceStore(); const firstRequest = createDeferredPromise<{ @@ -1161,6 +1346,25 @@ describe('teamSlice actions', () => { feedRevision: 'rev-1', }, }, + leadActivityByTeam: { + 'my-team': 'active', + }, + leadContextByTeam: { + 'my-team': { + currentTokens: 12, + contextWindow: 100, + percent: 12, + updatedAt: '2026-03-12T10:00:00.000Z', + }, + }, + memberSpawnStatusesByTeam: { + 'my-team': { + alice: createMemberSpawnStatus(), + }, + }, + memberSpawnSnapshotsByTeam: { + 'my-team': createMemberSpawnSnapshot(), + }, }); const initialResolvedMembers = selectResolvedMembersForTeamName(store.getState(), 'my-team'); @@ -1364,6 +1568,787 @@ describe('teamSlice actions', () => { expect(store.getState().teamDataCacheByName['my-team']).toBeUndefined(); }); + it('clears team-scoped selector and transient caches on delete and restore flows', async () => { + const store = createSliceStore(); + const message = { + from: 'alice', + to: 'team-lead', + text: 'hello', + timestamp: '2026-03-12T10:00:00.000Z', + messageId: 'm-1', + source: 'inbox' as const, + }; + + store.setState({ + selectedTeamName: 'my-team', + selectedTeamData: createTeamSnapshot({ + members: [ + { + name: 'alice', + role: 'developer', + currentTaskId: null, + }, + ], + }), + teamDataCacheByName: { + 'my-team': createTeamSnapshot({ + members: [ + { + name: 'alice', + role: 'developer', + currentTaskId: null, + }, + ], + }), + }, + teamMessagesByName: { + 'my-team': { + canonicalMessages: [message], + optimisticMessages: [], + nextCursor: null, + hasMore: false, + feedRevision: 'rev-1', + lastFetchedAt: Date.now(), + loadingHead: false, + loadingOlder: false, + headHydrated: true, + }, + }, + memberActivityMetaByTeam: { + 'my-team': { + teamName: 'my-team', + computedAt: '2026-03-12T10:00:00.000Z', + feedRevision: 'rev-1', + members: { + alice: { + memberName: 'alice', + lastAuthoredMessageAt: '2026-03-12T10:00:00.000Z', + messageCountExact: 1, + latestAuthoredMessageSignalsTermination: false, + }, + }, + }, + }, + }); + + selectResolvedMembersForTeamName(store.getState(), 'my-team'); + selectResolvedMemberForTeamName(store.getState(), 'my-team', 'alice'); + selectMemberMessagesForTeamMember(store.getState(), 'my-team', 'alice'); + + await store.getState().refreshTeamData('my-team', { withDedup: false }); + store.getState().syncTeamPendingReplyRefresh('my-team', 'test-source', true); + + expect(__getTeamScopedTransientStateForTests('my-team')).toMatchObject({ + hasResolvedMembersSelector: true, + resolvedMemberSelectorCount: 1, + hasMergedMessagesSelector: true, + memberMessagesSelectorCount: 1, + hasLastResolvedTeamDataRefresh: true, + }); + + await store.getState().deleteTeam('my-team'); + + expect(__getTeamScopedTransientStateForTests('my-team')).toEqual({ + hasResolvedMembersSelector: false, + resolvedMemberSelectorCount: 0, + hasMergedMessagesSelector: false, + memberMessagesSelectorCount: 0, + hasPendingFreshTeamDataRefresh: false, + hasQueuedHeadRefreshAfterOlder: false, + hasPendingFreshMessagesHeadRefresh: false, + hasPendingFreshMemberActivityMetaRefresh: false, + hasLastResolvedTeamDataRefresh: false, + hasCurrentLocalStateEpoch: true, + hasMemberSpawnStatusesIpcBackoff: false, + hasTeamRefreshBurstDiagnostics: false, + hasMemberSpawnUiEqualLastWarn: false, + }); + expect(store.getState().leadActivityByTeam['my-team']).toBeUndefined(); + expect(store.getState().leadContextByTeam['my-team']).toBeUndefined(); + expect(store.getState().memberSpawnStatusesByTeam['my-team']).toBeUndefined(); + expect(store.getState().memberSpawnSnapshotsByTeam['my-team']).toBeUndefined(); + + store.setState({ + teamDataCacheByName: { + 'my-team': createTeamSnapshot({ + members: [ + { + name: 'alice', + role: 'developer', + currentTaskId: null, + }, + ], + }), + }, + teamMessagesByName: { + 'my-team': { + canonicalMessages: [message], + optimisticMessages: [], + nextCursor: null, + hasMore: false, + feedRevision: 'rev-1', + lastFetchedAt: Date.now(), + loadingHead: false, + loadingOlder: false, + headHydrated: true, + }, + }, + memberActivityMetaByTeam: { + 'my-team': { + teamName: 'my-team', + computedAt: '2026-03-12T10:00:00.000Z', + feedRevision: 'rev-1', + members: { + alice: { + memberName: 'alice', + lastAuthoredMessageAt: '2026-03-12T10:00:00.000Z', + messageCountExact: 1, + latestAuthoredMessageSignalsTermination: false, + }, + }, + }, + }, + leadActivityByTeam: { + 'my-team': 'active', + }, + leadContextByTeam: { + 'my-team': { + currentTokens: 12, + contextWindow: 100, + percent: 12, + updatedAt: '2026-03-12T10:00:00.000Z', + }, + }, + memberSpawnStatusesByTeam: { + 'my-team': { + alice: createMemberSpawnStatus(), + }, + }, + memberSpawnSnapshotsByTeam: { + 'my-team': createMemberSpawnSnapshot(), + }, + }); + selectResolvedMembersForTeamName(store.getState(), 'my-team'); + selectResolvedMemberForTeamName(store.getState(), 'my-team', 'alice'); + selectMemberMessagesForTeamMember(store.getState(), 'my-team', 'alice'); + + expect(__getTeamScopedTransientStateForTests('my-team')).toMatchObject({ + hasResolvedMembersSelector: true, + resolvedMemberSelectorCount: 1, + hasMergedMessagesSelector: true, + memberMessagesSelectorCount: 1, + }); + + await store.getState().restoreTeam('my-team'); + + expect(__getTeamScopedTransientStateForTests('my-team')).toEqual({ + hasResolvedMembersSelector: false, + resolvedMemberSelectorCount: 0, + hasMergedMessagesSelector: false, + memberMessagesSelectorCount: 0, + hasPendingFreshTeamDataRefresh: false, + hasQueuedHeadRefreshAfterOlder: false, + hasPendingFreshMessagesHeadRefresh: false, + hasPendingFreshMemberActivityMetaRefresh: false, + hasLastResolvedTeamDataRefresh: false, + hasCurrentLocalStateEpoch: true, + hasMemberSpawnStatusesIpcBackoff: false, + hasTeamRefreshBurstDiagnostics: false, + hasMemberSpawnUiEqualLastWarn: false, + }); + expect(store.getState().leadActivityByTeam['my-team']).toBeUndefined(); + expect(store.getState().leadContextByTeam['my-team']).toBeUndefined(); + expect(store.getState().memberSpawnStatusesByTeam['my-team']).toBeUndefined(); + expect(store.getState().memberSpawnSnapshotsByTeam['my-team']).toBeUndefined(); + }); + + it('ignores stale async team snapshot and message refreshes after delete invalidates the team', async () => { + const store = createSliceStore(); + const deferredData = createDeferredPromise>(); + const deferredMessages = createDeferredPromise<{ + messages: Array<{ + from: string; + text: string; + timestamp: string; + messageId: string; + source: 'inbox'; + }>; + nextCursor: null; + hasMore: false; + feedRevision: string; + }>(); + const deferredMeta = createDeferredPromise<{ + teamName: string; + computedAt: string; + feedRevision: string; + members: Record< + string, + { + memberName: string; + lastAuthoredMessageAt: string | null; + messageCountExact: number; + latestAuthoredMessageSignalsTermination: boolean; + } + >; + }>(); + + hoisted.getData.mockImplementation(() => deferredData.promise); + hoisted.getMessagesPage.mockImplementation(() => deferredMessages.promise); + hoisted.getMemberActivityMeta.mockImplementation(() => deferredMeta.promise); + + store.setState({ + teamMessagesByName: { + 'my-team': { + canonicalMessages: [], + optimisticMessages: [], + nextCursor: null, + hasMore: false, + feedRevision: 'rev-0', + lastFetchedAt: Date.now(), + loadingHead: false, + loadingOlder: false, + headHydrated: true, + }, + }, + }); + + const refreshDataPromise = store.getState().refreshTeamData('my-team', { withDedup: false }); + const refreshMessagesPromise = store.getState().refreshTeamMessagesHead('my-team'); + const refreshMetaPromise = store.getState().refreshMemberActivityMeta('my-team'); + + await Promise.resolve(); + await store.getState().deleteTeam('my-team'); + + deferredData.resolve( + createTeamSnapshot({ + members: [{ name: 'alice', role: 'developer', currentTaskId: null }], + }) + ); + deferredMessages.resolve({ + messages: [ + { + from: 'alice', + text: 'late-message', + timestamp: '2026-03-12T10:00:00.000Z', + messageId: 'late-1', + source: 'inbox', + }, + ], + nextCursor: null, + hasMore: false, + feedRevision: 'rev-late', + }); + deferredMeta.resolve({ + teamName: 'my-team', + computedAt: '2026-03-12T10:00:00.000Z', + feedRevision: 'rev-late', + members: { + alice: { + memberName: 'alice', + lastAuthoredMessageAt: '2026-03-12T10:00:00.000Z', + messageCountExact: 1, + latestAuthoredMessageSignalsTermination: false, + }, + }, + }); + + await Promise.all([refreshDataPromise, refreshMessagesPromise, refreshMetaPromise]); + + expect(store.getState().teamDataCacheByName['my-team']).toBeUndefined(); + expect(store.getState().teamMessagesByName['my-team']).toBeUndefined(); + expect(store.getState().memberActivityMetaByTeam['my-team']).toBeUndefined(); + }); + + it('ignores stale async team refreshes after launch starts a new local epoch for the same team', async () => { + const store = createSliceStore(); + const existingData = createTeamSnapshot({ + config: { name: 'My Team Before Launch' }, + members: [{ name: 'lead', role: 'lead', currentTaskId: null }], + }); + const existingMeta: { + teamName: string; + computedAt: string; + feedRevision: string; + members: Record< + string, + { + memberName: string; + lastAuthoredMessageAt: string | null; + messageCountExact: number; + latestAuthoredMessageSignalsTermination: boolean; + } + >; + } = { + teamName: 'my-team', + computedAt: '2026-03-12T09:59:00.000Z', + feedRevision: 'rev-0', + members: { + lead: { + memberName: 'lead', + lastAuthoredMessageAt: '2026-03-12T09:59:00.000Z', + messageCountExact: 1, + latestAuthoredMessageSignalsTermination: false, + }, + }, + }; + const deferredData = createDeferredPromise>(); + const deferredMessages = createDeferredPromise<{ + messages: Array<{ + from: string; + text: string; + timestamp: string; + messageId: string; + source: 'inbox'; + }>; + nextCursor: null; + hasMore: false; + feedRevision: string; + }>(); + const deferredMeta = createDeferredPromise(); + + hoisted.getData.mockImplementation(() => deferredData.promise); + hoisted.getMessagesPage.mockImplementation(() => deferredMessages.promise); + hoisted.getMemberActivityMeta.mockImplementation(() => deferredMeta.promise); + + store.setState({ + selectedTeamName: 'my-team', + selectedTeamData: existingData, + teamDataCacheByName: { + 'my-team': existingData, + }, + teamMessagesByName: { + 'my-team': { + canonicalMessages: [], + optimisticMessages: [], + nextCursor: null, + hasMore: false, + feedRevision: 'rev-0', + lastFetchedAt: Date.now(), + loadingHead: false, + loadingOlder: false, + headHydrated: true, + }, + }, + memberActivityMetaByTeam: { + 'my-team': existingMeta, + }, + }); + + const refreshDataPromise = store.getState().refreshTeamData('my-team', { withDedup: false }); + const refreshMessagesPromise = store.getState().refreshTeamMessagesHead('my-team'); + const refreshMetaPromise = store.getState().refreshMemberActivityMeta('my-team'); + + await Promise.resolve(); + await store.getState().launchTeam({ + teamName: 'my-team', + cwd: '/tmp/project', + }); + + expect(store.getState().teamMessagesByName['my-team']?.loadingHead).toBe(false); + + deferredData.resolve( + createTeamSnapshot({ + config: { name: 'My Team Stale After Launch' }, + members: [{ name: 'alice', role: 'reviewer', currentTaskId: null }], + }) + ); + deferredMessages.resolve({ + messages: [ + { + from: 'alice', + text: 'stale-after-launch', + timestamp: '2026-03-12T10:00:00.000Z', + messageId: 'stale-after-launch-1', + source: 'inbox', + }, + ], + nextCursor: null, + hasMore: false, + feedRevision: 'rev-stale-after-launch', + }); + deferredMeta.resolve({ + teamName: 'my-team', + computedAt: '2026-03-12T10:00:00.000Z', + feedRevision: 'rev-stale-after-launch', + members: { + alice: { + memberName: 'alice', + lastAuthoredMessageAt: '2026-03-12T10:00:00.000Z', + messageCountExact: 3, + latestAuthoredMessageSignalsTermination: false, + }, + }, + }); + + await Promise.all([refreshDataPromise, refreshMessagesPromise, refreshMetaPromise]); + + expect(store.getState().selectedTeamData).toBe(existingData); + expect(store.getState().teamDataCacheByName['my-team']).toBe(existingData); + expect(store.getState().teamMessagesByName['my-team']?.feedRevision).toBe('rev-0'); + expect(store.getState().memberActivityMetaByTeam['my-team']).toEqual(existingMeta); + }); + + it('clears stale selectedTeamLoading when launch invalidates an in-flight selectTeam request', async () => { + const store = createSliceStore(); + const existingData = createTeamSnapshot({ + config: { name: 'My Team Cached' }, + members: [{ name: 'lead', role: 'lead', currentTaskId: null }], + }); + const deferredData = createDeferredPromise>(); + + hoisted.getData.mockImplementationOnce(() => deferredData.promise); + + store.setState({ + teamDataCacheByName: { + 'my-team': existingData, + }, + }); + + const selectPromise = store.getState().selectTeam('my-team'); + await Promise.resolve(); + + expect(store.getState().selectedTeamLoading).toBe(true); + expect(store.getState().selectedTeamData).toEqual(existingData); + + await store.getState().launchTeam({ + teamName: 'my-team', + cwd: '/tmp/project', + }); + + expect(store.getState().selectedTeamLoading).toBe(false); + expect(store.getState().selectedTeamError).toBeNull(); + expect(store.getState().selectedTeamData).toEqual(existingData); + + deferredData.resolve( + createTeamSnapshot({ + config: { name: 'My Team Stale Select' }, + members: [{ name: 'alice', role: 'reviewer', currentTaskId: null }], + }) + ); + await selectPromise; + + expect(store.getState().selectedTeamLoading).toBe(false); + expect(store.getState().selectedTeamData).toEqual(existingData); + }); + + it('clears stale loadingOlder when launch invalidates an in-flight older messages request', async () => { + const store = createSliceStore(); + const olderRequest = createDeferredPromise<{ + messages: Array<{ + from: string; + text: string; + timestamp: string; + read: boolean; + source: string; + messageId: string; + }>; + nextCursor: string | null; + hasMore: boolean; + feedRevision: string; + }>(); + + store.setState({ + teamMessagesByName: { + 'my-team': { + canonicalMessages: [], + optimisticMessages: [], + feedRevision: 'rev-1', + nextCursor: 'cursor-older', + hasMore: true, + lastFetchedAt: 123, + loadingHead: false, + loadingOlder: false, + headHydrated: true, + }, + }, + }); + + hoisted.getMessagesPage.mockImplementationOnce(() => olderRequest.promise); + + const olderPromise = store.getState().loadOlderTeamMessages('my-team'); + await Promise.resolve(); + expect(store.getState().teamMessagesByName['my-team']?.loadingOlder).toBe(true); + + await store.getState().launchTeam({ + teamName: 'my-team', + cwd: '/tmp/project', + }); + + expect(store.getState().teamMessagesByName['my-team']?.loadingOlder).toBe(false); + + olderRequest.resolve({ + messages: [ + { + from: 'bob', + text: 'Older tail', + timestamp: '2026-03-20T08:00:00.000Z', + read: true, + source: 'inbox', + messageId: 'msg-1', + }, + ], + nextCursor: null, + hasMore: false, + feedRevision: 'rev-1', + }); + + await olderPromise; + expect(store.getState().teamMessagesByName['my-team']?.loadingOlder).toBe(false); + }); + + it('ignores stale refreshTeamData failures after launch starts a new local epoch', async () => { + const store = createSliceStore(); + const existingData = createTeamSnapshot({ + config: { name: 'My Team Stable' }, + members: [{ name: 'lead', role: 'lead', currentTaskId: null }], + }); + const deferredData = createDeferredPromise>(); + + hoisted.getData.mockImplementation(() => deferredData.promise); + + store.setState({ + selectedTeamName: 'my-team', + selectedTeamData: existingData, + teamDataCacheByName: { + 'my-team': existingData, + }, + selectedTeamError: null, + }); + + const refreshPromise = store.getState().refreshTeamData('my-team', { withDedup: false }); + await Promise.resolve(); + await store.getState().launchTeam({ + teamName: 'my-team', + cwd: '/tmp/project', + }); + + deferredData.reject(new Error('TEAM_DRAFT')); + await refreshPromise; + + expect(store.getState().selectedTeamData).toBe(existingData); + expect(store.getState().teamDataCacheByName['my-team']).toBe(existingData); + expect(store.getState().selectedTeamError).toBeNull(); + }); + + it('keeps the newer messages-head request pinned when a stale pre-launch request settles', async () => { + const store = createSliceStore(); + const deferredOld = createDeferredPromise<{ + messages: Array<{ + from: string; + text: string; + timestamp: string; + messageId: string; + source: 'inbox'; + }>; + nextCursor: null; + hasMore: false; + feedRevision: string; + }>(); + const deferredNew = createDeferredPromise<{ + messages: Array<{ + from: string; + text: string; + timestamp: string; + messageId: string; + source: 'inbox'; + }>; + nextCursor: null; + hasMore: false; + feedRevision: string; + }>(); + + hoisted.getMessagesPage + .mockImplementationOnce(() => deferredOld.promise) + .mockImplementationOnce(() => deferredNew.promise); + + const firstPromise = store.getState().refreshTeamMessagesHead('my-team'); + await Promise.resolve(); + await store.getState().launchTeam({ + teamName: 'my-team', + cwd: '/tmp/project', + }); + + const secondPromise = store.getState().refreshTeamMessagesHead('my-team'); + await Promise.resolve(); + + deferredOld.reject(new Error('stale head failed')); + await expect(firstPromise).resolves.toEqual({ + feedChanged: false, + headChanged: false, + feedRevision: null, + }); + + expect(hoisted.getMessagesPage).toHaveBeenCalledTimes(2); + + deferredNew.resolve({ + messages: [ + { + from: 'bob', + text: 'fresh-after-launch', + timestamp: '2026-03-12T10:00:01.000Z', + messageId: 'fresh-after-launch-1', + source: 'inbox', + }, + ], + nextCursor: null, + hasMore: false, + feedRevision: 'rev-fresh-after-launch', + }); + + await secondPromise; + + expect(store.getState().teamMessagesByName['my-team']?.feedRevision).toBe( + 'rev-fresh-after-launch' + ); + }); + + it('does not reuse a pre-delete in-flight team snapshot request after the same team is reselected', async () => { + const store = createSliceStore(); + const deferredOld = createDeferredPromise>(); + const freshSnapshot = createTeamSnapshot({ + config: { name: 'My Team Reloaded' }, + members: [{ name: 'bob', role: 'developer', currentTaskId: null }], + }); + + hoisted.getData + .mockImplementationOnce(() => deferredOld.promise) + .mockResolvedValueOnce(freshSnapshot); + + const firstSelectPromise = store.getState().selectTeam('my-team'); + await Promise.resolve(); + await store.getState().deleteTeam('my-team'); + + const secondSelectPromise = store.getState().selectTeam('my-team'); + await secondSelectPromise; + + expect(hoisted.getData).toHaveBeenCalledTimes(2); + expect(store.getState().selectedTeamData).toEqual(freshSnapshot); + + deferredOld.resolve( + createTeamSnapshot({ + config: { name: 'My Team Stale' }, + members: [{ name: 'alice', role: 'reviewer', currentTaskId: null }], + }) + ); + await firstSelectPromise; + + expect(store.getState().selectedTeamData).toEqual(freshSnapshot); + }); + + it('does not reuse a pre-delete in-flight messages head request after the same team is reselected', async () => { + const store = createSliceStore(); + const deferredOld = createDeferredPromise<{ + messages: Array<{ + from: string; + text: string; + timestamp: string; + messageId: string; + source: 'inbox'; + }>; + nextCursor: null; + hasMore: false; + feedRevision: string; + }>(); + + hoisted.getMessagesPage + .mockImplementationOnce(() => deferredOld.promise) + .mockResolvedValueOnce({ + messages: [ + { + from: 'bob', + text: 'fresh-message', + timestamp: '2026-03-12T10:00:01.000Z', + messageId: 'fresh-1', + source: 'inbox', + }, + ], + nextCursor: null, + hasMore: false, + feedRevision: 'rev-fresh', + }); + + const firstHeadPromise = store.getState().refreshTeamMessagesHead('my-team'); + await Promise.resolve(); + await store.getState().deleteTeam('my-team'); + + const secondHeadPromise = store.getState().refreshTeamMessagesHead('my-team'); + await secondHeadPromise; + + expect(hoisted.getMessagesPage).toHaveBeenCalledTimes(2); + expect(store.getState().teamMessagesByName['my-team']?.feedRevision).toBe('rev-fresh'); + expect(store.getState().teamMessagesByName['my-team']?.canonicalMessages).toEqual([ + { + from: 'bob', + text: 'fresh-message', + timestamp: '2026-03-12T10:00:01.000Z', + messageId: 'fresh-1', + source: 'inbox', + }, + ]); + + deferredOld.resolve({ + messages: [ + { + from: 'alice', + text: 'stale-message', + timestamp: '2026-03-12T10:00:00.000Z', + messageId: 'stale-1', + source: 'inbox', + }, + ], + nextCursor: null, + hasMore: false, + feedRevision: 'rev-stale', + }); + await firstHeadPromise; + + expect(store.getState().teamMessagesByName['my-team']?.feedRevision).toBe('rev-fresh'); + }); + + it('tombstones current progress runs when delete clears a team so late progress cannot resurrect it', async () => { + const store = createSliceStore(); + store.setState({ + provisioningRuns: { + 'run-live': { + runId: 'run-live', + teamName: 'my-team', + state: 'assembling', + message: 'Live run', + startedAt: '2026-03-12T10:00:00.000Z', + updatedAt: '2026-03-12T10:00:00.000Z', + }, + }, + currentProvisioningRunIdByTeam: { + 'my-team': 'run-live', + }, + currentRuntimeRunIdByTeam: { + 'my-team': 'run-live', + }, + provisioningStartedAtFloorByTeam: { + 'my-team': '2026-03-12T10:00:00.000Z', + }, + }); + + await store.getState().deleteTeam('my-team'); + + expect(store.getState().ignoredProvisioningRunIds['run-live']).toBe('my-team'); + expect(store.getState().ignoredRuntimeRunIds['run-live']).toBe('my-team'); + expect(store.getState().provisioningStartedAtFloorByTeam['my-team']).toBeTruthy(); + + store.getState().onProvisioningProgress({ + runId: 'run-live', + teamName: 'my-team', + state: 'ready', + message: 'Late zombie progress', + startedAt: '2026-03-12T10:00:00.000Z', + updatedAt: '2026-03-12T10:00:05.000Z', + }); + + expect(store.getState().provisioningRuns['run-live']).toBeUndefined(); + expect(store.getState().currentProvisioningRunIdByTeam['my-team']).toBeUndefined(); + expect(store.getState().currentRuntimeRunIdByTeam['my-team']).toBeUndefined(); + }); + describe('refreshTeamData provisioning safety', () => { it('does not set fatal error on TEAM_PROVISIONING', async () => { const store = createSliceStore(); @@ -2413,7 +3398,7 @@ describe('teamSlice actions', () => { await store.getState().fetchMemberSpawnStatuses('my-team'); expect(store.getState().currentRuntimeRunIdByTeam['my-team']).toBe('runtime-run'); - expect(store.getState().ignoredRuntimeRunIds['runtime-old']).toBeUndefined(); + expect(store.getState().ignoredRuntimeRunIds['runtime-old']).toBe('my-team'); expect(store.getState().memberSpawnStatusesByTeam['my-team']).toBe(previousStatuses); expect(store.getState().memberSpawnSnapshotsByTeam['my-team']).toBe(previousSnapshot); }); @@ -2500,12 +3485,73 @@ describe('teamSlice actions', () => { }); expect(store.getState().currentRuntimeRunIdByTeam['my-team']).toBe('run-1'); - expect(store.getState().ignoredRuntimeRunIds['runtime-old']).toBeUndefined(); + expect(store.getState().ignoredRuntimeRunIds['runtime-old']).toBe('my-team'); expect(store.getState().activeToolsByTeam['my-team']).toBeUndefined(); expect(store.getState().finishedVisibleByTeam['my-team']).toBeUndefined(); expect(store.getState().toolHistoryByTeam['my-team']).toBeUndefined(); }); + it('keeps tombstoned runtime ids ignored during createTeam startup before the new run is pinned', async () => { + const store = createSliceStore(); + const createDeferred = createDeferredPromise<{ runId: string }>(); + hoisted.createTeam.mockImplementation(() => createDeferred.promise); + store.setState({ + currentRuntimeRunIdByTeam: { + 'my-team': 'runtime-live', + }, + ignoredRuntimeRunIds: { + 'runtime-old': 'my-team', + }, + }); + + const createPromise = store.getState().createTeam({ + teamName: 'my-team', + cwd: '/tmp/project', + members: [], + }); + + await Promise.resolve(); + + expect(store.getState().currentRuntimeRunIdByTeam['my-team']).toBeUndefined(); + expect(store.getState().ignoredRuntimeRunIds['runtime-old']).toBe('my-team'); + expect(store.getState().ignoredRuntimeRunIds['runtime-live']).toBe('my-team'); + + hoisted.getMemberSpawnStatuses.mockResolvedValue( + createMemberSpawnSnapshot({ + runId: 'runtime-old', + }) + ); + + await store.getState().fetchMemberSpawnStatuses('my-team'); + + expect(store.getState().memberSpawnStatusesByTeam['my-team']).toBeUndefined(); + expect(store.getState().memberSpawnSnapshotsByTeam['my-team']).toBeUndefined(); + + createDeferred.resolve({ runId: 'run-1' }); + await createPromise; + }); + + it('keeps older tombstoned runtime ids after canonical provisioning progress arrives', () => { + const store = createSliceStore(); + store.setState({ + ignoredRuntimeRunIds: { + 'runtime-old': 'my-team', + }, + }); + + store.getState().onProvisioningProgress({ + runId: 'run-current', + teamName: 'my-team', + state: 'assembling', + message: 'Current run', + startedAt: '2026-03-12T10:00:00.000Z', + updatedAt: '2026-03-12T10:00:01.000Z', + }); + + expect(store.getState().currentRuntimeRunIdByTeam['my-team']).toBe('run-current'); + expect(store.getState().ignoredRuntimeRunIds['runtime-old']).toBe('my-team'); + }); + it('ignores tombstoned runtime spawn-status snapshots', async () => { const store = createSliceStore(); store.setState({