From 41f0b0d1d1fef616e8faf983c5b6f60337487bc8 Mon Sep 17 00:00:00 2001 From: 777genius Date: Sun, 19 Apr 2026 22:04:44 +0300 Subject: [PATCH] feat(team): enhance TeamMessageFeedService caching and logging - Introduced caching mechanism with expiration for message feeds to improve performance. - Added logging for cache expiration events to aid in debugging. - Updated MessagesPanel to reopen search bar when participant filters are active. - Added test cases for handling tmux server errors and message panel behavior with filters. --- .../services/team/TeamMessageFeedService.ts | 23 +++- .../services/team/TeamProvisioningService.ts | 8 ++ .../team/messages/MessagesPanel.tsx | 8 +- src/renderer/store/index.ts | 10 +- .../team/TeamMessageFeedService.test.ts | 103 ++++++++++++++++++ .../team/TeamProvisioningService.test.ts | 54 +++++++++ .../team/messages/MessagesPanel.test.ts | 48 ++++++++ 7 files changed, 240 insertions(+), 14 deletions(-) create mode 100644 test/main/services/team/TeamMessageFeedService.test.ts diff --git a/src/main/services/team/TeamMessageFeedService.ts b/src/main/services/team/TeamMessageFeedService.ts index b40d01f8..fe4a5160 100644 --- a/src/main/services/team/TeamMessageFeedService.ts +++ b/src/main/services/team/TeamMessageFeedService.ts @@ -1,4 +1,5 @@ import { classifyIdleNotificationText } from '@shared/utils/idleNotificationSemantics'; +import { createLogger } from '@shared/utils/logger'; import { buildStandaloneSlashCommandMeta } from '@shared/utils/slashCommands'; import { createHash } from 'crypto'; @@ -7,6 +8,8 @@ import { getEffectiveInboxMessageId } from './inboxMessageIdentity'; import type { InboxMessage, TeamConfig } from '@shared/types'; const PASSIVE_USER_REPLY_LINK_WINDOW_MS = 15_000; +const MESSAGE_FEED_CACHE_MAX_AGE_MS = 5_000; +const logger = createLogger('Service:TeamMessageFeedService'); interface TeamMessageFeedDeps { getConfig: (teamName: string) => Promise; @@ -18,6 +21,7 @@ interface TeamMessageFeedDeps { interface TeamMessageFeedCacheEntry { feedRevision: string; messages: InboxMessage[]; + cachedAt: number; } export interface TeamNormalizedMessageFeed { @@ -352,7 +356,11 @@ export class TeamMessageFeedService { async getFeed(teamName: string): Promise { const cached = this.cacheByTeam.get(teamName); - if (cached && !this.dirtyTeams.has(teamName)) { + const now = Date.now(); + const cacheDirty = this.dirtyTeams.has(teamName); + const cacheExpired = + !cached || now - cached.cachedAt >= MESSAGE_FEED_CACHE_MAX_AGE_MS; + if (cached && !cacheDirty && !cacheExpired) { return { teamName, feedRevision: cached.feedRevision, @@ -362,7 +370,7 @@ export class TeamMessageFeedService { const config = await this.deps.getConfig(teamName); if (!config) { - const emptyEntry = { feedRevision: toFeedRevision([]), messages: [] }; + const emptyEntry = { feedRevision: toFeedRevision([]), messages: [], cachedAt: now }; this.cacheByTeam.set(teamName, emptyEntry); this.dirtyTeams.delete(teamName); return { teamName, ...emptyEntry }; @@ -389,12 +397,21 @@ export class TeamMessageFeedService { }); const feedRevision = toFeedRevision(messages); + if (cached && !cacheDirty && cacheExpired && cached.feedRevision !== feedRevision) { + logger.warn( + `[${teamName}] Message feed cache expired without dirty invalidation and recovered newer durable messages` + ); + } const nextEntry = cached?.feedRevision === feedRevision - ? cached + ? { + ...cached, + cachedAt: now, + } : { feedRevision, messages, + cachedAt: now, }; this.cacheByTeam.set(teamName, nextEntry); diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 6e6a0fd3..0ef30d72 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -865,6 +865,11 @@ function isConfigRegistrationFailureReason(reason?: string): boolean { ); } +function isTmuxNoServerRunningError(error: unknown): boolean { + const text = error instanceof Error ? error.message : String(error ?? ''); + return /no server running on /i.test(text); +} + function isAutoClearableLaunchFailureReason(reason?: string): boolean { return ( isNeverSpawnedDuringLaunchReason(reason) || @@ -1101,6 +1106,9 @@ async function waitForTmuxPanesToExit( livePanePidById = await listTmuxPanePidsForCurrentPlatform(remainingPaneIds); lastError = null; } catch (error) { + if (isTmuxNoServerRunningError(error)) { + return []; + } lastError = error; await sleep(opts.pollMs); continue; diff --git a/src/renderer/components/team/messages/MessagesPanel.tsx b/src/renderer/components/team/messages/MessagesPanel.tsx index 3dcd1d9a..c5d28c18 100644 --- a/src/renderer/components/team/messages/MessagesPanel.tsx +++ b/src/renderer/components/team/messages/MessagesPanel.tsx @@ -282,11 +282,15 @@ export const MessagesPanel = memo(function MessagesPanel({ ]); useEffect(() => { - if (messagesSearchBarVisible || messagesSearchQuery.trim().length === 0) { + const hasActiveParticipantFilter = messagesFilter.from.size > 0 || messagesFilter.to.size > 0; + if ( + messagesSearchBarVisible || + (messagesSearchQuery.trim().length === 0 && !hasActiveParticipantFilter) + ) { return; } setMessagesSearchBarVisible(true); - }, [messagesSearchBarVisible, messagesSearchQuery]); + }, [messagesFilter.from, messagesFilter.to, messagesSearchBarVisible, messagesSearchQuery]); useEffect(() => { if (!teamName) { diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index 77857dde..5ebcc535 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -116,15 +116,7 @@ function noteTeamChangeEventBurst(teamName: string, eventType: string, visible: now - diagnostic.lastWarnAt >= TEAM_CHANGE_EVENT_WARN_THROTTLE_MS ) { diagnostic.lastWarnAt = now; - const typeSummary = Object.entries(diagnostic.countsByType) - .sort((a, b) => b[1] - a[1]) - .map(([type, count]) => `${type}:${count}`) - .join(','); - logger.warn( - `[perf] team-change burst team=${teamName} total=${diagnostic.count} windowMs=${ - now - diagnostic.windowStartedAt - } types=${typeSummary}` - ); + // Disabled - this warning is too noisy during normal inbox bursts on active teams. } teamChangeEventDiagnostics.set(teamName, diagnostic); diff --git a/test/main/services/team/TeamMessageFeedService.test.ts b/test/main/services/team/TeamMessageFeedService.test.ts new file mode 100644 index 00000000..4c50ba49 --- /dev/null +++ b/test/main/services/team/TeamMessageFeedService.test.ts @@ -0,0 +1,103 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { TeamMessageFeedService } from '../../../../src/main/services/team/TeamMessageFeedService'; + +import type { InboxMessage, TeamConfig } from '../../../../src/shared/types/team'; + +function makeMessage(overrides: Partial = {}): InboxMessage { + return { + from: 'user', + to: 'jack', + text: 'Тут?', + timestamp: '2026-04-19T18:46:37.613Z', + read: true, + source: 'user_sent', + messageId: 'user-send-1', + ...overrides, + }; +} + +describe('TeamMessageFeedService', () => { + const config: TeamConfig = { + name: 'Signal Ops 4', + members: [{ name: 'team-lead', role: 'Lead' }], + }; + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-04-19T18:46:40.000Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('reuses the cached feed within the cache TTL when no dirty invalidation arrives', async () => { + let inboxMessages: InboxMessage[] = [makeMessage()]; + const getInboxMessages = vi.fn(async () => inboxMessages); + const service = new TeamMessageFeedService({ + getConfig: vi.fn(async () => config), + getInboxMessages, + getLeadSessionMessages: vi.fn(async () => []), + getSentMessages: vi.fn(async () => []), + }); + + const first = await service.getFeed('signal-ops-4'); + expect(first.messages).toHaveLength(1); + + inboxMessages = [ + makeMessage({ + from: 'jack', + to: 'user', + text: 'Да, я тут, на связи. Что нужно сделать/проверить?', + source: 'inbox', + timestamp: '2026-04-19T18:46:43.427Z', + }), + ...inboxMessages, + ]; + + vi.setSystemTime(new Date('2026-04-19T18:46:43.000Z')); + + const second = await service.getFeed('signal-ops-4'); + expect(getInboxMessages).toHaveBeenCalledTimes(1); + expect(second.messages).toHaveLength(1); + }); + + it('refreshes the durable feed after cache expiry even when the dirty signal was missed', async () => { + let inboxMessages: InboxMessage[] = [makeMessage()]; + const getInboxMessages = vi.fn(async () => inboxMessages); + const service = new TeamMessageFeedService({ + getConfig: vi.fn(async () => config), + getInboxMessages, + getLeadSessionMessages: vi.fn(async () => []), + getSentMessages: vi.fn(async () => []), + }); + + await service.getFeed('signal-ops-4'); + + inboxMessages = [ + makeMessage({ + from: 'jack', + to: 'user', + text: 'Да, я тут, на связи. Что нужно сделать/проверить?', + source: 'inbox', + timestamp: '2026-04-19T18:46:43.427Z', + }), + makeMessage(), + ]; + + vi.setSystemTime(new Date('2026-04-19T18:46:46.500Z')); + + const refreshed = await service.getFeed('signal-ops-4'); + expect(getInboxMessages).toHaveBeenCalledTimes(2); + expect( + refreshed.messages.some( + (message) => + message.from === 'jack' && + message.to === 'user' && + message.text.includes('Да, я тут') + ) + ).toBe(true); + }); +}); diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index 24a12ce0..db9b9a03 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -1301,6 +1301,60 @@ describe('TeamProvisioningService', () => { expect(sendMessageToRun).not.toHaveBeenCalled(); }); + it('treats a dead tmux server as successful pane exit verification after kill', async () => { + vi.useFakeTimers(); + + const svc = new TeamProvisioningService(); + const run = createMemberSpawnRun({ + teamName: 'tmux-team', + expectedMembers: ['forge'], + memberSpawnStatuses: new Map(), + }); + run.child = { pid: 111 }; + run.processKilled = false; + run.cancelRequested = false; + + const sendMessageToRun = vi.fn(async () => {}); + (svc as any).sendMessageToRun = sendMessageToRun; + (svc as any).configReader = { + getConfig: vi.fn(async () => ({ + name: 'Tmux Team', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + })), + }; + (svc as any).membersMetaStore = { + getMembers: vi.fn(async () => [ + { + name: 'forge', + role: 'Developer', + providerId: 'codex', + model: 'gpt-5.4', + effort: 'medium', + agentType: 'general-purpose', + }, + ]), + }; + (svc as any).readPersistedRuntimeMembers = vi.fn(() => [ + { + name: 'forge', + agentId: 'forge@tmux-team', + backendType: 'tmux', + tmuxPaneId: '%2', + }, + ]); + (svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn(async () => new Map()); + (svc as any).aliveRunByTeam.set('tmux-team', run.runId); + (svc as any).runs.set(run.runId, run); + + vi.mocked(listTmuxPanePidsForCurrentPlatform).mockRejectedValue( + new Error('no server running on /private/tmp/tmux-501/default') + ); + + await svc.restartMember('tmux-team', 'forge'); + + expect(sendMessageToRun).toHaveBeenCalledTimes(1); + }); + it('fails early when the previous process backend runtime does not exit before restart', async () => { vi.useFakeTimers(); diff --git a/test/renderer/components/team/messages/MessagesPanel.test.ts b/test/renderer/components/team/messages/MessagesPanel.test.ts index 824c04b0..9e05f746 100644 --- a/test/renderer/components/team/messages/MessagesPanel.test.ts +++ b/test/renderer/components/team/messages/MessagesPanel.test.ts @@ -504,6 +504,54 @@ describe('MessagesPanel idle summary invariants', () => { }); }); + it('reopens the search and filter bar when a persisted member filter is active', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + sidebarUiState.messagesFilter = { + from: new Set(), + to: new Set(['jack']), + showNoise: false, + }; + sidebarUiState.messagesSearchBarVisible = false; + + await act(async () => { + storeState.teamMessagesByName['atlas-hq'] = { + canonicalMessages: [makeMessage({ to: 'jack', text: 'Тут?' })], + optimisticMessages: [], + feedRevision: 'rev-1', + nextCursor: null, + hasMore: false, + lastFetchedAt: Date.now(), + loadingHead: false, + loadingOlder: false, + headHydrated: true, + }; + root.render( + React.createElement(MessagesPanel, { + teamName: 'atlas-hq', + position: 'sidebar', + onPositionChange: vi.fn(), + members: [], + tasks: [], + timeWindow: null, + pendingRepliesByMember: {}, + onPendingReplyChange: vi.fn(), + }) + ); + await Promise.resolve(); + }); + + expect(host.querySelector('input[placeholder=\"Search...\"]')).not.toBeNull(); + expect(host.textContent).toContain('filter-popover'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + it('requests a one-shot head refresh when the messages cache is empty', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); const host = document.createElement('div');