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:
parent
83748673af
commit
41f0b0d1d1
7 changed files with 240 additions and 14 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
103
test/main/services/team/TeamMessageFeedService.test.ts
Normal file
103
test/main/services/team/TeamMessageFeedService.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Reference in a new issue