diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index ffc1aa7a..bf0a36be 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -510,6 +510,15 @@ export class TeamDataService { TeamTaskReader.invalidateAllTasksCache(); } + private async readTasksForUiSnapshot(teamName: string): Promise { + const snapshotReader = this.taskReader as TeamTaskReader & { + getTasksProjectionSnapshot?: (teamName: string) => Promise; + }; + return typeof snapshotReader.getTasksProjectionSnapshot === 'function' + ? snapshotReader.getTasksProjectionSnapshot(teamName) + : this.taskReader.getTasks(teamName); + } + private getController(teamName: string): AgentTeamsController { return this.controllerFactory(teamName); } @@ -1013,7 +1022,7 @@ export class TeamDataService { : null; const [tasks, kanbanState, presenceIndex] = await Promise.all([ - this.taskReader.getTasks(teamName).catch(() => [] as TeamTask[]), + this.readTasksForUiSnapshot(teamName).catch(() => [] as readonly TeamTask[]), this.kanbanManager .getState(teamName) .catch(() => ({ teamName, reviewers: [], tasks: {} }) as KanbanState), @@ -1387,7 +1396,7 @@ export class TeamDataService { label: 'tasks', createFallback: () => [], warningText: 'Tasks failed to load', - load: () => this.taskReader.getTasks(teamName), + load: () => this.readTasksForUiSnapshot(teamName), }) ); const [ @@ -1424,7 +1433,7 @@ export class TeamDataService { if (launchStateStepResult.warning) warnings.push(launchStateStepResult.warning); if (kanbanStateStepResult.warning) warnings.push(kanbanStateStepResult.warning); - const tasks: TeamTask[] = tasksStepResult.value; + const tasks: readonly TeamTask[] = tasksStepResult.value; const inboxNames: string[] = inboxNamesStepResult.value; mark('postStart'); diff --git a/src/main/services/team/TeamTaskReader.ts b/src/main/services/team/TeamTaskReader.ts index df565c57..aa8c91aa 100644 --- a/src/main/services/team/TeamTaskReader.ts +++ b/src/main/services/team/TeamTaskReader.ts @@ -48,12 +48,19 @@ interface CachedTaskFile { task: TeamTask | null; } -function cloneTasks(tasks: readonly T[]): T[] { - return structuredClone([...tasks]); +interface CachedTeamTasks { + signaturesByFile: Map; + value: TeamTask[]; } -function cloneTask(task: TeamTask): TeamTask { - return structuredClone(task); +interface ScannedTaskFile { + file: string; + taskPath: string; + signature: TaskFileSignature; +} + +function cloneTasks(tasks: readonly T[]): T[] { + return structuredClone([...tasks]); } function buildTaskFileSignature(stat: fs.Stats): TaskFileSignature { @@ -121,14 +128,16 @@ export class TeamTaskReader { private static allTasksInFlight: InFlightAllTasks | null = null; private static allTasksGeneration = 0; private static taskFileCache = new Map(); + private static teamTasksCache = new Map(); static invalidateAllTasksCache(): void { TeamTaskReader.allTasksCache = null; TeamTaskReader.taskFileCache.clear(); + TeamTaskReader.teamTasksCache.clear(); TeamTaskReader.allTasksGeneration += 1; } - private static getCachedTaskFile( + private static getCachedTaskFileSnapshot( taskPath: string, signature: TaskFileSignature ): TeamTask | null | undefined { @@ -140,7 +149,7 @@ export class TeamTaskReader { TeamTaskReader.taskFileCache.delete(taskPath); return undefined; } - return cached.task ? cloneTask(cached.task) : null; + return cached.task; } private static setCachedTaskFile( @@ -159,7 +168,37 @@ export class TeamTaskReader { } TeamTaskReader.taskFileCache.set(taskPath, { signature, - task: task ? cloneTask(task) : null, + task, + }); + } + + private static getCachedTeamTasks( + teamName: string, + scannedFiles: readonly ScannedTaskFile[] + ): readonly TeamTask[] | null { + const cached = TeamTaskReader.teamTasksCache.get(teamName); + if (!cached || cached.signaturesByFile.size !== scannedFiles.length) { + return null; + } + + for (const file of scannedFiles) { + const cachedSignature = cached.signaturesByFile.get(file.file); + if (!cachedSignature || !taskFileSignaturesEqual(cachedSignature, file.signature)) { + return null; + } + } + + return cached.value; + } + + private static setCachedTeamTasks( + teamName: string, + scannedFiles: readonly ScannedTaskFile[], + tasks: TeamTask[] + ): void { + TeamTaskReader.teamTasksCache.set(teamName, { + signaturesByFile: new Map(scannedFiles.map((file) => [file.file, file.signature] as const)), + value: tasks, }); } @@ -193,6 +232,11 @@ export class TeamTaskReader { } async getTasks(teamName: string): Promise { + const tasks = await this.getTasksProjectionSnapshot(teamName); + return cloneTasks(tasks); + } + + async getTasksProjectionSnapshot(teamName: string): Promise { const tasksDir = path.join(getTasksBasePath(), teamName); let entries: string[]; @@ -205,8 +249,8 @@ export class TeamTaskReader { throw error; } - const tasks: TeamTask[] = []; - let processed = 0; + const scannedFiles: ScannedTaskFile[] = []; + let canCacheTeamSnapshot = true; for (const file of entries) { if ( !file.endsWith('.json') || @@ -223,10 +267,34 @@ export class TeamTaskReader { if (!fileStat.isFile() || fileStat.size > MAX_TASK_FILE_BYTES) { logger.debug(`Skipping suspicious task file: ${taskPath}`); TeamTaskReader.taskFileCache.delete(taskPath); + TeamTaskReader.teamTasksCache.delete(teamName); + canCacheTeamSnapshot = false; continue; } - const signature = buildTaskFileSignature(fileStat); - const cachedTask = TeamTaskReader.getCachedTaskFile(taskPath, signature); + scannedFiles.push({ + file, + taskPath, + signature: buildTaskFileSignature(fileStat), + }); + } catch { + TeamTaskReader.taskFileCache.delete(taskPath); + TeamTaskReader.teamTasksCache.delete(teamName); + canCacheTeamSnapshot = false; + logger.debug(`Skipping invalid task file: ${taskPath}`); + } + } + + const cachedTeamTasks = TeamTaskReader.getCachedTeamTasks(teamName, scannedFiles); + if (cachedTeamTasks) { + return cachedTeamTasks; + } + + const tasks: TeamTask[] = []; + let processed = 0; + for (const scannedFile of scannedFiles) { + const { taskPath, signature } = scannedFile; + try { + const cachedTask = TeamTaskReader.getCachedTaskFileSnapshot(taskPath, signature); if (cachedTask !== undefined) { if (cachedTask) { tasks.push(cachedTask); @@ -245,7 +313,7 @@ export class TeamTaskReader { const createdAt = typeof parsed.createdAt === 'string' ? parsed.createdAt : undefined; let updatedAt: string | undefined; try { - updatedAt = fileStat.mtime.toISOString(); + updatedAt = new Date(signature.mtimeMs).toISOString(); } catch { /* leave undefined */ } @@ -453,6 +521,8 @@ export class TeamTaskReader { tasks.push(task); } catch { TeamTaskReader.taskFileCache.delete(taskPath); + TeamTaskReader.teamTasksCache.delete(teamName); + canCacheTeamSnapshot = false; logger.debug(`Skipping invalid task file: ${taskPath}`); } processed++; @@ -478,6 +548,10 @@ export class TeamTaskReader { return a.id.localeCompare(b.id, undefined, { numeric: true, sensitivity: 'base' }); }); + if (canCacheTeamSnapshot) { + TeamTaskReader.setCachedTeamTasks(teamName, scannedFiles, tasks); + } + return tasks; } @@ -667,7 +741,7 @@ export class TeamTaskReader { for (const entry of entries) { if (!entry.isDirectory()) continue; try { - const tasks = await this.getTasks(entry.name); + const tasks = await this.getTasksProjectionSnapshot(entry.name); for (const task of tasks) { result.push({ ...task, teamName: entry.name }); } diff --git a/test/main/services/team/TeamTaskReader.test.ts b/test/main/services/team/TeamTaskReader.test.ts index 560ba8f2..67e3e31a 100644 --- a/test/main/services/team/TeamTaskReader.test.ts +++ b/test/main/services/team/TeamTaskReader.test.ts @@ -142,4 +142,46 @@ describe('TeamTaskReader', () => { ]); expect(readFileSpy).toHaveBeenCalledTimes(2); }); + + it('reuses read-only team task projection snapshots until a file signature changes', async () => { + await setupTasksRoot(); + const taskPath = await writeTaskFile('atlas-hq', { + id: '1', + subject: 'Projection cached task', + status: 'pending', + createdAt: '2026-05-02T12:00:00.000Z', + }); + + const readFileSpy = vi.spyOn(fs.promises, 'readFile'); + const reader = new TeamTaskReader(); + + const firstRead = await reader.getTasksProjectionSnapshot('atlas-hq'); + const secondRead = await reader.getTasksProjectionSnapshot('atlas-hq'); + + expect(secondRead).toBe(firstRead); + expect(secondRead).toMatchObject([{ id: '1', subject: 'Projection cached task' }]); + expect(readFileSpy).toHaveBeenCalledTimes(1); + + await fsp.writeFile( + taskPath, + JSON.stringify( + { + id: '1', + subject: 'Projection changed task', + status: 'pending', + createdAt: '2026-05-02T12:00:00.000Z', + }, + null, + 2 + ), + 'utf8' + ); + const changedTime = new Date(Date.now() + 2_000); + await fsp.utimes(taskPath, changedTime, changedTime); + + const thirdRead = await reader.getTasksProjectionSnapshot('atlas-hq'); + expect(thirdRead).not.toBe(firstRead); + expect(thirdRead).toMatchObject([{ id: '1', subject: 'Projection changed task' }]); + expect(readFileSpy).toHaveBeenCalledTimes(2); + }); });