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.
This commit is contained in:
iliya 2026-03-14 19:14:34 +02:00
parent e6b7a6fe53
commit c4148b0fc8
3 changed files with 343 additions and 65 deletions

View file

@ -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<void> => {
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<void> => {
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<string>
knownMembers: Set<string>,
precomputedAttribution?: SubagentAttribution
): Promise<MemberSubagentLogSummary | null> {
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<string>
): Promise<{
detectedMember: string;
description: string;
firstTimestamp: string | null;
} | null> {
): Promise<SubagentAttribution | null> {
const lines: string[] = [];
try {

View file

@ -68,10 +68,23 @@ export const MessageComposer = ({
sending,
sendError,
lastResult,
textareaRef,
textareaRef: externalTextareaRef,
onSend,
onCrossTeamSend,
}: MessageComposerProps): React.JSX.Element => {
const internalTextareaRef = useRef<HTMLTextAreaElement>(null);
const textareaRef = useMemo(() => {
// Merge internal and external refs into a single callback ref
return (node: HTMLTextAreaElement | null) => {
(internalTextareaRef as React.MutableRefObject<HTMLTextAreaElement | null>).current = node;
if (typeof externalTextareaRef === 'function') {
externalTextareaRef(node);
} else if (externalTextareaRef) {
(externalTextareaRef as React.MutableRefObject<HTMLTextAreaElement | null>).current = node;
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [externalTextareaRef]);
const [recipient, setRecipient] = useState<string>(() => {
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.

View file

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