From c4148b0fc880de041aa8459f0434a95a93612faa Mon Sep 17 00:00:00 2001 From: iliya Date: Sat, 14 Mar 2026 19:14:34 +0200 Subject: [PATCH] refactor: enhance TeamMemberLogsFinder for improved file scanning and attribution - Increased FILE_MENTIONS_CACHE_MAX from 200 to 1000 to accommodate larger datasets. - Introduced SCAN_CONCURRENCY constant to limit the number of concurrent file reads during scans, optimizing performance. - Refactored file scanning logic to collect candidate files more efficiently and process them in parallel. - Added new SubagentAttribution interface to standardize the structure of attribution results. - Expanded unit tests to validate the new file reference retrieval logic for tasks, ensuring accurate member identification across teams. --- .../services/team/TeamMemberLogsFinder.ts | 166 ++++++++----- .../team/messages/MessageComposer.tsx | 24 +- .../team/TeamMemberLogsFinder.test.ts | 218 ++++++++++++++++++ 3 files changed, 343 insertions(+), 65 deletions(-) diff --git a/src/main/services/team/TeamMemberLogsFinder.ts b/src/main/services/team/TeamMemberLogsFinder.ts index b62c7d99..88a36a75 100644 --- a/src/main/services/team/TeamMemberLogsFinder.ts +++ b/src/main/services/team/TeamMemberLogsFinder.ts @@ -23,7 +23,10 @@ const ATTRIBUTION_SCAN_LINES = 50; /** Grace before task creation — logs cannot reference a task before it exists. */ const TASK_SINCE_GRACE_MS = 2 * 60 * 1000; -const FILE_MENTIONS_CACHE_MAX = 200; +const FILE_MENTIONS_CACHE_MAX = 1000; + +/** Max concurrent file reads during parallel scan phases. */ +const SCAN_CONCURRENCY = 15; /** Signal sources for subagent member attribution, ordered by reliability. */ type AttributionSignalSource = 'process_team' | 'routing_sender' | 'teammate_id' | 'text_mention'; @@ -53,6 +56,13 @@ interface StreamedMetadata { messageCount: number; } +/** Result of attributing a subagent file to a team member. */ +interface SubagentAttribution { + detectedMember: string; + description: string; + firstTimestamp: string | null; +} + function trimTrailingSlashes(value: string): string { let end = value.length; while (end > 0) { @@ -182,44 +192,61 @@ export class TeamMemberLogsFinder { } const tLead = performance.now(); - let totalFiles = 0; - let mentionHits = 0; - let cacheHits = 0; - const cacheSnapshotBefore = this.fileMentionsCache.size; - + // ── Collect all subagent file candidates ── + const candidates: { filePath: string; sessionId: string; fileName: string }[] = []; for (const sessionId of sessionIds) { const subagentsDir = path.join(projectDir, sessionId, 'subagents'); - let files: string[]; + let dirFiles: string[]; try { - files = await fs.readdir(subagentsDir); + dirFiles = await fs.readdir(subagentsDir); } catch { continue; } - for (const file of files) { - if (!file.startsWith('agent-') || !file.endsWith('.jsonl')) continue; - if (file.startsWith('agent-acompact')) continue; - totalFiles++; - const filePath = path.join(subagentsDir, file); - const cacheSizeBefore = this.fileMentionsCache.size; - if (!(await this.fileMentionsTaskIdCached(filePath, teamName, taskId, false, sinceMs))) { - if (this.fileMentionsCache.size === cacheSizeBefore) cacheHits++; + for (const f of dirFiles) { + if (!f.startsWith('agent-') || !f.endsWith('.jsonl') || f.startsWith('agent-acompact')) continue; - } - if (this.fileMentionsCache.size === cacheSizeBefore) cacheHits++; - mentionHits++; - const attribution = await this.attributeSubagent(filePath, knownMembers); - if (!attribution) continue; - const summary = await this.parseSubagentSummary( - filePath, - projectId, - sessionId, - file, - attribution.detectedMember, - knownMembers - ); - if (summary) results.push(summary); + candidates.push({ filePath: path.join(subagentsDir, f), sessionId, fileName: f }); } } + + // ── Parallel scan with concurrency limit ── + const settled: (MemberLogSummary | null)[] = new Array(candidates.length).fill(null); + let nextIdx = 0; + let mentionHits = 0; + + const scanWorker = async (): Promise => { + while (nextIdx < candidates.length) { + const idx = nextIdx++; + const c = candidates[idx]; + try { + if (!(await this.fileMentionsTaskIdCached(c.filePath, teamName, taskId, false, sinceMs))) + continue; + mentionHits++; + const attribution = await this.attributeSubagent(c.filePath, knownMembers); + if (!attribution) continue; + const summary = await this.parseSubagentSummary( + c.filePath, + projectId, + c.sessionId, + c.fileName, + attribution.detectedMember, + knownMembers, + attribution + ); + if (summary) settled[idx] = summary; + } catch { + // One file error must not break others + } + } + }; + + await Promise.all( + Array.from({ length: Math.min(SCAN_CONCURRENCY, candidates.length) }, () => scanWorker()) + ); + for (const s of settled) { + if (s) results.push(s); + } + const totalFiles = candidates.length; const tScan = performance.now(); const normalizedOwner = @@ -315,10 +342,9 @@ export class TeamMemberLogsFinder { `total=${(tTotal - t0).toFixed(0)}ms | ` + `discovery=${(tDiscovery - t0).toFixed(0)}ms | ` + `lead=${(tLead - tDiscovery).toFixed(0)}ms | ` + - `scan=${(tScan - tLead).toFixed(0)}ms (${totalFiles} files, ${mentionHits} hits, ${cacheHits} cache) | ` + + `scan=${(tScan - tLead).toFixed(0)}ms (${totalFiles} files, ${mentionHits} hits) | ` + `owner=${(tOwner - tScan).toFixed(0)}ms | ` + - `sessions=${sessionIds.length} | cache=${cacheSnapshotBefore}→${this.fileMentionsCache.size} | ` + - `results=${sorted.length}` + `sessions=${sessionIds.length} | results=${sorted.length}` ); return sorted; @@ -378,37 +404,52 @@ export class TeamMemberLogsFinder { } const tLead = performance.now(); - let totalFiles = 0; - let mentionHits = 0; - + // ── Collect all subagent file candidates ── + const candidates: { filePath: string; sessionId: string; fileName: string }[] = []; for (const sessionId of sessionIds) { const subagentsDir = path.join(projectDir, sessionId, 'subagents'); - let files: string[]; + let dirFiles: string[]; try { - files = await fs.readdir(subagentsDir); + dirFiles = await fs.readdir(subagentsDir); } catch { continue; } - for (const file of files) { - if (!file.startsWith('agent-') || !file.endsWith('.jsonl')) continue; - if (file.startsWith('agent-acompact')) continue; - totalFiles++; - - const filePath = path.join(subagentsDir, file); - if (!(await this.fileMentionsTaskIdCached(filePath, teamName, taskId, false, sinceMs))) { + for (const f of dirFiles) { + if (!f.startsWith('agent-') || !f.endsWith('.jsonl') || f.startsWith('agent-acompact')) continue; - } - mentionHits++; - - const attribution = await this.attributeSubagent(filePath, knownMembers); - if (!attribution) continue; - pushRef( - filePath, - attribution.detectedMember, - await this.getSortTime(filePath, attribution.firstTimestamp) - ); + candidates.push({ filePath: path.join(subagentsDir, f), sessionId, fileName: f }); } } + + // ── Parallel scan with concurrency limit ── + let nextIdx = 0; + let mentionHits = 0; + + const scanWorker = async (): Promise => { + while (nextIdx < candidates.length) { + const idx = nextIdx++; + const c = candidates[idx]; + try { + if (!(await this.fileMentionsTaskIdCached(c.filePath, teamName, taskId, false, sinceMs))) + continue; + mentionHits++; + const attribution = await this.attributeSubagent(c.filePath, knownMembers); + if (!attribution) continue; + pushRef( + c.filePath, + attribution.detectedMember, + await this.getSortTime(c.filePath, attribution.firstTimestamp) + ); + } catch { + // One file error must not break others + } + } + }; + + await Promise.all( + Array.from({ length: Math.min(SCAN_CONCURRENCY, candidates.length) }, () => scanWorker()) + ); + const totalFiles = candidates.length; const tScan = performance.now(); const normalizedOwner = @@ -967,14 +1008,15 @@ export class TeamMemberLogsFinder { sessionId: string, fileName: string, targetMember: string, - knownMembers: Set + knownMembers: Set, + precomputedAttribution?: SubagentAttribution ): Promise { const subagentId = fileName.replace(/^agent-/, '').replace(/\.jsonl$/, ''); // ── Phase 1: Attribution (first N lines) ── - // Detect which member owns this file + extract description. - // All detection signals appear in the first few lines of the JSONL. - const attribution = await this.attributeSubagent(filePath, knownMembers); + // Reuse pre-computed attribution when available to avoid re-reading the file. + const attribution = + precomputedAttribution ?? (await this.attributeSubagent(filePath, knownMembers)); if (!attribution) return null; const targetLower = targetMember.toLowerCase(); @@ -1031,11 +1073,7 @@ export class TeamMemberLogsFinder { private async attributeSubagent( filePath: string, knownMembers: Set - ): Promise<{ - detectedMember: string; - description: string; - firstTimestamp: string | null; - } | null> { + ): Promise { const lines: string[] = []; try { diff --git a/src/renderer/components/team/messages/MessageComposer.tsx b/src/renderer/components/team/messages/MessageComposer.tsx index 48194f3d..d579629e 100644 --- a/src/renderer/components/team/messages/MessageComposer.tsx +++ b/src/renderer/components/team/messages/MessageComposer.tsx @@ -68,10 +68,23 @@ export const MessageComposer = ({ sending, sendError, lastResult, - textareaRef, + textareaRef: externalTextareaRef, onSend, onCrossTeamSend, }: MessageComposerProps): React.JSX.Element => { + const internalTextareaRef = useRef(null); + const textareaRef = useMemo(() => { + // Merge internal and external refs into a single callback ref + return (node: HTMLTextAreaElement | null) => { + (internalTextareaRef as React.MutableRefObject).current = node; + if (typeof externalTextareaRef === 'function') { + externalTextareaRef(node); + } else if (externalTextareaRef) { + (externalTextareaRef as React.MutableRefObject).current = node; + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [externalTextareaRef]); const [recipient, setRecipient] = useState(() => { const lead = members.find((m) => m.role === 'lead' || m.name === 'team-lead'); return lead?.name ?? members[0]?.name ?? ''; @@ -197,6 +210,15 @@ export const MessageComposer = ({ const { actionMode, setActionMode, isLoaded: draftLoaded } = draft; + // Re-focus textarea after action mode changes (Do/Ask/Delegate button clicks) + const prevActionModeRef = useRef(actionMode); + useEffect(() => { + if (prevActionModeRef.current !== actionMode) { + prevActionModeRef.current = actionMode; + internalTextareaRef.current?.focus(); + } + }, [actionMode]); + // Auto-select delegate when lead recipient is chosen by the user. // Wait until draft is restored from IndexedDB (draftLoaded) before running, // so we don't overwrite the persisted actionMode during initialization. diff --git a/test/main/services/team/TeamMemberLogsFinder.test.ts b/test/main/services/team/TeamMemberLogsFinder.test.ts index 16b70e1c..78f17bc2 100644 --- a/test/main/services/team/TeamMemberLogsFinder.test.ts +++ b/test/main/services/team/TeamMemberLogsFinder.test.ts @@ -800,4 +800,222 @@ describe('TeamMemberLogsFinder', () => { await expect(finder.hasTaskUpdateMarker(legacyPath, 'task-42')).resolves.toBe(false); await expect(finder.hasTaskUpdateMarker(noisePath, 'task-42')).resolves.toBe(false); }); + + it('findLogFileRefsForTask returns correct refs for a task', async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-refs-')); + setClaudeBasePathOverride(tmpDir); + + const teamName = 'refs-team'; + const projectPath = '/Users/test/ref-proj'; + const projectId = '-Users-test-ref-proj'; + const sessionId = 'sr1'; + + await fs.mkdir(path.join(tmpDir, 'teams', teamName), { recursive: true }); + await fs.writeFile( + path.join(tmpDir, 'teams', teamName, 'config.json'), + JSON.stringify({ + name: teamName, + projectPath, + members: [ + { name: 'team-lead', agentType: 'team-lead' }, + { name: 'dev', agentType: 'general-purpose' }, + ], + }), + 'utf8' + ); + + const projectRoot = path.join(tmpDir, 'projects', projectId); + await fs.mkdir(path.join(projectRoot, sessionId, 'subagents'), { recursive: true }); + + await fs.writeFile( + path.join(projectRoot, sessionId, 'subagents', 'agent-ref1.jsonl'), + [ + JSON.stringify({ + timestamp: '2026-01-01T00:00:01.000Z', + type: 'user', + message: { + role: 'user', + content: 'You are dev, a developer on team "refs-team" (refs-team).', + }, + }), + JSON.stringify({ + timestamp: '2026-01-01T00:00:02.000Z', + type: 'assistant', + message: { + role: 'assistant', + content: [ + { type: 'tool_use', name: 'TaskUpdate', input: { taskId: '5', status: 'in_progress' } }, + ], + }, + }), + ].join('\n') + '\n', + 'utf8' + ); + + const finder = new TeamMemberLogsFinder(); + const refs = await finder.findLogFileRefsForTask(teamName, '5'); + + expect(refs).toHaveLength(1); + expect(refs[0].memberName.toLowerCase()).toBe('dev'); + expect(refs[0].filePath).toContain('agent-ref1.jsonl'); + }); + + it('findLogFileRefsForTask does not mix tasks across teams sharing a projectPath', async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-refs-cross-')); + setClaudeBasePathOverride(tmpDir); + + const projectPath = '/Users/test/shared-ref-proj'; + const projectId = '-Users-test-shared-ref-proj'; + const sessionId = 'sref'; + const teamA = 'ref-a'; + const teamB = 'ref-b'; + + await fs.mkdir(path.join(tmpDir, 'teams', teamA), { recursive: true }); + await fs.mkdir(path.join(tmpDir, 'teams', teamB), { recursive: true }); + + await fs.writeFile( + path.join(tmpDir, 'teams', teamA, 'config.json'), + JSON.stringify({ + name: teamA, + projectPath, + members: [ + { name: 'team-lead', agentType: 'team-lead' }, + { name: 'alice', agentType: 'general-purpose' }, + ], + }), + 'utf8' + ); + await fs.writeFile( + path.join(tmpDir, 'teams', teamB, 'config.json'), + JSON.stringify({ + name: teamB, + projectPath, + members: [ + { name: 'team-lead', agentType: 'team-lead' }, + { name: 'bob', agentType: 'general-purpose' }, + ], + }), + 'utf8' + ); + + const projectRoot = path.join(tmpDir, 'projects', projectId); + await fs.mkdir(path.join(projectRoot, sessionId, 'subagents'), { recursive: true }); + + // Team A agent with task 7 + await fs.writeFile( + path.join(projectRoot, sessionId, 'subagents', 'agent-ra.jsonl'), + [ + JSON.stringify({ + timestamp: '2026-01-01T00:00:01.000Z', + type: 'user', + message: { role: 'user', content: 'You are alice, a developer on team "ref-a" (ref-a).' }, + }), + JSON.stringify({ + timestamp: '2026-01-01T00:00:02.000Z', + type: 'assistant', + message: { + role: 'assistant', + content: [ + { type: 'tool_use', name: 'TaskUpdate', input: { taskId: '7', status: 'completed' } }, + ], + }, + }), + ].join('\n') + '\n', + 'utf8' + ); + + // Team B agent with same task id 7 + await fs.writeFile( + path.join(projectRoot, sessionId, 'subagents', 'agent-rb.jsonl'), + [ + JSON.stringify({ + timestamp: '2026-01-01T00:00:03.000Z', + type: 'user', + message: { role: 'user', content: 'You are bob, a developer on team "ref-b" (ref-b).' }, + }), + JSON.stringify({ + timestamp: '2026-01-01T00:00:04.000Z', + type: 'assistant', + message: { + role: 'assistant', + content: [ + { type: 'tool_use', name: 'TaskUpdate', input: { taskId: '7', status: 'completed' } }, + ], + }, + }), + ].join('\n') + '\n', + 'utf8' + ); + + const finder = new TeamMemberLogsFinder(); + const refsA = await finder.findLogFileRefsForTask(teamA, '7'); + + expect(refsA.some((r) => r.memberName.toLowerCase() === 'alice')).toBe(true); + expect(refsA.some((r) => r.memberName.toLowerCase() === 'bob')).toBe(false); + }); + + it('findLogFileRefsForTask does not duplicate refs for owner logs', async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-refs-dedup-')); + setClaudeBasePathOverride(tmpDir); + + const teamName = 'dedup-team'; + const projectPath = '/Users/test/dedup-proj'; + const projectId = '-Users-test-dedup-proj'; + const sessionId = 'sdd'; + + await fs.mkdir(path.join(tmpDir, 'teams', teamName), { recursive: true }); + await fs.writeFile( + path.join(tmpDir, 'teams', teamName, 'config.json'), + JSON.stringify({ + name: teamName, + projectPath, + members: [ + { name: 'team-lead', agentType: 'team-lead' }, + { name: 'dev', agentType: 'general-purpose' }, + ], + }), + 'utf8' + ); + + const projectRoot = path.join(tmpDir, 'projects', projectId); + await fs.mkdir(path.join(projectRoot, sessionId, 'subagents'), { recursive: true }); + + // Agent file that mentions task AND belongs to owner 'dev' + await fs.writeFile( + path.join(projectRoot, sessionId, 'subagents', 'agent-dd1.jsonl'), + [ + JSON.stringify({ + timestamp: '2026-01-01T00:00:01.000Z', + type: 'user', + message: { + role: 'user', + content: 'You are dev, a developer on team "dedup-team" (dedup-team).', + }, + }), + JSON.stringify({ + timestamp: '2026-01-01T00:00:02.000Z', + type: 'assistant', + message: { + role: 'assistant', + content: [ + { type: 'tool_use', name: 'TaskUpdate', input: { taskId: '3', status: 'in_progress' } }, + ], + }, + }), + ].join('\n') + '\n', + 'utf8' + ); + + const finder = new TeamMemberLogsFinder(); + // File found as direct task hit AND as owner log — should appear once + const refs = await finder.findLogFileRefsForTask(teamName, '3', { + owner: 'dev', + status: 'in_progress', + }); + + // Count refs with this file path — should be exactly 1 + const deduped = refs.filter((r) => r.filePath.includes('agent-dd1.jsonl')); + expect(deduped).toHaveLength(1); + expect(deduped[0].memberName.toLowerCase()).toBe('dev'); + }); });