fix(renderer): reduce team detail refresh churn
This commit is contained in:
parent
bf6370556d
commit
535178a076
3 changed files with 796 additions and 102 deletions
|
|
@ -19,12 +19,43 @@ const logger = createLogger('teamSlice');
|
||||||
const TEAM_GET_DATA_TIMEOUT_MS = 30_000;
|
const TEAM_GET_DATA_TIMEOUT_MS = 30_000;
|
||||||
const TEAM_FETCH_TIMEOUT_MS = 30_000;
|
const TEAM_FETCH_TIMEOUT_MS = 30_000;
|
||||||
const MEMBER_SPAWN_STATUSES_IPC_RETRY_BACKOFF_MS = 5_000;
|
const MEMBER_SPAWN_STATUSES_IPC_RETRY_BACKOFF_MS = 5_000;
|
||||||
|
const TEAM_DATA_IPC_WARN_MS = 350;
|
||||||
|
const TEAM_DATA_SET_WARN_MS = 12;
|
||||||
|
const TEAM_DATA_POST_WARN_MS = 24;
|
||||||
|
const TEAM_DATA_LARGE_MESSAGES = 150;
|
||||||
|
const TEAM_DATA_LARGE_TASKS = 80;
|
||||||
|
const TEAM_REFRESH_BURST_WINDOW_MS = 4_000;
|
||||||
|
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<string, Promise<TeamData>>();
|
const inFlightTeamDataRequests = new Map<string, Promise<TeamData>>();
|
||||||
|
const inFlightRefreshTeamDataCalls = new Set<string>();
|
||||||
const pendingFreshTeamDataRefreshes = new Set<string>();
|
const pendingFreshTeamDataRefreshes = new Set<string>();
|
||||||
|
const lastResolvedTeamDataRefreshAtByTeam = new Map<string, number>();
|
||||||
|
let inFlightGlobalTasksRefresh: Promise<void> | null = null;
|
||||||
|
let pendingFreshGlobalTasksRefresh = false;
|
||||||
const memberSpawnStatusesIpcBackoffUntilByTeam = new Map<string, number>();
|
const memberSpawnStatusesIpcBackoffUntilByTeam = new Map<string, number>();
|
||||||
|
const teamRefreshBurstDiagnostics = new Map<
|
||||||
|
string,
|
||||||
|
{ windowStartedAt: number; count: number; lastWarnAt: number }
|
||||||
|
>();
|
||||||
|
const memberSpawnUiEqualLastWarnAtByTeam = new Map<string, number>();
|
||||||
type RefreshTeamDataOptions = {
|
type RefreshTeamDataOptions = {
|
||||||
withDedup?: boolean;
|
withDedup?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function isTeamDataRefreshPending(teamName: string): boolean {
|
||||||
|
return (
|
||||||
|
inFlightTeamDataRequests.has(teamName) ||
|
||||||
|
inFlightRefreshTeamDataCalls.has(teamName) ||
|
||||||
|
pendingFreshTeamDataRefreshes.has(teamName)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLastResolvedTeamDataRefreshAt(teamName: string): number | undefined {
|
||||||
|
return lastResolvedTeamDataRefreshAtByTeam.get(teamName);
|
||||||
|
}
|
||||||
|
|
||||||
function nowIso(): string {
|
function nowIso(): string {
|
||||||
return new Date().toISOString();
|
return new Date().toISOString();
|
||||||
}
|
}
|
||||||
|
|
@ -92,6 +123,245 @@ function fetchTeamDataFresh(teamName: string): Promise<TeamData> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function summarizeTeamDataCounts(data: TeamData | null | undefined): {
|
||||||
|
messages: number;
|
||||||
|
tasks: number;
|
||||||
|
members: number;
|
||||||
|
activeMembers: number;
|
||||||
|
processes: number;
|
||||||
|
} {
|
||||||
|
if (!data) {
|
||||||
|
return { messages: 0, tasks: 0, members: 0, activeMembers: 0, processes: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
messages: data.messages.length,
|
||||||
|
tasks: data.tasks.length,
|
||||||
|
members: data.members.length,
|
||||||
|
activeMembers: data.members.filter((member) => !member.removedAt).length,
|
||||||
|
processes: data.processes.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function estimateTeamPayloadWeight(data: TeamData): {
|
||||||
|
messageTextChars: number;
|
||||||
|
messageAttachments: number;
|
||||||
|
taskComments: number;
|
||||||
|
taskHistoryEvents: number;
|
||||||
|
taskDescriptionChars: number;
|
||||||
|
} {
|
||||||
|
let messageTextChars = 0;
|
||||||
|
let messageAttachments = 0;
|
||||||
|
for (const message of data.messages) {
|
||||||
|
messageTextChars += (message.text?.length ?? 0) + (message.summary?.length ?? 0);
|
||||||
|
messageAttachments += message.attachments?.length ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let taskComments = 0;
|
||||||
|
let taskHistoryEvents = 0;
|
||||||
|
let taskDescriptionChars = 0;
|
||||||
|
for (const task of data.tasks) {
|
||||||
|
taskComments += task.comments?.length ?? 0;
|
||||||
|
taskHistoryEvents += task.historyEvents?.length ?? 0;
|
||||||
|
taskDescriptionChars += task.description?.length ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
messageTextChars,
|
||||||
|
messageAttachments,
|
||||||
|
taskComments,
|
||||||
|
taskHistoryEvents,
|
||||||
|
taskDescriptionChars,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function noteTeamRefreshBurst(teamName: string): number {
|
||||||
|
const now = Date.now();
|
||||||
|
const diagnostic = teamRefreshBurstDiagnostics.get(teamName) ?? {
|
||||||
|
windowStartedAt: now,
|
||||||
|
count: 0,
|
||||||
|
lastWarnAt: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (now - diagnostic.windowStartedAt > TEAM_REFRESH_BURST_WINDOW_MS) {
|
||||||
|
diagnostic.windowStartedAt = now;
|
||||||
|
diagnostic.count = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
diagnostic.count += 1;
|
||||||
|
|
||||||
|
if (
|
||||||
|
diagnostic.count >= TEAM_REFRESH_BURST_WARN_COUNT &&
|
||||||
|
now - diagnostic.lastWarnAt >= TEAM_REFRESH_WARN_THROTTLE_MS
|
||||||
|
) {
|
||||||
|
diagnostic.lastWarnAt = now;
|
||||||
|
logger.warn(
|
||||||
|
`[perf] refreshTeamData burst team=${teamName} count=${diagnostic.count} windowMs=${
|
||||||
|
now - diagnostic.windowStartedAt
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
teamRefreshBurstDiagnostics.set(teamName, diagnostic);
|
||||||
|
return diagnostic.count;
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybeLogTeamDataPerf(params: {
|
||||||
|
phase: 'selectTeam' | 'refreshTeamData';
|
||||||
|
teamName: string;
|
||||||
|
ipcMs: number;
|
||||||
|
setMs: number;
|
||||||
|
postMs: number;
|
||||||
|
totalMs: number;
|
||||||
|
previousData: TeamData | null | undefined;
|
||||||
|
nextData: TeamData;
|
||||||
|
deduped: boolean;
|
||||||
|
reusedInFlightRequest: boolean;
|
||||||
|
burstCount?: number;
|
||||||
|
}): void {
|
||||||
|
const {
|
||||||
|
phase,
|
||||||
|
teamName,
|
||||||
|
ipcMs,
|
||||||
|
setMs,
|
||||||
|
postMs,
|
||||||
|
totalMs,
|
||||||
|
previousData,
|
||||||
|
nextData,
|
||||||
|
deduped,
|
||||||
|
reusedInFlightRequest,
|
||||||
|
burstCount,
|
||||||
|
} = params;
|
||||||
|
|
||||||
|
const nextCounts = summarizeTeamDataCounts(nextData);
|
||||||
|
const previousCounts = summarizeTeamDataCounts(previousData);
|
||||||
|
const largePayload =
|
||||||
|
nextCounts.messages >= TEAM_DATA_LARGE_MESSAGES || nextCounts.tasks >= TEAM_DATA_LARGE_TASKS;
|
||||||
|
const slow =
|
||||||
|
ipcMs >= TEAM_DATA_IPC_WARN_MS ||
|
||||||
|
setMs >= TEAM_DATA_SET_WARN_MS ||
|
||||||
|
postMs >= TEAM_DATA_POST_WARN_MS;
|
||||||
|
|
||||||
|
if (!slow && !largePayload && !reusedInFlightRequest) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payloadWeight = estimateTeamPayloadWeight(nextData);
|
||||||
|
logger.warn(
|
||||||
|
`[perf] ${phase} team=${teamName} ipc=${ipcMs.toFixed(1)}ms set=${setMs.toFixed(
|
||||||
|
1
|
||||||
|
)}ms post=${postMs.toFixed(1)}ms total=${totalMs.toFixed(1)}ms deduped=${deduped} reusedInFlight=${
|
||||||
|
reusedInFlightRequest ? 'yes' : 'no'
|
||||||
|
} burst=${burstCount ?? 1} counts=messages:${previousCounts.messages}->${nextCounts.messages},tasks:${
|
||||||
|
previousCounts.tasks
|
||||||
|
}->${nextCounts.tasks},members:${previousCounts.members}->${nextCounts.members},activeMembers:${
|
||||||
|
previousCounts.activeMembers
|
||||||
|
}->${nextCounts.activeMembers},processes:${previousCounts.processes}->${nextCounts.processes} payload=textChars:${
|
||||||
|
payloadWeight.messageTextChars + payloadWeight.taskDescriptionChars
|
||||||
|
},attachments=${payloadWeight.messageAttachments},taskComments=${
|
||||||
|
payloadWeight.taskComments
|
||||||
|
},historyEvents=${payloadWeight.taskHistoryEvents}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function areLaunchSummaryCountsEqual(
|
||||||
|
left: PersistedTeamLaunchSummary | undefined,
|
||||||
|
right: PersistedTeamLaunchSummary | undefined
|
||||||
|
): boolean {
|
||||||
|
if (left === right) return true;
|
||||||
|
if (!left || !right) return left === right;
|
||||||
|
return (
|
||||||
|
left.confirmedCount === right.confirmedCount &&
|
||||||
|
left.pendingCount === right.pendingCount &&
|
||||||
|
left.failedCount === right.failedCount &&
|
||||||
|
left.runtimeAlivePendingCount === right.runtimeAlivePendingCount
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function areExpectedMembersEqual(
|
||||||
|
left: readonly string[] | undefined,
|
||||||
|
right: readonly string[] | undefined
|
||||||
|
): boolean {
|
||||||
|
if (left === right) return true;
|
||||||
|
if (!left || !right) return left === right;
|
||||||
|
if (left.length !== right.length) return false;
|
||||||
|
for (let index = 0; index < left.length; index += 1) {
|
||||||
|
if (left[index] !== right[index]) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function areMemberSpawnStatusEntriesEqual(
|
||||||
|
left: MemberSpawnStatusEntry | undefined,
|
||||||
|
right: MemberSpawnStatusEntry | undefined
|
||||||
|
): boolean {
|
||||||
|
if (left === right) return true;
|
||||||
|
if (!left || !right) return left === right;
|
||||||
|
// Renderer equality intentionally ignores raw timing fields that do not change
|
||||||
|
// visible member status. This suppresses heartbeat-only churn in TeamDetailView.
|
||||||
|
return (
|
||||||
|
left.status === right.status &&
|
||||||
|
left.launchState === right.launchState &&
|
||||||
|
left.error === right.error &&
|
||||||
|
left.livenessSource === right.livenessSource &&
|
||||||
|
left.runtimeAlive === right.runtimeAlive &&
|
||||||
|
left.bootstrapConfirmed === right.bootstrapConfirmed &&
|
||||||
|
left.hardFailure === right.hardFailure
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function areMemberSpawnStatusesEqual(
|
||||||
|
left: Record<string, MemberSpawnStatusEntry>,
|
||||||
|
right: Record<string, MemberSpawnStatusEntry>
|
||||||
|
): boolean {
|
||||||
|
if (left === right) return true;
|
||||||
|
const leftKeys = Object.keys(left);
|
||||||
|
const rightKeys = Object.keys(right);
|
||||||
|
if (leftKeys.length !== rightKeys.length) return false;
|
||||||
|
for (const key of leftKeys) {
|
||||||
|
if (!(key in right)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!areMemberSpawnStatusEntriesEqual(left[key], right[key])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function areMemberSpawnSnapshotsSemanticallyEqual(
|
||||||
|
left: MemberSpawnStatusesSnapshot | undefined,
|
||||||
|
right: MemberSpawnStatusesSnapshot
|
||||||
|
): boolean {
|
||||||
|
if (!left) return false;
|
||||||
|
return (
|
||||||
|
left.runId === right.runId &&
|
||||||
|
left.teamLaunchState === right.teamLaunchState &&
|
||||||
|
left.launchPhase === right.launchPhase &&
|
||||||
|
left.source === right.source &&
|
||||||
|
areExpectedMembersEqual(left.expectedMembers, right.expectedMembers) &&
|
||||||
|
areLaunchSummaryCountsEqual(left.summary, right.summary) &&
|
||||||
|
areMemberSpawnStatusesEqual(left.statuses, right.statuses)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybeLogMemberSpawnUiEqualSuppressed(
|
||||||
|
teamName: string,
|
||||||
|
runId: string | null | undefined
|
||||||
|
): void {
|
||||||
|
const now = Date.now();
|
||||||
|
const lastWarnAt = memberSpawnUiEqualLastWarnAtByTeam.get(teamName) ?? 0;
|
||||||
|
if (now - lastWarnAt < MEMBER_SPAWN_UI_EQUAL_WARN_THROTTLE_MS) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
memberSpawnUiEqualLastWarnAtByTeam.set(teamName, now);
|
||||||
|
logger.debug(
|
||||||
|
`[perf] member-spawn snapshot suppressed team=${teamName} runId=${runId ?? 'none'} reason=member-spawn-ui-equal`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function compareInboxMessagesByTimestamp(a: InboxMessage, b: InboxMessage): number {
|
function compareInboxMessagesByTimestamp(a: InboxMessage, b: InboxMessage): number {
|
||||||
const aTime = Date.parse(a.timestamp);
|
const aTime = Date.parse(a.timestamp);
|
||||||
const bTime = Date.parse(b.timestamp);
|
const bTime = Date.parse(b.timestamp);
|
||||||
|
|
@ -217,6 +487,7 @@ import type {
|
||||||
KanbanColumnId,
|
KanbanColumnId,
|
||||||
LeadActivityState,
|
LeadActivityState,
|
||||||
LeadContextUsage,
|
LeadContextUsage,
|
||||||
|
PersistedTeamLaunchSummary,
|
||||||
MemberSpawnStatusesSnapshot,
|
MemberSpawnStatusesSnapshot,
|
||||||
MemberSpawnStatusEntry,
|
MemberSpawnStatusEntry,
|
||||||
SendMessageRequest,
|
SendMessageRequest,
|
||||||
|
|
@ -1037,22 +1308,48 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const nextCurrentRuntimeRunIdByTeam =
|
||||||
|
snapshot.runId == null || prev.currentRuntimeRunIdByTeam[teamName] != null
|
||||||
|
? prev.currentRuntimeRunIdByTeam
|
||||||
|
: {
|
||||||
|
...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
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const previousSnapshot = prev.memberSpawnSnapshotsByTeam[teamName];
|
||||||
|
const snapshotChanged = !areMemberSpawnSnapshotsSemanticallyEqual(
|
||||||
|
previousSnapshot,
|
||||||
|
snapshot
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!snapshotChanged) {
|
||||||
|
maybeLogMemberSpawnUiEqualSuppressed(teamName, snapshot.runId);
|
||||||
|
if (
|
||||||
|
nextCurrentRuntimeRunIdByTeam === prev.currentRuntimeRunIdByTeam &&
|
||||||
|
nextIgnoredRuntimeRunIds === prev.ignoredRuntimeRunIds
|
||||||
|
) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentRuntimeRunIdByTeam: nextCurrentRuntimeRunIdByTeam,
|
||||||
|
ignoredRuntimeRunIds: nextIgnoredRuntimeRunIds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
currentRuntimeRunIdByTeam:
|
currentRuntimeRunIdByTeam: nextCurrentRuntimeRunIdByTeam,
|
||||||
snapshot.runId == null
|
ignoredRuntimeRunIds: nextIgnoredRuntimeRunIds,
|
||||||
? prev.currentRuntimeRunIdByTeam
|
|
||||||
: {
|
|
||||||
...prev.currentRuntimeRunIdByTeam,
|
|
||||||
[teamName]: prev.currentRuntimeRunIdByTeam[teamName] ?? snapshot.runId,
|
|
||||||
},
|
|
||||||
ignoredRuntimeRunIds:
|
|
||||||
snapshot.runId == null
|
|
||||||
? prev.ignoredRuntimeRunIds
|
|
||||||
: Object.fromEntries(
|
|
||||||
Object.entries(prev.ignoredRuntimeRunIds).filter(
|
|
||||||
([, ignoredTeamName]) => ignoredTeamName !== teamName
|
|
||||||
)
|
|
||||||
),
|
|
||||||
memberSpawnStatusesByTeam: {
|
memberSpawnStatusesByTeam: {
|
||||||
...prev.memberSpawnStatusesByTeam,
|
...prev.memberSpawnStatusesByTeam,
|
||||||
[teamName]: snapshot.statuses,
|
[teamName]: snapshot.statuses,
|
||||||
|
|
@ -1194,91 +1491,110 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
},
|
},
|
||||||
|
|
||||||
fetchAllTasks: async () => {
|
fetchAllTasks: async () => {
|
||||||
// Guard: prevent concurrent fetches (component mount + centralized init chain)
|
if (inFlightGlobalTasksRefresh) {
|
||||||
if (get().globalTasksLoading) return;
|
pendingFreshGlobalTasksRefresh = true;
|
||||||
// Show skeleton only on the very first fetch — not on subsequent refreshes
|
await inFlightGlobalTasksRefresh;
|
||||||
// even when the task list is empty (avoids flickering skeleton on every watcher event).
|
return;
|
||||||
const isInitialLoad = !get().globalTasksInitialized;
|
|
||||||
if (isInitialLoad) {
|
|
||||||
set({ globalTasksLoading: true, globalTasksError: null });
|
|
||||||
}
|
}
|
||||||
const oldTasks = get().globalTasks;
|
|
||||||
const wasFirst = isFirstFetchAllTasks;
|
|
||||||
isFirstFetchAllTasks = false;
|
|
||||||
try {
|
|
||||||
const tasks = await withTimeout(
|
|
||||||
unwrapIpc('team:getAllTasks', () => api.teams.getAllTasks()),
|
|
||||||
TEAM_FETCH_TIMEOUT_MS,
|
|
||||||
'fetchAllTasks'
|
|
||||||
);
|
|
||||||
if (!wasFirst) {
|
|
||||||
const notifyOnClarifications =
|
|
||||||
get().appConfig?.notifications?.notifyOnClarifications ?? true;
|
|
||||||
detectClarificationNotifications(oldTasks, tasks, notifyOnClarifications);
|
|
||||||
detectStatusChangeNotifications(oldTasks, tasks, get().appConfig, get().teamByName);
|
|
||||||
const notifyOnTaskComments = get().appConfig?.notifications?.notifyOnTaskComments ?? true;
|
|
||||||
detectTaskCommentNotifications(oldTasks, tasks, notifyOnTaskComments);
|
|
||||||
const notifyOnTaskCreated = get().appConfig?.notifications?.notifyOnTaskCreated ?? true;
|
|
||||||
detectTaskCreatedNotifications(oldTasks, tasks, notifyOnTaskCreated);
|
|
||||||
const notifyOnAllCompleted =
|
|
||||||
get().appConfig?.notifications?.notifyOnAllTasksCompleted ?? true;
|
|
||||||
detectAllTasksCompletedNotification(oldTasks, tasks, notifyOnAllCompleted);
|
|
||||||
} else {
|
|
||||||
// Initial load — seed the Sets to prevent false notifications on next update
|
|
||||||
for (const task of tasks) {
|
|
||||||
if (task.needsClarification === 'user') {
|
|
||||||
notifiedClarificationTaskKeys.add(`${task.teamName}:${task.id}`);
|
|
||||||
}
|
|
||||||
notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:${task.status}`);
|
|
||||||
if (task.reviewState === 'needsFix') {
|
|
||||||
notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:needsFix`);
|
|
||||||
}
|
|
||||||
if (getTaskKanbanColumn(task) === 'approved') {
|
|
||||||
notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:approved`);
|
|
||||||
}
|
|
||||||
if (getTaskKanbanColumn(task) === 'review') {
|
|
||||||
notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:review`);
|
|
||||||
}
|
|
||||||
// Seed comment keys to prevent false notifications
|
|
||||||
for (const comment of task.comments ?? []) {
|
|
||||||
notifiedCommentKeys.add(`${task.teamName}:${task.id}:${comment.id}`);
|
|
||||||
}
|
|
||||||
// Seed created task keys to prevent false notifications
|
|
||||||
notifiedCreatedTaskKeys.add(`${task.teamName}:${task.id}`);
|
|
||||||
}
|
|
||||||
// Seed all-completed teams
|
|
||||||
const teamTasksMap = new Map<string, GlobalTask[]>();
|
|
||||||
for (const task of tasks) {
|
|
||||||
const list = teamTasksMap.get(task.teamName) ?? [];
|
|
||||||
list.push(task);
|
|
||||||
teamTasksMap.set(task.teamName, list);
|
|
||||||
}
|
|
||||||
for (const [teamName, teamTasks] of teamTasksMap) {
|
|
||||||
if (teamTasks.every((t) => t.status === 'completed' || t.status === 'deleted')) {
|
|
||||||
notifiedAllCompletedTeams.add(teamName);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
set({
|
const runRefresh = async (): Promise<void> => {
|
||||||
globalTasks: tasks,
|
do {
|
||||||
globalTasksLoading: false,
|
pendingFreshGlobalTasksRefresh = false;
|
||||||
globalTasksInitialized: true,
|
|
||||||
globalTasksError: null,
|
// Show skeleton only on the very first fetch — not on subsequent refreshes
|
||||||
});
|
// even when the task list is empty (avoids flickering skeleton on every watcher event).
|
||||||
} catch (error) {
|
const isInitialLoad = !get().globalTasksInitialized;
|
||||||
set({
|
if (isInitialLoad) {
|
||||||
globalTasksLoading: false,
|
set({ globalTasksLoading: true, globalTasksError: null });
|
||||||
globalTasksInitialized: true,
|
}
|
||||||
globalTasksError: isInitialLoad
|
const oldTasks = get().globalTasks;
|
||||||
? error instanceof IpcError
|
const wasFirst = isFirstFetchAllTasks;
|
||||||
? error.message
|
isFirstFetchAllTasks = false;
|
||||||
: error instanceof Error
|
try {
|
||||||
? error.message
|
const tasks = await withTimeout(
|
||||||
: 'Failed to fetch tasks'
|
unwrapIpc('team:getAllTasks', () => api.teams.getAllTasks()),
|
||||||
: null,
|
TEAM_FETCH_TIMEOUT_MS,
|
||||||
});
|
'fetchAllTasks'
|
||||||
}
|
);
|
||||||
|
if (!wasFirst) {
|
||||||
|
const notifyOnClarifications =
|
||||||
|
get().appConfig?.notifications?.notifyOnClarifications ?? true;
|
||||||
|
detectClarificationNotifications(oldTasks, tasks, notifyOnClarifications);
|
||||||
|
detectStatusChangeNotifications(oldTasks, tasks, get().appConfig, get().teamByName);
|
||||||
|
const notifyOnTaskComments =
|
||||||
|
get().appConfig?.notifications?.notifyOnTaskComments ?? true;
|
||||||
|
detectTaskCommentNotifications(oldTasks, tasks, notifyOnTaskComments);
|
||||||
|
const notifyOnTaskCreated = get().appConfig?.notifications?.notifyOnTaskCreated ?? true;
|
||||||
|
detectTaskCreatedNotifications(oldTasks, tasks, notifyOnTaskCreated);
|
||||||
|
const notifyOnAllCompleted =
|
||||||
|
get().appConfig?.notifications?.notifyOnAllTasksCompleted ?? true;
|
||||||
|
detectAllTasksCompletedNotification(oldTasks, tasks, notifyOnAllCompleted);
|
||||||
|
} else {
|
||||||
|
// Initial load — seed the Sets to prevent false notifications on next update
|
||||||
|
for (const task of tasks) {
|
||||||
|
if (task.needsClarification === 'user') {
|
||||||
|
notifiedClarificationTaskKeys.add(`${task.teamName}:${task.id}`);
|
||||||
|
}
|
||||||
|
notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:${task.status}`);
|
||||||
|
if (task.reviewState === 'needsFix') {
|
||||||
|
notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:needsFix`);
|
||||||
|
}
|
||||||
|
if (getTaskKanbanColumn(task) === 'approved') {
|
||||||
|
notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:approved`);
|
||||||
|
}
|
||||||
|
if (getTaskKanbanColumn(task) === 'review') {
|
||||||
|
notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:review`);
|
||||||
|
}
|
||||||
|
// Seed comment keys to prevent false notifications
|
||||||
|
for (const comment of task.comments ?? []) {
|
||||||
|
notifiedCommentKeys.add(`${task.teamName}:${task.id}:${comment.id}`);
|
||||||
|
}
|
||||||
|
// Seed created task keys to prevent false notifications
|
||||||
|
notifiedCreatedTaskKeys.add(`${task.teamName}:${task.id}`);
|
||||||
|
}
|
||||||
|
// Seed all-completed teams
|
||||||
|
const teamTasksMap = new Map<string, GlobalTask[]>();
|
||||||
|
for (const task of tasks) {
|
||||||
|
const list = teamTasksMap.get(task.teamName) ?? [];
|
||||||
|
list.push(task);
|
||||||
|
teamTasksMap.set(task.teamName, list);
|
||||||
|
}
|
||||||
|
for (const [teamName, teamTasks] of teamTasksMap) {
|
||||||
|
if (teamTasks.every((t) => t.status === 'completed' || t.status === 'deleted')) {
|
||||||
|
notifiedAllCompletedTeams.add(teamName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
set({
|
||||||
|
globalTasks: tasks,
|
||||||
|
globalTasksLoading: false,
|
||||||
|
globalTasksInitialized: true,
|
||||||
|
globalTasksError: null,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
set({
|
||||||
|
globalTasksLoading: false,
|
||||||
|
globalTasksInitialized: true,
|
||||||
|
globalTasksError: isInitialLoad
|
||||||
|
? error instanceof IpcError
|
||||||
|
? error.message
|
||||||
|
: error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: 'Failed to fetch tasks'
|
||||||
|
: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} while (pendingFreshGlobalTasksRefresh);
|
||||||
|
};
|
||||||
|
|
||||||
|
const request = runRefresh().finally(() => {
|
||||||
|
if (inFlightGlobalTasksRefresh === request) {
|
||||||
|
inFlightGlobalTasksRefresh = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
inFlightGlobalTasksRefresh = request;
|
||||||
|
await request;
|
||||||
},
|
},
|
||||||
|
|
||||||
openTeamsTab: () => {
|
openTeamsTab: () => {
|
||||||
|
|
@ -1426,6 +1742,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
},
|
},
|
||||||
|
|
||||||
selectTeam: async (teamName: string, opts) => {
|
selectTeam: async (teamName: string, opts) => {
|
||||||
|
const startedAt = performance.now();
|
||||||
const allowReloadWhileProvisioning = opts?.allowReloadWhileProvisioning === true;
|
const allowReloadWhileProvisioning = opts?.allowReloadWhileProvisioning === true;
|
||||||
// Guard: prevent duplicate in-flight fetches for the same team.
|
// Guard: prevent duplicate in-flight fetches for the same team.
|
||||||
// GlobalTaskDetailDialog + tab navigation can call selectTeam() in quick succession.
|
// GlobalTaskDetailDialog + tab navigation can call selectTeam() in quick succession.
|
||||||
|
|
@ -1455,6 +1772,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await fetchTeamDataDeduped(teamName);
|
const data = await fetchTeamDataDeduped(teamName);
|
||||||
|
const ipcMs = performance.now() - startedAt;
|
||||||
// Stale check: user may have switched to another team during the async call
|
// Stale check: user may have switched to another team during the async call
|
||||||
if (get().selectedTeamName !== teamName || get().selectedTeamLoadNonce !== requestNonce) {
|
if (get().selectedTeamName !== teamName || get().selectedTeamLoadNonce !== requestNonce) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -1479,6 +1797,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
set({ teamByName: { ...prevByName, [teamName]: patched } });
|
set({ teamByName: { ...prevByName, [teamName]: patched } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const setStartedAt = performance.now();
|
||||||
set({
|
set({
|
||||||
selectedTeamName: teamName,
|
selectedTeamName: teamName,
|
||||||
selectedTeamData: previousData
|
selectedTeamData: previousData
|
||||||
|
|
@ -1490,6 +1809,9 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
selectedTeamLoading: false,
|
selectedTeamLoading: false,
|
||||||
selectedTeamError: null,
|
selectedTeamError: null,
|
||||||
});
|
});
|
||||||
|
lastResolvedTeamDataRefreshAtByTeam.set(teamName, Date.now());
|
||||||
|
const setMs = performance.now() - setStartedAt;
|
||||||
|
const postStartedAt = performance.now();
|
||||||
const invalidationState = previousData
|
const invalidationState = previousData
|
||||||
? collectTaskChangeInvalidationState(teamName, previousData.tasks, data.tasks)
|
? collectTaskChangeInvalidationState(teamName, previousData.tasks, data.tasks)
|
||||||
: { cacheKeys: [], taskIds: [] };
|
: { cacheKeys: [], taskIds: [] };
|
||||||
|
|
@ -1499,6 +1821,19 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
if (invalidationState.taskIds.length > 0) {
|
if (invalidationState.taskIds.length > 0) {
|
||||||
await api.review.invalidateTaskChangeSummaries(teamName, invalidationState.taskIds);
|
await api.review.invalidateTaskChangeSummaries(teamName, invalidationState.taskIds);
|
||||||
}
|
}
|
||||||
|
const postMs = performance.now() - postStartedAt;
|
||||||
|
maybeLogTeamDataPerf({
|
||||||
|
phase: 'selectTeam',
|
||||||
|
teamName,
|
||||||
|
ipcMs,
|
||||||
|
setMs,
|
||||||
|
postMs,
|
||||||
|
totalMs: performance.now() - startedAt,
|
||||||
|
previousData,
|
||||||
|
nextData: data,
|
||||||
|
deduped: true,
|
||||||
|
reusedInFlightRequest: false,
|
||||||
|
});
|
||||||
// Sync tab label with the team's display name from config
|
// Sync tab label with the team's display name from config
|
||||||
const displayName = data.config.name || teamName;
|
const displayName = data.config.name || teamName;
|
||||||
const allTabs = get().getAllPaneTabs();
|
const allTabs = get().getAllPaneTabs();
|
||||||
|
|
@ -1589,26 +1924,34 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
},
|
},
|
||||||
|
|
||||||
refreshTeamData: async (teamName: string, opts?: RefreshTeamDataOptions) => {
|
refreshTeamData: async (teamName: string, opts?: RefreshTeamDataOptions) => {
|
||||||
|
const startedAt = performance.now();
|
||||||
const state = get();
|
const state = get();
|
||||||
if (state.selectedTeamName !== teamName) {
|
if (state.selectedTeamName !== teamName) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
inFlightRefreshTeamDataCalls.add(teamName);
|
||||||
// Silent refresh — update data without showing loading skeleton.
|
// Silent refresh — update data without showing loading skeleton.
|
||||||
// Only selectTeam() sets loading: true (for initial load).
|
// Only selectTeam() sets loading: true (for initial load).
|
||||||
const reusedInFlightRequest =
|
const reusedInFlightRequest =
|
||||||
opts?.withDedup === true && inFlightTeamDataRequests.has(teamName);
|
opts?.withDedup === true && inFlightTeamDataRequests.has(teamName);
|
||||||
|
const burstCount = noteTeamRefreshBurst(teamName);
|
||||||
if (reusedInFlightRequest) {
|
if (reusedInFlightRequest) {
|
||||||
pendingFreshTeamDataRefreshes.add(teamName);
|
pendingFreshTeamDataRefreshes.add(teamName);
|
||||||
|
logger.warn(
|
||||||
|
`[perf] refreshTeamData queued-fresh team=${teamName} burst=${burstCount} reason=inFlightDedup`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const previousData = get().selectedTeamData;
|
const previousData = get().selectedTeamData;
|
||||||
const data = opts?.withDedup
|
const data = opts?.withDedup
|
||||||
? await fetchTeamDataDeduped(teamName)
|
? await fetchTeamDataDeduped(teamName)
|
||||||
: await fetchTeamDataFresh(teamName);
|
: await fetchTeamDataFresh(teamName);
|
||||||
|
const ipcMs = performance.now() - startedAt;
|
||||||
// Re-check after async: the user might have navigated away.
|
// Re-check after async: the user might have navigated away.
|
||||||
if (get().selectedTeamName !== teamName) {
|
if (get().selectedTeamName !== teamName) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const setStartedAt = performance.now();
|
||||||
set({
|
set({
|
||||||
selectedTeamData: previousData
|
selectedTeamData: previousData
|
||||||
? {
|
? {
|
||||||
|
|
@ -1618,6 +1961,9 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
: data,
|
: data,
|
||||||
selectedTeamError: null,
|
selectedTeamError: null,
|
||||||
});
|
});
|
||||||
|
lastResolvedTeamDataRefreshAtByTeam.set(teamName, Date.now());
|
||||||
|
const setMs = performance.now() - setStartedAt;
|
||||||
|
const postStartedAt = performance.now();
|
||||||
const invalidationState = previousData
|
const invalidationState = previousData
|
||||||
? collectTaskChangeInvalidationState(teamName, previousData.tasks, data.tasks)
|
? collectTaskChangeInvalidationState(teamName, previousData.tasks, data.tasks)
|
||||||
: { cacheKeys: [], taskIds: [] };
|
: { cacheKeys: [], taskIds: [] };
|
||||||
|
|
@ -1627,6 +1973,20 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
if (invalidationState.taskIds.length > 0) {
|
if (invalidationState.taskIds.length > 0) {
|
||||||
await api.review.invalidateTaskChangeSummaries(teamName, invalidationState.taskIds);
|
await api.review.invalidateTaskChangeSummaries(teamName, invalidationState.taskIds);
|
||||||
}
|
}
|
||||||
|
const postMs = performance.now() - postStartedAt;
|
||||||
|
maybeLogTeamDataPerf({
|
||||||
|
phase: 'refreshTeamData',
|
||||||
|
teamName,
|
||||||
|
ipcMs,
|
||||||
|
setMs,
|
||||||
|
postMs,
|
||||||
|
totalMs: performance.now() - startedAt,
|
||||||
|
previousData,
|
||||||
|
nextData: data,
|
||||||
|
deduped: opts?.withDedup === true,
|
||||||
|
reusedInFlightRequest,
|
||||||
|
burstCount,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (get().selectedTeamName !== teamName) {
|
if (get().selectedTeamName !== teamName) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -1666,6 +2026,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
}
|
}
|
||||||
set({ selectedTeamError: msg });
|
set({ selectedTeamError: msg });
|
||||||
} finally {
|
} finally {
|
||||||
|
inFlightRefreshTeamDataCalls.delete(teamName);
|
||||||
if (reusedInFlightRequest && pendingFreshTeamDataRefreshes.delete(teamName)) {
|
if (reusedInFlightRequest && pendingFreshTeamDataRefreshes.delete(teamName)) {
|
||||||
void get().refreshTeamData(teamName);
|
void get().refreshTeamData(teamName);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,7 @@ describe('team change throttling', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup?.();
|
cleanup?.();
|
||||||
cleanup = null;
|
cleanup = null;
|
||||||
|
vi.mocked(console.warn).mockClear();
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -125,7 +126,7 @@ describe('team change throttling', () => {
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(1);
|
await vi.advanceTimersByTimeAsync(1);
|
||||||
expect(refreshTeamDataSpy).toHaveBeenCalledTimes(1);
|
expect(refreshTeamDataSpy).toHaveBeenCalledTimes(1);
|
||||||
expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team');
|
expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team', { withDedup: true });
|
||||||
|
|
||||||
// List refresh fires at 2000ms
|
// List refresh fires at 2000ms
|
||||||
expect(fetchTeamsSpy).not.toHaveBeenCalled();
|
expect(fetchTeamsSpy).not.toHaveBeenCalled();
|
||||||
|
|
@ -161,7 +162,7 @@ describe('team change throttling', () => {
|
||||||
|
|
||||||
// Should trigger refreshTeamData at 800ms
|
// Should trigger refreshTeamData at 800ms
|
||||||
expect(refreshTeamDataSpy).toHaveBeenCalledTimes(1);
|
expect(refreshTeamDataSpy).toHaveBeenCalledTimes(1);
|
||||||
expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team');
|
expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team', { withDedup: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('lead-message does not call fetchAllTasks', async () => {
|
it('lead-message does not call fetchAllTasks', async () => {
|
||||||
|
|
@ -303,8 +304,8 @@ describe('team change throttling', () => {
|
||||||
|
|
||||||
// Both teams should get exactly 1 refresh each
|
// Both teams should get exactly 1 refresh each
|
||||||
expect(refreshTeamDataSpy).toHaveBeenCalledTimes(2);
|
expect(refreshTeamDataSpy).toHaveBeenCalledTimes(2);
|
||||||
expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team');
|
expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team', { withDedup: true });
|
||||||
expect(refreshTeamDataSpy).toHaveBeenCalledWith('other-team');
|
expect(refreshTeamDataSpy).toHaveBeenCalledWith('other-team', { withDedup: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps auto change presence tracking disabled even after selected team data is hydrated', async () => {
|
it('keeps auto change presence tracking disabled even after selected team data is hydrated', async () => {
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,44 @@ function createSliceStore() {
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createMemberSpawnStatus(overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
status: 'online',
|
||||||
|
launchState: 'confirmed_alive',
|
||||||
|
error: undefined,
|
||||||
|
updatedAt: '2026-03-12T10:00:00.000Z',
|
||||||
|
runtimeAlive: true,
|
||||||
|
livenessSource: 'heartbeat',
|
||||||
|
bootstrapConfirmed: true,
|
||||||
|
hardFailure: false,
|
||||||
|
firstSpawnAcceptedAt: '2026-03-12T09:59:30.000Z',
|
||||||
|
lastHeartbeatAt: '2026-03-12T10:00:00.000Z',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMemberSpawnSnapshot(overrides: Record<string, unknown> = {}) {
|
||||||
|
const typedOverrides = overrides as {
|
||||||
|
statuses?: Record<string, ReturnType<typeof createMemberSpawnStatus>>;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
runId: 'runtime-run',
|
||||||
|
teamLaunchState: 'clean_success',
|
||||||
|
launchPhase: 'finished',
|
||||||
|
expectedMembers: ['alice'],
|
||||||
|
updatedAt: '2026-03-12T10:00:00.000Z',
|
||||||
|
summary: {
|
||||||
|
confirmedCount: 1,
|
||||||
|
pendingCount: 0,
|
||||||
|
failedCount: 0,
|
||||||
|
runtimeAlivePendingCount: 0,
|
||||||
|
},
|
||||||
|
source: 'merged',
|
||||||
|
statuses: typedOverrides.statuses ?? { alice: createMemberSpawnStatus() },
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe('teamSlice actions', () => {
|
describe('teamSlice actions', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
|
@ -88,6 +126,7 @@ describe('teamSlice actions', () => {
|
||||||
members: [],
|
members: [],
|
||||||
messages: [],
|
messages: [],
|
||||||
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
||||||
|
processes: [],
|
||||||
});
|
});
|
||||||
hoisted.sendMessage.mockResolvedValue({ deliveredToInbox: true, messageId: 'm1' });
|
hoisted.sendMessage.mockResolvedValue({ deliveredToInbox: true, messageId: 'm1' });
|
||||||
hoisted.requestReview.mockResolvedValue(undefined);
|
hoisted.requestReview.mockResolvedValue(undefined);
|
||||||
|
|
@ -336,6 +375,7 @@ describe('teamSlice actions', () => {
|
||||||
members: [],
|
members: [],
|
||||||
messages: [],
|
messages: [],
|
||||||
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
||||||
|
processes: [],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -385,6 +425,7 @@ describe('teamSlice actions', () => {
|
||||||
members: [],
|
members: [],
|
||||||
messages: [],
|
messages: [],
|
||||||
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
||||||
|
processes: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
await store.getState().refreshTeamData('my-team');
|
await store.getState().refreshTeamData('my-team');
|
||||||
|
|
@ -419,6 +460,7 @@ describe('teamSlice actions', () => {
|
||||||
members: [],
|
members: [],
|
||||||
messages: [],
|
messages: [],
|
||||||
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
||||||
|
processes: [],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -443,6 +485,7 @@ describe('teamSlice actions', () => {
|
||||||
members: [],
|
members: [],
|
||||||
messages: [{ from: 'team-lead', text: 'Ping', timestamp: '2026-03-01T10:10:00.000Z' }],
|
messages: [{ from: 'team-lead', text: 'Ping', timestamp: '2026-03-01T10:10:00.000Z' }],
|
||||||
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
||||||
|
processes: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
await store.getState().refreshTeamData('my-team');
|
await store.getState().refreshTeamData('my-team');
|
||||||
|
|
@ -628,6 +671,295 @@ describe('teamSlice actions', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('suppresses renderer rewrites when only lastHeartbeatAt changes', async () => {
|
||||||
|
const store = createSliceStore();
|
||||||
|
const previousSnapshot = createMemberSpawnSnapshot();
|
||||||
|
const previousStatuses = previousSnapshot.statuses;
|
||||||
|
|
||||||
|
store.setState({
|
||||||
|
currentRuntimeRunIdByTeam: {
|
||||||
|
'my-team': 'runtime-run',
|
||||||
|
},
|
||||||
|
memberSpawnStatusesByTeam: {
|
||||||
|
'my-team': previousStatuses,
|
||||||
|
},
|
||||||
|
memberSpawnSnapshotsByTeam: {
|
||||||
|
'my-team': previousSnapshot,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
hoisted.getMemberSpawnStatuses.mockResolvedValue(
|
||||||
|
createMemberSpawnSnapshot({
|
||||||
|
statuses: {
|
||||||
|
alice: createMemberSpawnStatus({
|
||||||
|
lastHeartbeatAt: '2026-03-12T10:00:09.000Z',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await store.getState().fetchMemberSpawnStatuses('my-team');
|
||||||
|
|
||||||
|
expect(store.getState().memberSpawnStatusesByTeam['my-team']).toBe(previousStatuses);
|
||||||
|
expect(store.getState().memberSpawnSnapshotsByTeam['my-team']).toBe(previousSnapshot);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('suppresses renderer rewrites when only firstSpawnAcceptedAt changes', async () => {
|
||||||
|
const store = createSliceStore();
|
||||||
|
const previousSnapshot = createMemberSpawnSnapshot();
|
||||||
|
const previousStatuses = previousSnapshot.statuses;
|
||||||
|
|
||||||
|
store.setState({
|
||||||
|
currentRuntimeRunIdByTeam: {
|
||||||
|
'my-team': 'runtime-run',
|
||||||
|
},
|
||||||
|
memberSpawnStatusesByTeam: {
|
||||||
|
'my-team': previousStatuses,
|
||||||
|
},
|
||||||
|
memberSpawnSnapshotsByTeam: {
|
||||||
|
'my-team': previousSnapshot,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
hoisted.getMemberSpawnStatuses.mockResolvedValue(
|
||||||
|
createMemberSpawnSnapshot({
|
||||||
|
statuses: {
|
||||||
|
alice: createMemberSpawnStatus({
|
||||||
|
firstSpawnAcceptedAt: '2026-03-12T09:59:35.000Z',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await store.getState().fetchMemberSpawnStatuses('my-team');
|
||||||
|
|
||||||
|
expect(store.getState().memberSpawnStatusesByTeam['my-team']).toBe(previousStatuses);
|
||||||
|
expect(store.getState().memberSpawnSnapshotsByTeam['my-team']).toBe(previousSnapshot);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('suppresses renderer rewrites when only updatedAt changes', async () => {
|
||||||
|
const store = createSliceStore();
|
||||||
|
const previousSnapshot = createMemberSpawnSnapshot();
|
||||||
|
const previousStatuses = previousSnapshot.statuses;
|
||||||
|
|
||||||
|
store.setState({
|
||||||
|
currentRuntimeRunIdByTeam: {
|
||||||
|
'my-team': 'runtime-run',
|
||||||
|
},
|
||||||
|
memberSpawnStatusesByTeam: {
|
||||||
|
'my-team': previousStatuses,
|
||||||
|
},
|
||||||
|
memberSpawnSnapshotsByTeam: {
|
||||||
|
'my-team': previousSnapshot,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
hoisted.getMemberSpawnStatuses.mockResolvedValue(
|
||||||
|
createMemberSpawnSnapshot({
|
||||||
|
updatedAt: '2026-03-12T10:00:11.000Z',
|
||||||
|
statuses: {
|
||||||
|
alice: createMemberSpawnStatus({
|
||||||
|
updatedAt: '2026-03-12T10:00:11.000Z',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await store.getState().fetchMemberSpawnStatuses('my-team');
|
||||||
|
|
||||||
|
expect(store.getState().memberSpawnStatusesByTeam['my-team']).toBe(previousStatuses);
|
||||||
|
expect(store.getState().memberSpawnSnapshotsByTeam['my-team']).toBe(previousSnapshot);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rewrites renderer state when runtimeAlive changes', async () => {
|
||||||
|
const store = createSliceStore();
|
||||||
|
const previousSnapshot = createMemberSpawnSnapshot({
|
||||||
|
statuses: {
|
||||||
|
alice: createMemberSpawnStatus({
|
||||||
|
launchState: 'runtime_pending_bootstrap',
|
||||||
|
livenessSource: 'process',
|
||||||
|
bootstrapConfirmed: false,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
teamLaunchState: 'partial_pending',
|
||||||
|
summary: {
|
||||||
|
confirmedCount: 0,
|
||||||
|
pendingCount: 1,
|
||||||
|
failedCount: 0,
|
||||||
|
runtimeAlivePendingCount: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const previousStatuses = previousSnapshot.statuses;
|
||||||
|
|
||||||
|
store.setState({
|
||||||
|
currentRuntimeRunIdByTeam: {
|
||||||
|
'my-team': 'runtime-run',
|
||||||
|
},
|
||||||
|
memberSpawnStatusesByTeam: {
|
||||||
|
'my-team': previousStatuses,
|
||||||
|
},
|
||||||
|
memberSpawnSnapshotsByTeam: {
|
||||||
|
'my-team': previousSnapshot,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const nextSnapshot = createMemberSpawnSnapshot();
|
||||||
|
hoisted.getMemberSpawnStatuses.mockResolvedValue(nextSnapshot);
|
||||||
|
|
||||||
|
await store.getState().fetchMemberSpawnStatuses('my-team');
|
||||||
|
|
||||||
|
expect(store.getState().memberSpawnStatusesByTeam['my-team']).not.toBe(previousStatuses);
|
||||||
|
expect(store.getState().memberSpawnStatusesByTeam['my-team']).toEqual(nextSnapshot.statuses);
|
||||||
|
expect(store.getState().memberSpawnSnapshotsByTeam['my-team']).toEqual(nextSnapshot);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rewrites renderer state when error semantics change', async () => {
|
||||||
|
const store = createSliceStore();
|
||||||
|
const previousSnapshot = createMemberSpawnSnapshot({
|
||||||
|
statuses: {
|
||||||
|
alice: createMemberSpawnStatus({
|
||||||
|
status: 'waiting',
|
||||||
|
launchState: 'runtime_pending_bootstrap',
|
||||||
|
runtimeAlive: false,
|
||||||
|
livenessSource: undefined,
|
||||||
|
bootstrapConfirmed: false,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
teamLaunchState: 'partial_pending',
|
||||||
|
summary: {
|
||||||
|
confirmedCount: 0,
|
||||||
|
pendingCount: 1,
|
||||||
|
failedCount: 0,
|
||||||
|
runtimeAlivePendingCount: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const previousStatuses = previousSnapshot.statuses;
|
||||||
|
|
||||||
|
store.setState({
|
||||||
|
currentRuntimeRunIdByTeam: {
|
||||||
|
'my-team': 'runtime-run',
|
||||||
|
},
|
||||||
|
memberSpawnStatusesByTeam: {
|
||||||
|
'my-team': previousStatuses,
|
||||||
|
},
|
||||||
|
memberSpawnSnapshotsByTeam: {
|
||||||
|
'my-team': previousSnapshot,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const nextSnapshot = createMemberSpawnSnapshot({
|
||||||
|
teamLaunchState: 'partial_failure',
|
||||||
|
summary: {
|
||||||
|
confirmedCount: 0,
|
||||||
|
pendingCount: 0,
|
||||||
|
failedCount: 1,
|
||||||
|
runtimeAlivePendingCount: 0,
|
||||||
|
},
|
||||||
|
statuses: {
|
||||||
|
alice: createMemberSpawnStatus({
|
||||||
|
status: 'error',
|
||||||
|
launchState: 'failed_to_start',
|
||||||
|
error: 'bootstrap failed',
|
||||||
|
runtimeAlive: false,
|
||||||
|
livenessSource: undefined,
|
||||||
|
bootstrapConfirmed: false,
|
||||||
|
hardFailure: true,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
hoisted.getMemberSpawnStatuses.mockResolvedValue(nextSnapshot);
|
||||||
|
|
||||||
|
await store.getState().fetchMemberSpawnStatuses('my-team');
|
||||||
|
|
||||||
|
expect(store.getState().memberSpawnStatusesByTeam['my-team']).not.toBe(previousStatuses);
|
||||||
|
expect(store.getState().memberSpawnStatusesByTeam['my-team']).toEqual(nextSnapshot.statuses);
|
||||||
|
expect(store.getState().memberSpawnSnapshotsByTeam['my-team']).toEqual(nextSnapshot);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rewrites renderer state when top-level launch summary changes', async () => {
|
||||||
|
const store = createSliceStore();
|
||||||
|
const previousSnapshot = createMemberSpawnSnapshot({
|
||||||
|
teamLaunchState: 'partial_pending',
|
||||||
|
summary: {
|
||||||
|
confirmedCount: 0,
|
||||||
|
pendingCount: 1,
|
||||||
|
failedCount: 0,
|
||||||
|
runtimeAlivePendingCount: 1,
|
||||||
|
},
|
||||||
|
statuses: {
|
||||||
|
alice: createMemberSpawnStatus({
|
||||||
|
launchState: 'runtime_pending_bootstrap',
|
||||||
|
livenessSource: 'process',
|
||||||
|
bootstrapConfirmed: false,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const previousStatuses = previousSnapshot.statuses;
|
||||||
|
|
||||||
|
store.setState({
|
||||||
|
currentRuntimeRunIdByTeam: {
|
||||||
|
'my-team': 'runtime-run',
|
||||||
|
},
|
||||||
|
memberSpawnStatusesByTeam: {
|
||||||
|
'my-team': previousStatuses,
|
||||||
|
},
|
||||||
|
memberSpawnSnapshotsByTeam: {
|
||||||
|
'my-team': previousSnapshot,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const nextSnapshot = createMemberSpawnSnapshot({
|
||||||
|
teamLaunchState: 'clean_success',
|
||||||
|
summary: {
|
||||||
|
confirmedCount: 1,
|
||||||
|
pendingCount: 0,
|
||||||
|
failedCount: 0,
|
||||||
|
runtimeAlivePendingCount: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
hoisted.getMemberSpawnStatuses.mockResolvedValue(nextSnapshot);
|
||||||
|
|
||||||
|
await store.getState().fetchMemberSpawnStatuses('my-team');
|
||||||
|
|
||||||
|
expect(store.getState().memberSpawnStatusesByTeam['my-team']).not.toBe(previousStatuses);
|
||||||
|
expect(store.getState().memberSpawnSnapshotsByTeam['my-team']).toEqual(nextSnapshot);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves spawn snapshot references while still updating bookkeeping on suppressed snapshots', async () => {
|
||||||
|
const store = createSliceStore();
|
||||||
|
const previousSnapshot = createMemberSpawnSnapshot();
|
||||||
|
const previousStatuses = previousSnapshot.statuses;
|
||||||
|
|
||||||
|
store.setState({
|
||||||
|
ignoredRuntimeRunIds: {
|
||||||
|
'runtime-old': 'my-team',
|
||||||
|
},
|
||||||
|
memberSpawnStatusesByTeam: {
|
||||||
|
'my-team': previousStatuses,
|
||||||
|
},
|
||||||
|
memberSpawnSnapshotsByTeam: {
|
||||||
|
'my-team': previousSnapshot,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
hoisted.getMemberSpawnStatuses.mockResolvedValue(
|
||||||
|
createMemberSpawnSnapshot({
|
||||||
|
statuses: {
|
||||||
|
alice: createMemberSpawnStatus({
|
||||||
|
lastHeartbeatAt: '2026-03-12T10:00:09.000Z',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
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().memberSpawnStatusesByTeam['my-team']).toBe(previousStatuses);
|
||||||
|
expect(store.getState().memberSpawnSnapshotsByTeam['my-team']).toBe(previousSnapshot);
|
||||||
|
});
|
||||||
|
|
||||||
it('ignores stale spawn-status fetches after runtime already went offline', async () => {
|
it('ignores stale spawn-status fetches after runtime already went offline', async () => {
|
||||||
const store = createSliceStore();
|
const store = createSliceStore();
|
||||||
store.setState({
|
store.setState({
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue