From 127d31ba8888d1be61b02cec02bd64ce7c2af12c Mon Sep 17 00:00:00 2001 From: 777genius Date: Sat, 30 May 2026 15:51:15 +0300 Subject: [PATCH] perf: cache unchanged team task reads --- src/main/services/team/TeamTaskReader.ts | 88 +++++++++++++++++++ .../main/services/team/TeamTaskReader.test.ts | 69 ++++++++++++++- 2 files changed, 155 insertions(+), 2 deletions(-) diff --git a/src/main/services/team/TeamTaskReader.ts b/src/main/services/team/TeamTaskReader.ts index 3edf2ca5..92be5402 100644 --- a/src/main/services/team/TeamTaskReader.ts +++ b/src/main/services/team/TeamTaskReader.ts @@ -23,6 +23,7 @@ import type { const logger = createLogger('Service:TeamTaskReader'); const MAX_TASK_FILE_BYTES = 2 * 1024 * 1024; const ALL_TASKS_CACHE_TTL_MS = 5_000; +const TASK_FILE_CACHE_MAX_ENTRIES = 8_192; interface CachedAllTasks { value: (TeamTask & { teamName: string })[]; @@ -34,10 +35,47 @@ interface InFlightAllTasks { generationAtStart: number; } +interface TaskFileSignature { + size: number; + mtimeMs: number; + ctimeMs: number; + dev: number; + ino: number; +} + +interface CachedTaskFile { + signature: TaskFileSignature; + task: TeamTask | null; +} + function cloneTasks(tasks: T[]): T[] { return structuredClone(tasks); } +function cloneTask(task: TeamTask): TeamTask { + return structuredClone(task); +} + +function buildTaskFileSignature(stat: fs.Stats): TaskFileSignature { + return { + size: stat.size, + mtimeMs: stat.mtimeMs, + ctimeMs: stat.ctimeMs, + dev: stat.dev, + ino: stat.ino, + }; +} + +function taskFileSignaturesEqual(a: TaskFileSignature, b: TaskFileSignature): boolean { + return ( + a.size === b.size && + a.mtimeMs === b.mtimeMs && + a.ctimeMs === b.ctimeMs && + a.dev === b.dev && + a.ino === b.ino + ); +} + /** * Normalise escaped newline sequences (`\\n`) that some MCP/CLI sources * write as literal two-character strings instead of real line-breaks. @@ -82,12 +120,49 @@ export class TeamTaskReader { private static allTasksCache: CachedAllTasks | null = null; private static allTasksInFlight: InFlightAllTasks | null = null; private static allTasksGeneration = 0; + private static taskFileCache = new Map(); static invalidateAllTasksCache(): void { TeamTaskReader.allTasksCache = null; + TeamTaskReader.taskFileCache.clear(); TeamTaskReader.allTasksGeneration += 1; } + private static getCachedTaskFile( + taskPath: string, + signature: TaskFileSignature + ): TeamTask | null | undefined { + const cached = TeamTaskReader.taskFileCache.get(taskPath); + if (!cached) { + return undefined; + } + if (!taskFileSignaturesEqual(cached.signature, signature)) { + TeamTaskReader.taskFileCache.delete(taskPath); + return undefined; + } + return cached.task ? cloneTask(cached.task) : null; + } + + private static setCachedTaskFile( + taskPath: string, + signature: TaskFileSignature, + task: TeamTask | null + ): void { + if ( + !TeamTaskReader.taskFileCache.has(taskPath) && + TeamTaskReader.taskFileCache.size >= TASK_FILE_CACHE_MAX_ENTRIES + ) { + const oldestKey = TeamTaskReader.taskFileCache.keys().next().value; + if (oldestKey) { + TeamTaskReader.taskFileCache.delete(oldestKey); + } + } + TeamTaskReader.taskFileCache.set(taskPath, { + signature, + task: task ? cloneTask(task) : null, + }); + } + /** * Returns the next available numeric task ID by scanning ALL task files * (including _internal ones) to avoid ID collisions. @@ -147,6 +222,15 @@ export class TeamTaskReader { const fileStat = await fs.promises.stat(taskPath); if (!fileStat.isFile() || fileStat.size > MAX_TASK_FILE_BYTES) { logger.debug(`Skipping suspicious task file: ${taskPath}`); + TeamTaskReader.taskFileCache.delete(taskPath); + continue; + } + const signature = buildTaskFileSignature(fileStat); + const cachedTask = TeamTaskReader.getCachedTaskFile(taskPath, signature); + if (cachedTask !== undefined) { + if (cachedTask) { + tasks.push(cachedTask); + } continue; } const raw = await readFileUtf8WithTimeout(taskPath, 5_000); @@ -154,6 +238,7 @@ export class TeamTaskReader { // Skip internal CLI tracking entries (spawned subagent bookkeeping) const metadata = parsed.metadata as Record | undefined; if (metadata?._internal === true) { + TeamTaskReader.setCachedTaskFile(taskPath, signature, null); continue; } const subject = typeof parsed.subject === 'string' ? parsed.subject : ''; @@ -361,10 +446,13 @@ export class TeamTaskReader { : undefined, } satisfies Record; if (task.status === 'deleted') { + TeamTaskReader.setCachedTaskFile(taskPath, signature, null); continue; } + TeamTaskReader.setCachedTaskFile(taskPath, signature, task); tasks.push(task); } catch { + TeamTaskReader.taskFileCache.delete(taskPath); logger.debug(`Skipping invalid task file: ${taskPath}`); } processed++; diff --git a/test/main/services/team/TeamTaskReader.test.ts b/test/main/services/team/TeamTaskReader.test.ts index 40426231..27ba1e1a 100644 --- a/test/main/services/team/TeamTaskReader.test.ts +++ b/test/main/services/team/TeamTaskReader.test.ts @@ -1,6 +1,11 @@ +import * as fs from 'fs'; +import * as fsp from 'fs/promises'; +import * as os from 'os'; +import * as path from 'path'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { TeamTaskReader } from '../../../../src/main/services/team/TeamTaskReader'; +import { setClaudeBasePathOverride } from '../../../../src/main/utils/pathDecoder'; import type { TeamTask } from '../../../../src/shared/types/team'; @@ -27,16 +32,43 @@ function makeTask(id: string): TeamTask & { teamName: string } { } describe('TeamTaskReader', () => { - afterEach(() => { + let tmpDir: string | null = null; + + afterEach(async () => { vi.restoreAllMocks(); TeamTaskReader.invalidateAllTasksCache(); + setClaudeBasePathOverride(null); + if (tmpDir) { + await fsp.rm(tmpDir, { recursive: true, force: true }); + tmpDir = null; + } }); + async function setupTasksRoot(): Promise { + tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'team-task-reader-')); + setClaudeBasePathOverride(tmpDir); + await fsp.mkdir(path.join(tmpDir, 'tasks'), { recursive: true }); + return tmpDir; + } + + async function writeTaskFile(teamName: string, task: Record): Promise { + const tasksDir = path.join(tmpDir!, 'tasks', teamName); + await fsp.mkdir(tasksDir, { recursive: true }); + const taskPath = path.join(tasksDir, `${String(task.id)}.json`); + await fsp.writeFile(taskPath, JSON.stringify(task, null, 2), 'utf8'); + return taskPath; + } + it('does not reuse or cache a stale in-flight getAllTasks scan after invalidation', async () => { const firstRead = createDeferred<(TeamTask & { teamName: string })[]>(); const secondRead = createDeferred<(TeamTask & { teamName: string })[]>(); const readAllTasksUncached = vi - .spyOn(TeamTaskReader.prototype as unknown as { readAllTasksUncached: () => Promise<(TeamTask & { teamName: string })[]> }, 'readAllTasksUncached') + .spyOn( + TeamTaskReader.prototype as unknown as { + readAllTasksUncached: () => Promise<(TeamTask & { teamName: string })[]>; + }, + 'readAllTasksUncached' + ) .mockImplementationOnce(() => firstRead.promise) .mockImplementationOnce(() => secondRead.promise); @@ -59,4 +91,37 @@ describe('TeamTaskReader', () => { await expect(reader.getAllTasks()).resolves.toEqual([makeTask('fresh-task')]); expect(readAllTasksUncached).toHaveBeenCalledTimes(2); }); + + it('reuses parsed task files until their file signature changes', async () => { + await setupTasksRoot(); + await writeTaskFile('atlas-hq', { + id: '1', + subject: '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.getTasks('atlas-hq'); + expect(firstRead).toMatchObject([{ id: '1', subject: 'Cached task' }]); + firstRead[0]!.subject = 'Mutated caller copy'; + await expect(reader.getTasks('atlas-hq')).resolves.toMatchObject([ + { id: '1', subject: 'Cached task' }, + ]); + expect(readFileSpy).toHaveBeenCalledTimes(1); + + await writeTaskFile('atlas-hq', { + id: '1', + subject: 'Changed cached task', + status: 'pending', + createdAt: '2026-05-02T12:00:00.000Z', + }); + + await expect(reader.getTasks('atlas-hq')).resolves.toMatchObject([ + { id: '1', subject: 'Changed cached task' }, + ]); + expect(readFileSpy).toHaveBeenCalledTimes(2); + }); });