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:
parent
e6b7a6fe53
commit
c4148b0fc8
3 changed files with 343 additions and 65 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue