diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index fcdd2d70..bdd2800a 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -807,13 +807,18 @@ import type { // its initial two-sample pass. Keep this above slow PowerShell startup time, or // the first sample can expire before the recursive second read and loop again. const RUNTIME_PIDUSAGE_OPTIONS = process.platform === 'win32' ? { maxage: 10_000 } : { maxage: 0 }; -const READ_PROCESS_COMMAND_TIMEOUT_MS = 1_000; +const READ_PROCESS_COMMAND_TIMEOUT_MS = 300; interface RuntimeProcessUsageStats { rssBytes?: number; cpuPercent?: number; } +interface RuntimeProcessCommandCacheEntry { + expiresAtMs: number; + command: string | null; +} + interface RuntimeProcessLoadStats extends RuntimeProcessUsageStats { primaryCpuPercent?: number; primaryRssBytes?: number; @@ -3347,7 +3352,10 @@ export class TeamProvisioningService { private static readonly MAX_RUNTIME_TREE_PIDS_PER_ROOT = 64; private static readonly MAX_RUNTIME_USAGE_PIDS_PER_SNAPSHOT = 512; private static readonly RUNTIME_PROCESS_TABLE_CACHE_TTL_MS = 2_000; - private static readonly RUNTIME_PROCESS_USAGE_CACHE_TTL_MS = 2_000; + private static readonly RUNTIME_PROCESS_USAGE_CACHE_TTL_MS = 30_000; + private static readonly RUNTIME_PROCESS_COMMAND_CACHE_TTL_MS = 10_000; + private static readonly RUNTIME_PROCESS_COMMAND_MISS_CACHE_TTL_MS = 15_000; + private static readonly RUNTIME_PROCESS_COMMAND_CACHE_MAX = 2_048; private static readonly RUNTIME_PROCESS_TABLE_TIMEOUT_MS = 1_500; private static readonly RUNTIME_WINDOWS_PROCESS_TABLE_TIMEOUT_MS = 1_500; private static readonly RUNTIME_PIDUSAGE_BATCH_TIMEOUT_MS = 2_000; @@ -3471,6 +3479,10 @@ export class TeamProvisioningService { stats: RuntimeProcessUsageStats | null; } >(); + private readonly runtimeProcessCommandCacheByPid = new Map< + number, + RuntimeProcessCommandCacheEntry + >(); private readonly bootstrapTranscriptOutcomeCache = new Map< string, BootstrapTranscriptOutcome | null @@ -5307,6 +5319,55 @@ export class TeamProvisioningService { } } + private readCachedProcessCommandByPid(pid: number): string | null { + if (!Number.isFinite(pid) || pid <= 0) { + return null; + } + + const now = Date.now(); + const cached = this.runtimeProcessCommandCacheByPid.get(pid); + if (cached && cached.expiresAtMs > now) { + return cached.command; + } + + const command = this.readProcessCommandByPid(pid); + const normalizedCommand = command?.trim() || null; + const ttlMs = normalizedCommand + ? TeamProvisioningService.RUNTIME_PROCESS_COMMAND_CACHE_TTL_MS + : TeamProvisioningService.RUNTIME_PROCESS_COMMAND_MISS_CACHE_TTL_MS; + this.rememberRuntimeProcessCommand(pid, normalizedCommand, now + ttlMs); + return normalizedCommand; + } + + private rememberRuntimeProcessCommand( + pid: number, + command: string | null, + expiresAtMs: number + ): void { + if ( + this.runtimeProcessCommandCacheByPid.size >= + TeamProvisioningService.RUNTIME_PROCESS_COMMAND_CACHE_MAX + ) { + const now = Date.now(); + for (const [cachedPid, cached] of this.runtimeProcessCommandCacheByPid) { + if (cached.expiresAtMs <= now) { + this.runtimeProcessCommandCacheByPid.delete(cachedPid); + } + } + } + while ( + this.runtimeProcessCommandCacheByPid.size >= + TeamProvisioningService.RUNTIME_PROCESS_COMMAND_CACHE_MAX + ) { + const oldestPid = this.runtimeProcessCommandCacheByPid.keys().next().value; + if (oldestPid === undefined) { + break; + } + this.runtimeProcessCommandCacheByPid.delete(oldestPid); + } + this.runtimeProcessCommandCacheByPid.set(pid, { expiresAtMs, command }); + } + private isOpenCodeServeCommand(command: string): boolean { return /(^|[/\\\s])opencode(?:\.exe)?(\s|$)/i.test(command) && /\sserve(\s|$)/i.test(command); } @@ -14646,7 +14707,6 @@ export class TeamProvisioningService { rssPid > 0 ) { try { - this.runtimeProcessUsageStatsCacheByPid.delete(rssPid); const refreshedUsageStats = (await this.readProcessUsageStatsByPid([rssPid])).get(rssPid); if (refreshedUsageStats) { usageStatsByPid.set(rssPid, refreshedUsageStats); @@ -25938,9 +25998,13 @@ export class TeamProvisioningService { runtimePid: targetedRuntimePid, }) ) { + const shouldUseTargetedDirectPidRead = + !memberProcessTableAvailable || memberProcessRows.length === 0; const targetedCommand = this.findRuntimeProcessCommandByPid(memberProcessRows, targetedRuntimePid) ?? - this.readProcessCommandByPid(targetedRuntimePid); + (shouldUseTargetedDirectPidRead + ? this.readCachedProcessCommandByPid(targetedRuntimePid) + : null); if (targetedCommand) { resolved = resolveTeamMemberRuntimeLiveness({ ...livenessInput, diff --git a/src/renderer/components/team/messages/MessageComposer.pendingSend.test.tsx b/src/renderer/components/team/messages/MessageComposer.pendingSend.test.tsx index 247dd78d..7f1818b2 100644 --- a/src/renderer/components/team/messages/MessageComposer.pendingSend.test.tsx +++ b/src/renderer/components/team/messages/MessageComposer.pendingSend.test.tsx @@ -71,6 +71,24 @@ const provisioningHarness = vi.hoisted(() => { }; }); +type SuggestionHookOptions = { + enabled?: boolean; +}; + +const suggestionHarness = vi.hoisted(() => { + const state = { + taskOptions: [] as SuggestionHookOptions[], + teamOptions: [] as SuggestionHookOptions[], + }; + return { + reset: () => { + state.taskOptions = []; + state.teamOptions = []; + }, + state, + }; +}); + const storeHarness = vi.hoisted(() => { const state = { crossTeamTargets: [] as { @@ -83,9 +101,16 @@ const storeHarness = vi.hoisted(() => { isOnline?: boolean; }[], }; + const methods = { + fetchCrossTeamTargets: vi.fn(), + fetchSkillsCatalog: vi.fn(), + }; return { + methods, reset: () => { state.crossTeamTargets = []; + methods.fetchCrossTeamTargets.mockClear(); + methods.fetchSkillsCatalog.mockClear(); }, state, }; @@ -129,14 +154,18 @@ vi.mock('@renderer/components/ui/MentionableTextarea', () => { cornerAction?: React.ReactNode; cornerActionLeft?: React.ReactNode; footerRight?: React.ReactNode; + onBlur?: React.FocusEventHandler; + onFocus?: React.FocusEventHandler; } - >(({ value, disabled, cornerAction, cornerActionLeft, footerRight }, ref) => + >(({ value, disabled, cornerAction, cornerActionLeft, footerRight, onBlur, onFocus }, ref) => React.createElement( 'div', null, React.createElement('textarea', { 'aria-label': 'Message', disabled, + onBlur, + onFocus, readOnly: true, ref, value, @@ -198,19 +227,25 @@ vi.mock('@renderer/hooks/useComposerDraft', () => ({ })); vi.mock('@renderer/hooks/useTaskSuggestions', () => ({ - useTaskSuggestions: () => ({ suggestions: [] }), + useTaskSuggestions: (_teamName: string | null, options: SuggestionHookOptions = {}) => { + suggestionHarness.state.taskOptions.push(options); + return { suggestions: [] }; + }, })); vi.mock('@renderer/hooks/useTeamSuggestions', () => ({ - useTeamSuggestions: () => ({ suggestions: [] }), + useTeamSuggestions: (_teamName: string | null, options: SuggestionHookOptions = {}) => { + suggestionHarness.state.teamOptions.push(options); + return { suggestions: [] }; + }, })); vi.mock('@renderer/store', () => ({ useStore: (selector: (state: Record) => unknown) => selector({ crossTeamTargets: storeHarness.state.crossTeamTargets, - fetchCrossTeamTargets: vi.fn(), - fetchSkillsCatalog: vi.fn(), + fetchCrossTeamTargets: storeHarness.methods.fetchCrossTeamTargets, + fetchSkillsCatalog: storeHarness.methods.fetchSkillsCatalog, selectedTeamData: null, selectedTeamName: null, skillsProjectCatalogByProjectPath: {}, @@ -312,6 +347,7 @@ describe('MessageComposer pending send lifecycle', () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); draftHarness.reset(); provisioningHarness.reset(); + suggestionHarness.reset(); storeHarness.reset(); }); @@ -649,4 +685,47 @@ describe('MessageComposer pending send lifecycle', () => { root.unmount(); }); }); + + it('defers expensive mention data until the matching trigger is typed', () => { + draftHarness.state.text = ''; + const { host, render, root } = renderComposer(); + + expect(suggestionHarness.state.taskOptions.at(-1)?.enabled).toBe(false); + expect(suggestionHarness.state.teamOptions.at(-1)?.enabled).toBe(false); + expect(storeHarness.methods.fetchSkillsCatalog).not.toHaveBeenCalled(); + expect(storeHarness.methods.fetchCrossTeamTargets).not.toHaveBeenCalled(); + + act(() => { + getTextarea(host).focus(); + }); + + expect(suggestionHarness.state.taskOptions.at(-1)?.enabled).toBe(false); + expect(suggestionHarness.state.teamOptions.at(-1)?.enabled).toBe(false); + expect(storeHarness.methods.fetchSkillsCatalog).not.toHaveBeenCalled(); + expect(storeHarness.methods.fetchCrossTeamTargets).not.toHaveBeenCalled(); + + draftHarness.state.text = '#'; + render(); + + expect(suggestionHarness.state.taskOptions.at(-1)?.enabled).toBe(true); + expect(suggestionHarness.state.teamOptions.at(-1)?.enabled).toBe(false); + expect(storeHarness.methods.fetchSkillsCatalog).not.toHaveBeenCalled(); + + draftHarness.state.text = '@'; + render(); + + expect(suggestionHarness.state.taskOptions.at(-1)?.enabled).toBe(false); + expect(suggestionHarness.state.teamOptions.at(-1)?.enabled).toBe(true); + expect(storeHarness.methods.fetchSkillsCatalog).not.toHaveBeenCalled(); + + draftHarness.state.text = '/'; + render(); + + expect(storeHarness.methods.fetchSkillsCatalog).toHaveBeenCalledTimes(1); + expect(storeHarness.methods.fetchCrossTeamTargets).not.toHaveBeenCalled(); + + act(() => { + root.unmount(); + }); + }); }); diff --git a/src/renderer/components/team/messages/MessageComposer.tsx b/src/renderer/components/team/messages/MessageComposer.tsx index f7d0b4a3..24b18594 100644 --- a/src/renderer/components/team/messages/MessageComposer.tsx +++ b/src/renderer/components/team/messages/MessageComposer.tsx @@ -112,6 +112,8 @@ let pendingSendIdCounter = 0; const FLOATING_COMPOSER_MIN_WIDTH = 350; const FLOATING_COMPOSER_MAX_WIDTH = 500; const FLOATING_COMPOSER_TEXT_BUFFER = 4; +const EMPTY_MENTION_SUGGESTIONS: MentionSuggestion[] = []; +const EMPTY_SKILL_CATALOG = [] as const; function createPendingSendId(): string { const randomId = globalThis.crypto?.randomUUID?.(); @@ -189,13 +191,10 @@ export const MessageComposer = ({ const [selectedTeam, setSelectedTeam] = useState(null); const [teamSelectorOpen, setTeamSelectorOpen] = useState(false); const [aliveTeams, setAliveTeams] = useState>(new Set()); + const crossTeamTargetsFetchedRef = useRef(false); const allCrossTeamTargets = useStore(useShallow((s) => s.crossTeamTargets)); const fetchCrossTeamTargets = useStore((s) => s.fetchCrossTeamTargets); - useEffect(() => { - void fetchCrossTeamTargets(); - }, [fetchCrossTeamTargets]); - const refreshAliveTeams = useCallback(async () => { try { const list = await api.teams.aliveList(); @@ -205,14 +204,14 @@ export const MessageComposer = ({ } }, []); - useEffect(() => { - void refreshAliveTeams(); - }, [refreshAliveTeams]); - useEffect(() => { if (!teamSelectorOpen) return; + if (!crossTeamTargetsFetchedRef.current) { + crossTeamTargetsFetchedRef.current = true; + void fetchCrossTeamTargets(); + } void refreshAliveTeams(); - }, [teamSelectorOpen, refreshAliveTeams]); + }, [fetchCrossTeamTargets, refreshAliveTeams, teamSelectorOpen]); // Always filter out current team on the UI side (store is global, shared across tabs) const crossTeamTargets = useMemo( @@ -273,6 +272,15 @@ export const MessageComposer = ({ const isProvisioning = useStore((s) => isTeamProvisioningActive(s, teamName)); const draft = useComposerDraft(teamName); const appliedRevisionRequestIdRef = useRef(null); + const textHasTeamMentionTrigger = draft.text.includes('@'); + const textHasTaskMentionTrigger = draft.text.includes('#'); + const textHasSlashCommandTrigger = stripEncodedTaskReferenceMetadata(draft.text) + .trimStart() + .startsWith('/'); + const taskSuggestionDataEnabled = + textHasTaskMentionTrigger || draft.chips.length > 0 || revisionRequest != null; + const teamSuggestionDataEnabled = textHasTeamMentionTrigger; + const slashCommandDataEnabled = textHasSlashCommandTrigger; const colorMap = useMemo(() => buildMemberColorMap(members), [members]); @@ -293,30 +301,43 @@ export const MessageComposer = ({ ); }, [members]); - const { suggestions: teamMentionSuggestions } = useTeamSuggestions(teamName); - const { suggestions: taskSuggestions } = useTaskSuggestions(teamName); + const { suggestions: teamMentionSuggestions } = useTeamSuggestions(teamName, { + enabled: teamSuggestionDataEnabled, + }); + const { suggestions: taskSuggestions } = useTaskSuggestions(teamName, { + enabled: taskSuggestionDataEnabled, + }); // Project skills as slash command suggestions const projectSkills = useStore( - useShallow((s) => (projectPath ? (s.skillsProjectCatalogByProjectPath[projectPath] ?? []) : [])) + useShallow((s) => + slashCommandDataEnabled && projectPath + ? (s.skillsProjectCatalogByProjectPath[projectPath] ?? EMPTY_SKILL_CATALOG) + : EMPTY_SKILL_CATALOG + ) + ); + const userSkills = useStore( + useShallow((s) => (slashCommandDataEnabled ? s.skillsUserCatalog : EMPTY_SKILL_CATALOG)) ); - const userSkills = useStore(useShallow((s) => s.skillsUserCatalog)); const fetchSkillsCatalog = useStore((s) => s.fetchSkillsCatalog); const isLaunchBlocking = isProvisioning && !isTeamAlive; - // Fetch skills catalog for the team's project on mount / project change + // Fetch the catalog only when slash suggestions are actually needed. useEffect(() => { + if (!slashCommandDataEnabled) return; void fetchSkillsCatalog(projectPath ?? undefined); - }, [fetchSkillsCatalog, projectPath]); + }, [fetchSkillsCatalog, projectPath, slashCommandDataEnabled]); const slashCommandSuggestions = useMemo( () => - buildSlashCommandSuggestions( - getSuggestedSlashCommandsForProvider(leadProviderId), - projectSkills, - userSkills, - leadProviderId - ), - [leadProviderId, projectSkills, userSkills] + slashCommandDataEnabled + ? buildSlashCommandSuggestions( + getSuggestedSlashCommandsForProvider(leadProviderId), + projectSkills, + userSkills, + leadProviderId + ) + : EMPTY_MENTION_SUGGESTIONS, + [leadProviderId, projectSkills, slashCommandDataEnabled, userSkills] ); const trimmed = stripEncodedTaskReferenceMetadata(draft.text).trim(); diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index da153b6b..9ffef623 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -613,6 +613,11 @@ type TeamProvisioningServicePrivateHarness = { ...segments: string[] ) => string; mergeAndRemoveDuplicateInboxes: (teamName: string, baseNames: Set) => Promise; + readProcessCommandByPid: (pid: number) => string | null; + readCachedProcessCommandByPid: (pid: number) => string | null; + readProcessUsageStatsByPid: ( + pids: readonly number[] + ) => Promise>; getLiveTeamAgentRuntimeMetadata: ( teamName: string ) => Promise>>; @@ -629,6 +634,10 @@ type TeamProvisioningServicePrivateHarness = { ) => Record; }; +type TeamProvisioningServiceRuntimeCommandCacheStatics = { + RUNTIME_PROCESS_COMMAND_MISS_CACHE_TTL_MS: number; +}; + function privateHarness(svc: TeamProvisioningService): TeamProvisioningServicePrivateHarness { return svc as unknown as TeamProvisioningServicePrivateHarness; } @@ -4890,6 +4899,23 @@ describe('TeamProvisioningService', () => { expect(stats.get(333)).toEqual({ rssBytes: 123_000_000, cpuPercent: 7 }); }); + it('caches runtime process usage stats for repeated reads', async () => { + const svc = new TeamProvisioningService(); + const usageByPid: Record> = { + '111': createPidusageStat(111, 123_000_000, 7), + }; + vi.mocked(pidusage).mockResolvedValueOnce(usageByPid); + + const harness = privateHarness(svc); + const first = await harness.readProcessUsageStatsByPid([111]); + const second = await harness.readProcessUsageStatsByPid([111]); + + expect(pidusage).toHaveBeenCalledTimes(1); + expect(pidusage).toHaveBeenCalledWith([111], EXPECTED_RUNTIME_PIDUSAGE_OPTIONS); + expect(first.get(111)).toEqual({ rssBytes: 123_000_000, cpuPercent: 7 }); + expect(second.get(111)).toEqual({ rssBytes: 123_000_000, cpuPercent: 7 }); + }); + it('falls back to direct agent process lookup when tmux pane pid lookup is unavailable', async () => { const svc = new TeamProvisioningService(); (svc as any).configReader = { @@ -5652,6 +5678,102 @@ describe('TeamProvisioningService', () => { }); }); + it('does not run targeted pid verification when a non-empty process table misses the pid', async () => { + const svc = new TeamProvisioningService(); + (svc as any).configReader = { + getConfig: vi.fn(async () => ({ + members: [ + { name: 'team-lead', agentType: 'team-lead' }, + { + name: 'alice', + providerId: 'codex', + model: 'gpt-5.4-mini', + agentId: 'alice@vector-room-13', + backendType: 'process', + runtimePid: 74735, + tmuxPaneId: 'process:74735', + }, + ], + })), + }; + (svc as any).membersMetaStore = { + getMembers: vi.fn(async () => [ + { + name: 'alice', + providerId: 'codex', + model: 'gpt-5.4-mini', + }, + ]), + }; + (svc as any).readPersistedRuntimeMembers = vi.fn(() => [ + { + name: 'alice', + providerId: 'codex', + model: 'gpt-5.4-mini', + agentId: 'alice@vector-room-13', + backendType: 'process', + runtimePid: 74735, + tmuxPaneId: 'process:74735', + }, + ]); + vi.mocked(listRuntimeProcessTableForCurrentPlatform).mockResolvedValueOnce([ + { + pid: 111, + ppid: 1, + command: '/usr/bin/other-process', + }, + ]); + const targetedRead = vi.spyOn(svc as any, 'readProcessCommandByPid').mockReturnValue( + '/Users/belief/.bun/bin/bun cli.js --agent-id alice@vector-room-13 --agent-name alice --team-name vector-room-13 --model gpt-5.4-mini' + ); + + const metadata = await (svc as any).getLiveTeamAgentRuntimeMetadata('vector-room-13'); + + expect(targetedRead).not.toHaveBeenCalled(); + expect(metadata.get('alice')?.livenessKind).not.toBe('runtime_process'); + }); + + it('caches targeted process command reads for liveness checks', () => { + const svc = new TeamProvisioningService(); + const harness = privateHarness(svc); + const directRead = vi + .spyOn(harness, 'readProcessCommandByPid') + .mockReturnValue('/usr/bin/codex --agent-id alice@runtime-team'); + + expect(harness.readCachedProcessCommandByPid(74735)).toBe( + '/usr/bin/codex --agent-id alice@runtime-team' + ); + expect(harness.readCachedProcessCommandByPid(74735)).toBe( + '/usr/bin/codex --agent-id alice@runtime-team' + ); + + expect(directRead).toHaveBeenCalledTimes(1); + }); + + it('expires cached targeted process command misses quickly', () => { + vi.useFakeTimers(); + const svc = new TeamProvisioningService(); + const harness = privateHarness(svc); + const runtimeCommandCacheStatics = + TeamProvisioningService as unknown as TeamProvisioningServiceRuntimeCommandCacheStatics; + const originalMissTtl = runtimeCommandCacheStatics.RUNTIME_PROCESS_COMMAND_MISS_CACHE_TTL_MS; + runtimeCommandCacheStatics.RUNTIME_PROCESS_COMMAND_MISS_CACHE_TTL_MS = 25; + const directRead = vi.spyOn(harness, 'readProcessCommandByPid').mockReturnValue(null); + + try { + expect(harness.readCachedProcessCommandByPid(74735)).toBeNull(); + expect(harness.readCachedProcessCommandByPid(74735)).toBeNull(); + expect(directRead).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(26); + + expect(harness.readCachedProcessCommandByPid(74735)).toBeNull(); + expect(directRead).toHaveBeenCalledTimes(2); + } finally { + runtimeCommandCacheStatics.RUNTIME_PROCESS_COMMAND_MISS_CACHE_TTL_MS = originalMissTtl; + } + }); + it('does not let removed base member metadata hide an active suffixed member', async () => { const svc = new TeamProvisioningService(); (svc as any).configReader = {