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 { classifyIdleNotificationText } from '@shared/utils/idleNotificationSemantics';
|
||||||
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import { buildStandaloneSlashCommandMeta } from '@shared/utils/slashCommands';
|
import { buildStandaloneSlashCommandMeta } from '@shared/utils/slashCommands';
|
||||||
import { createHash } from 'crypto';
|
import { createHash } from 'crypto';
|
||||||
|
|
||||||
|
|
@ -7,6 +8,8 @@ import { getEffectiveInboxMessageId } from './inboxMessageIdentity';
|
||||||
import type { InboxMessage, TeamConfig } from '@shared/types';
|
import type { InboxMessage, TeamConfig } from '@shared/types';
|
||||||
|
|
||||||
const PASSIVE_USER_REPLY_LINK_WINDOW_MS = 15_000;
|
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 {
|
interface TeamMessageFeedDeps {
|
||||||
getConfig: (teamName: string) => Promise<TeamConfig | null>;
|
getConfig: (teamName: string) => Promise<TeamConfig | null>;
|
||||||
|
|
@ -18,6 +21,7 @@ interface TeamMessageFeedDeps {
|
||||||
interface TeamMessageFeedCacheEntry {
|
interface TeamMessageFeedCacheEntry {
|
||||||
feedRevision: string;
|
feedRevision: string;
|
||||||
messages: InboxMessage[];
|
messages: InboxMessage[];
|
||||||
|
cachedAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TeamNormalizedMessageFeed {
|
export interface TeamNormalizedMessageFeed {
|
||||||
|
|
@ -352,7 +356,11 @@ export class TeamMessageFeedService {
|
||||||
|
|
||||||
async getFeed(teamName: string): Promise<TeamNormalizedMessageFeed> {
|
async getFeed(teamName: string): Promise<TeamNormalizedMessageFeed> {
|
||||||
const cached = this.cacheByTeam.get(teamName);
|
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 {
|
return {
|
||||||
teamName,
|
teamName,
|
||||||
feedRevision: cached.feedRevision,
|
feedRevision: cached.feedRevision,
|
||||||
|
|
@ -362,7 +370,7 @@ export class TeamMessageFeedService {
|
||||||
|
|
||||||
const config = await this.deps.getConfig(teamName);
|
const config = await this.deps.getConfig(teamName);
|
||||||
if (!config) {
|
if (!config) {
|
||||||
const emptyEntry = { feedRevision: toFeedRevision([]), messages: [] };
|
const emptyEntry = { feedRevision: toFeedRevision([]), messages: [], cachedAt: now };
|
||||||
this.cacheByTeam.set(teamName, emptyEntry);
|
this.cacheByTeam.set(teamName, emptyEntry);
|
||||||
this.dirtyTeams.delete(teamName);
|
this.dirtyTeams.delete(teamName);
|
||||||
return { teamName, ...emptyEntry };
|
return { teamName, ...emptyEntry };
|
||||||
|
|
@ -389,12 +397,21 @@ export class TeamMessageFeedService {
|
||||||
});
|
});
|
||||||
|
|
||||||
const feedRevision = toFeedRevision(messages);
|
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 =
|
const nextEntry =
|
||||||
cached?.feedRevision === feedRevision
|
cached?.feedRevision === feedRevision
|
||||||
? cached
|
? {
|
||||||
|
...cached,
|
||||||
|
cachedAt: now,
|
||||||
|
}
|
||||||
: {
|
: {
|
||||||
feedRevision,
|
feedRevision,
|
||||||
messages,
|
messages,
|
||||||
|
cachedAt: now,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.cacheByTeam.set(teamName, nextEntry);
|
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 {
|
function isAutoClearableLaunchFailureReason(reason?: string): boolean {
|
||||||
return (
|
return (
|
||||||
isNeverSpawnedDuringLaunchReason(reason) ||
|
isNeverSpawnedDuringLaunchReason(reason) ||
|
||||||
|
|
@ -1101,6 +1106,9 @@ async function waitForTmuxPanesToExit(
|
||||||
livePanePidById = await listTmuxPanePidsForCurrentPlatform(remainingPaneIds);
|
livePanePidById = await listTmuxPanePidsForCurrentPlatform(remainingPaneIds);
|
||||||
lastError = null;
|
lastError = null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (isTmuxNoServerRunningError(error)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
lastError = error;
|
lastError = error;
|
||||||
await sleep(opts.pollMs);
|
await sleep(opts.pollMs);
|
||||||
continue;
|
continue;
|
||||||
|
|
|
||||||
|
|
@ -282,11 +282,15 @@ export const MessagesPanel = memo(function MessagesPanel({
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
setMessagesSearchBarVisible(true);
|
setMessagesSearchBarVisible(true);
|
||||||
}, [messagesSearchBarVisible, messagesSearchQuery]);
|
}, [messagesFilter.from, messagesFilter.to, messagesSearchBarVisible, messagesSearchQuery]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!teamName) {
|
if (!teamName) {
|
||||||
|
|
|
||||||
|
|
@ -116,15 +116,7 @@ function noteTeamChangeEventBurst(teamName: string, eventType: string, visible:
|
||||||
now - diagnostic.lastWarnAt >= TEAM_CHANGE_EVENT_WARN_THROTTLE_MS
|
now - diagnostic.lastWarnAt >= TEAM_CHANGE_EVENT_WARN_THROTTLE_MS
|
||||||
) {
|
) {
|
||||||
diagnostic.lastWarnAt = now;
|
diagnostic.lastWarnAt = now;
|
||||||
const typeSummary = Object.entries(diagnostic.countsByType)
|
// Disabled - this warning is too noisy during normal inbox bursts on active teams.
|
||||||
.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}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
teamChangeEventDiagnostics.set(teamName, diagnostic);
|
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();
|
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 () => {
|
it('fails early when the previous process backend runtime does not exit before restart', async () => {
|
||||||
vi.useFakeTimers();
|
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 () => {
|
it('requests a one-shot head refresh when the messages cache is empty', async () => {
|
||||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
const host = document.createElement('div');
|
const host = document.createElement('div');
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue