diff --git a/src/main/services/team/TeamTranscriptProjectResolver.ts b/src/main/services/team/TeamTranscriptProjectResolver.ts index f0f2451b..8064e3b7 100644 --- a/src/main/services/team/TeamTranscriptProjectResolver.ts +++ b/src/main/services/team/TeamTranscriptProjectResolver.ts @@ -966,17 +966,19 @@ export class TeamTranscriptProjectResolver { while (nextIndex < rootJsonlEntries.length) { const entry = rootJsonlEntries[nextIndex++]; const filePath = path.join(projectDir, entry.name); + let precomputedStat: { mtimeMs: number; size: number; isFile: () => boolean } | undefined; if (mtimeSinceMs != null) { try { const stat = await fs.stat(filePath); if (!stat.isFile() || stat.mtimeMs < mtimeSinceMs) { continue; } + precomputedStat = stat; } catch { continue; } } - if (!(await this.fileBelongsToTeam(filePath, teamName))) { + if (!(await this.fileBelongsToTeam(filePath, teamName, precomputedStat))) { continue; } discovered.add(entry.name.slice(0, -'.jsonl'.length)); @@ -1008,17 +1010,29 @@ export class TeamTranscriptProjectResolver { return this.collectRootJsonlSessionIds(rootJsonlEntries, projectDir, teamName, mtimeSinceMs); } - private async fileBelongsToTeam(filePath: string, teamName: string): Promise { + private async fileBelongsToTeam( + filePath: string, + teamName: string, + precomputedStat?: { mtimeMs: number; size: number; isFile: () => boolean } + ): Promise { const normalizedTeam = teamName.trim().toLowerCase(); if (!normalizedTeam) { return false; } + // Reuse the caller's stat when it already statted this exact file (the mtime-window + // filter in collectRootJsonlSessionIds does). On the live resolution path this drops + // a second fs.stat of the same file per entry, every poll — and using a single stat + // snapshot is also more consistent than two reads that could straddle a write. let fileStat: { mtimeMs: number; size: number; isFile: () => boolean }; - try { - fileStat = await fs.stat(filePath); - } catch { - return false; + if (precomputedStat) { + fileStat = precomputedStat; + } else { + try { + fileStat = await fs.stat(filePath); + } catch { + return false; + } } if (!fileStat.isFile()) { diff --git a/test/main/services/team/TeamTranscriptProjectResolver.test.ts b/test/main/services/team/TeamTranscriptProjectResolver.test.ts index 696c7911..c3b3fd23 100644 --- a/test/main/services/team/TeamTranscriptProjectResolver.test.ts +++ b/test/main/services/team/TeamTranscriptProjectResolver.test.ts @@ -637,7 +637,11 @@ describe('TeamTranscriptProjectResolver', () => { headWindowFull: boolean; }; type ResolverProbe = { - fileBelongsToTeam: (filePath: string, teamName: string) => Promise; + fileBelongsToTeam: ( + filePath: string, + teamName: string, + precomputedStat?: { mtimeMs: number; size: number; isFile: () => boolean } + ) => Promise; buildTeamAffinityFileCacheKey: (filePath: string, normalizedTeam: string) => string; teamAffinityFileCache: Map; }; @@ -716,4 +720,33 @@ describe('TeamTranscriptProjectResolver', () => { expect(second?.belongsToTeam).toBe(true); expect(second!.size).toBeGreaterThan(sizeAfterFirst); // re-scanned + re-cached }); + + // Regression: when the caller already statted the file (the mtime-window filter in + // collectRootJsonlSessionIds), fileBelongsToTeam must reuse that stat rather than + // issuing a second fs.stat of the same file. Proven without mocking fs: a precomputed + // stat with a deliberately distinct size/mtime must be the one recorded in the cache. + it('reuses a caller-supplied stat instead of re-statting the file', async () => { + await setupClaudeRoot(); + const resolver = new TeamTranscriptProjectResolver() as unknown as ResolverProbe; + const team = 'absent-team'; + const projectDir = path.join(tmpDir!, 'projects', encodePath('/repo/precomp')); + await fs.mkdir(projectDir, { recursive: true }); + const jsonlPath = path.join(projectDir, 'f.jsonl'); + await fs.writeFile( + jsonlPath, + `${Array.from({ length: 45 }, (_, i) => + JSON.stringify({ type: 'user', message: { role: 'user', content: `x ${i}` } }) + ).join('\n')}\n`, + 'utf8' + ); + + // Distinct sentinel values the real file does not have. + const precomputedStat = { mtimeMs: 123_456, size: 999_999, isFile: () => true }; + expect(await resolver.fileBelongsToTeam(jsonlPath, team, precomputedStat)).toBe(false); + + const key = resolver.buildTeamAffinityFileCacheKey(jsonlPath, team); + const entry = resolver.teamAffinityFileCache.get(key); + expect(entry?.size).toBe(999_999); // cache recorded the precomputed stat -> no re-stat + expect(entry?.mtimeMs).toBe(123_456); + }); });