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.
This commit is contained in:
777genius 2026-04-19 22:04:44 +03:00
parent 83748673af
commit 41f0b0d1d1
7 changed files with 240 additions and 14 deletions

View file

@ -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<TeamConfig | null>;
@ -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<TeamNormalizedMessageFeed> {
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);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<string>(),
to: new Set<string>(['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');