refactor: optimize TeamMemberLogsFinder for efficient subagent file processing

- Introduced a new method to collect subagent file candidates, streamlining the file scanning process.
- Implemented parallel processing of subagent files with a concurrency limit to enhance performance.
- Improved error handling during file parsing, ensuring that failures do not disrupt the overall scanning process.
- Added unit tests to verify that member logs are returned sorted by start time in descending order.
This commit is contained in:
iliya 2026-03-14 19:44:28 +02:00
parent 471d1871ec
commit 85fa86f1be
2 changed files with 122 additions and 56 deletions

View file

@ -108,31 +108,36 @@ export class TeamMemberLogsFinder {
} }
} }
for (const sessionId of sessionIds) { // ── Collect and parallel-scan subagent files ──
const subagentsDir = path.join(projectDir, sessionId, 'subagents'); const candidates = await this.collectSubagentCandidates(projectDir, sessionIds);
const settled: (MemberSubagentLogSummary | null)[] = new Array(candidates.length).fill(null);
let nextIdx = 0;
let files: string[]; const scanWorker = async (): Promise<void> => {
try { while (nextIdx < candidates.length) {
files = await fs.readdir(subagentsDir); const idx = nextIdx++;
} catch { const c = candidates[idx];
continue; try {
const summary = await this.parseSubagentSummary(
c.filePath,
projectId,
c.sessionId,
c.fileName,
memberName,
knownMembers
);
if (summary) settled[idx] = summary;
} catch (err) {
logger.warn(`Failed to parse subagent summary: ${c.filePath}`, err);
}
} }
};
for (const file of files) { await Promise.all(
if (!file.startsWith('agent-') || !file.endsWith('.jsonl')) continue; Array.from({ length: Math.min(SCAN_CONCURRENCY, candidates.length) }, () => scanWorker())
if (file.startsWith('agent-acompact')) continue; );
for (const s of settled) {
const filePath = path.join(subagentsDir, file); if (s) results.push(s);
const summary = await this.parseSubagentSummary(
filePath,
projectId,
sessionId,
file,
memberName,
knownMembers
);
if (summary) results.push(summary);
}
} }
return results.sort( return results.sort(
@ -193,21 +198,7 @@ export class TeamMemberLogsFinder {
const tLead = performance.now(); const tLead = performance.now();
// ── Collect all subagent file candidates ── // ── Collect all subagent file candidates ──
const candidates: { filePath: string; sessionId: string; fileName: string }[] = []; const candidates = await this.collectSubagentCandidates(projectDir, sessionIds);
for (const sessionId of sessionIds) {
const subagentsDir = path.join(projectDir, sessionId, 'subagents');
let dirFiles: string[];
try {
dirFiles = await fs.readdir(subagentsDir);
} catch {
continue;
}
for (const f of dirFiles) {
if (!f.startsWith('agent-') || !f.endsWith('.jsonl') || f.startsWith('agent-acompact'))
continue;
candidates.push({ filePath: path.join(subagentsDir, f), sessionId, fileName: f });
}
}
// ── Parallel scan with concurrency limit ── // ── Parallel scan with concurrency limit ──
const settled: (MemberLogSummary | null)[] = new Array(candidates.length).fill(null); const settled: (MemberLogSummary | null)[] = new Array(candidates.length).fill(null);
@ -234,8 +225,8 @@ export class TeamMemberLogsFinder {
attribution attribution
); );
if (summary) settled[idx] = summary; if (summary) settled[idx] = summary;
} catch { } catch (err) {
// One file error must not break others logger.warn(`Failed to scan subagent file: ${c.filePath}`, err);
} }
} }
}; };
@ -405,21 +396,7 @@ export class TeamMemberLogsFinder {
const tLead = performance.now(); const tLead = performance.now();
// ── Collect all subagent file candidates ── // ── Collect all subagent file candidates ──
const candidates: { filePath: string; sessionId: string; fileName: string }[] = []; const candidates = await this.collectSubagentCandidates(projectDir, sessionIds);
for (const sessionId of sessionIds) {
const subagentsDir = path.join(projectDir, sessionId, 'subagents');
let dirFiles: string[];
try {
dirFiles = await fs.readdir(subagentsDir);
} catch {
continue;
}
for (const f of dirFiles) {
if (!f.startsWith('agent-') || !f.endsWith('.jsonl') || f.startsWith('agent-acompact'))
continue;
candidates.push({ filePath: path.join(subagentsDir, f), sessionId, fileName: f });
}
}
// ── Parallel scan with concurrency limit ── // ── Parallel scan with concurrency limit ──
let nextIdx = 0; let nextIdx = 0;
@ -440,8 +417,8 @@ export class TeamMemberLogsFinder {
attribution.detectedMember, attribution.detectedMember,
await this.getSortTime(c.filePath, attribution.firstTimestamp) await this.getSortTime(c.filePath, attribution.firstTimestamp)
); );
} catch { } catch (err) {
// One file error must not break others logger.warn(`Failed to scan subagent file: ${c.filePath}`, err);
} }
} }
}; };
@ -762,6 +739,32 @@ export class TeamMemberLogsFinder {
return { ...discovery, isLeadMember }; return { ...discovery, isLeadMember };
} }
/**
* Collect all subagent JSONL file candidates across session directories.
* Filters out non-agent files and compact files (agent-acompact*).
*/
private async collectSubagentCandidates(
projectDir: string,
sessionIds: string[]
): Promise<{ filePath: string; sessionId: string; fileName: string }[]> {
const candidates: { filePath: string; sessionId: string; fileName: string }[] = [];
for (const sessionId of sessionIds) {
const subagentsDir = path.join(projectDir, sessionId, 'subagents');
let dirFiles: string[];
try {
dirFiles = await fs.readdir(subagentsDir);
} catch {
continue;
}
for (const f of dirFiles) {
if (!f.startsWith('agent-') || !f.endsWith('.jsonl') || f.startsWith('agent-acompact'))
continue;
candidates.push({ filePath: path.join(subagentsDir, f), sessionId, fileName: f });
}
}
return candidates;
}
private deriveSinceMs(options?: { private deriveSinceMs(options?: {
intervals?: { startedAt: string; completedAt?: string }[]; intervals?: { startedAt: string; completedAt?: string }[];
since?: string; since?: string;

View file

@ -1018,4 +1018,67 @@ describe('TeamMemberLogsFinder', () => {
expect(deduped).toHaveLength(1); expect(deduped).toHaveLength(1);
expect(deduped[0].memberName.toLowerCase()).toBe('dev'); expect(deduped[0].memberName.toLowerCase()).toBe('dev');
}); });
it('findMemberLogs returns results sorted by startTime descending', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-sort-'));
setClaudeBasePathOverride(tmpDir);
const teamName = 'sort-team';
const projectPath = '/Users/test/sort-proj';
const projectId = '-Users-test-sort-proj';
const sessionId = 'ss1';
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 });
// 3 agent files with different startTimes: 00:03, 00:01, 00:02
for (const [id, ts] of [
['agent-s1.jsonl', '2026-01-01T00:00:03.000Z'],
['agent-s2.jsonl', '2026-01-01T00:00:01.000Z'],
['agent-s3.jsonl', '2026-01-01T00:00:02.000Z'],
] as const) {
await fs.writeFile(
path.join(projectRoot, sessionId, 'subagents', id),
[
JSON.stringify({
timestamp: ts,
type: 'user',
message: {
role: 'user',
content: `You are dev, a developer on team "${teamName}" (${teamName}).`,
},
}),
JSON.stringify({
timestamp: ts,
type: 'assistant',
message: { role: 'assistant', content: [{ type: 'text', text: 'OK' }] },
}),
].join('\n') + '\n',
'utf8'
);
}
const finder = new TeamMemberLogsFinder();
const logs = await finder.findMemberLogs(teamName, 'dev');
expect(logs).toHaveLength(3);
// Must be descending: 00:03, 00:02, 00:01
const times = logs.map((l) => new Date(l.startTime).getTime());
expect(times[0]).toBeGreaterThan(times[1]);
expect(times[1]).toBeGreaterThan(times[2]);
});
}); });