perf(main): reuse team task snapshots for UI reads

This commit is contained in:
777genius 2026-05-31 10:22:11 +03:00
parent cb60fca258
commit dc3001f713
3 changed files with 141 additions and 16 deletions

View file

@ -510,6 +510,15 @@ export class TeamDataService {
TeamTaskReader.invalidateAllTasksCache();
}
private async readTasksForUiSnapshot(teamName: string): Promise<readonly TeamTask[]> {
const snapshotReader = this.taskReader as TeamTaskReader & {
getTasksProjectionSnapshot?: (teamName: string) => Promise<readonly TeamTask[]>;
};
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');

View file

@ -48,12 +48,19 @@ interface CachedTaskFile {
task: TeamTask | null;
}
function cloneTasks<T>(tasks: readonly T[]): T[] {
return structuredClone([...tasks]);
interface CachedTeamTasks {
signaturesByFile: Map<string, TaskFileSignature>;
value: TeamTask[];
}
function cloneTask(task: TeamTask): TeamTask {
return structuredClone(task);
interface ScannedTaskFile {
file: string;
taskPath: string;
signature: TaskFileSignature;
}
function cloneTasks<T>(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<string, CachedTaskFile>();
private static teamTasksCache = new Map<string, CachedTeamTasks>();
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<TeamTask[]> {
const tasks = await this.getTasksProjectionSnapshot(teamName);
return cloneTasks(tasks);
}
async getTasksProjectionSnapshot(teamName: string): Promise<readonly TeamTask[]> {
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 });
}

View file

@ -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);
});
});