diff --git a/src/main/services/team/TeamTaskActivityIntervalService.ts b/src/main/services/team/TeamTaskActivityIntervalService.ts index d36581a9..3d4765c0 100644 --- a/src/main/services/team/TeamTaskActivityIntervalService.ts +++ b/src/main/services/team/TeamTaskActivityIntervalService.ts @@ -18,6 +18,15 @@ interface ActivityIntervalResult { failed?: boolean; } +interface TaskDirectorySignature { + key: string; +} + +interface ResumeMembersCacheEntry { + memberKey: string; + signatureKey: string; +} + type MutableTeamTask = TeamTask & { reviewIntervals?: TaskReviewInterval[]; }; @@ -322,13 +331,15 @@ function writeTaskFile(filePath: string, task: MutableTeamTask): void { } export class TeamTaskActivityIntervalService { - private mutateTeamTasks( + private readonly resumeMembersCache = new Map(); + + private mutateTeamTasksWithLock( teamName: string, - mutate: (task: MutableTeamTask) => boolean + run: () => ActivityIntervalResult ): ActivityIntervalResult { const lockScope = path.join(getTeamsBasePath(), teamName, 'board-state'); try { - return withFileLockSync(lockScope, () => this.mutateTeamTasksUnlocked(teamName, mutate)); + return withFileLockSync(lockScope, run); } catch (error) { logger.warn( `[${teamName}] Failed to update task activity intervals: ${ @@ -339,6 +350,19 @@ export class TeamTaskActivityIntervalService { } } + private mutateTeamTasks( + teamName: string, + mutate: (task: MutableTeamTask) => boolean + ): ActivityIntervalResult { + const result = this.mutateTeamTasksWithLock(teamName, () => + this.mutateTeamTasksUnlocked(teamName, mutate) + ); + if (result.changedTasks > 0 || result.failed) { + this.resumeMembersCache.delete(teamName); + } + return result; + } + private mutateTeamTasksUnlocked( teamName: string, mutate: (task: MutableTeamTask) => boolean @@ -371,6 +395,38 @@ export class TeamTaskActivityIntervalService { return { changedTasks }; } + private readTaskDirectorySignature(teamName: string): TaskDirectorySignature | null { + const tasksDir = path.join(getTasksBasePath(), teamName); + let entries: string[]; + try { + entries = fs + .readdirSync(tasksDir) + .filter((fileName) => fileName.endsWith('.json') && !fileName.startsWith('.')) + .sort(); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return { key: 'missing' }; + } + return null; + } + + const parts: string[] = []; + for (const fileName of entries) { + try { + const stat = fs.statSync(path.join(tasksDir, fileName)); + if (!stat.isFile()) continue; + parts.push([fileName, stat.size, stat.mtimeMs, stat.ctimeMs].join('\0')); + } catch { + return null; + } + } + return { key: parts.join('\0\0') }; + } + + private makeMemberSetKey(memberKeys: ReadonlySet): string { + return [...memberKeys].sort().join('\0'); + } + pauseActiveIntervalsForTeam( teamName: string, at = new Date().toISOString() @@ -450,7 +506,8 @@ export class TeamTaskActivityIntervalService { * file-lock + read of every task file PER member PER cycle. This applies the * identical per-member resume logic against a member set in one locked pass, so * the mutations are exactly the same but the lock + reads happen once per cycle - * instead of once per member. + * instead of once per member. After a no-op pass, a task-file signature skips + * unchanged repeat cycles without parsing every task JSON again. */ resumeActiveIntervalsForMembers( teamName: string, @@ -461,42 +518,74 @@ export class TeamTaskActivityIntervalService { memberNames.map((name) => normalizeMemberName(name)).filter((key): key is string => !!key) ); if (memberKeys.size === 0) return { changedTasks: 0 }; + const memberKey = this.makeMemberSetKey(memberKeys); - return this.mutateTeamTasks(teamName, (task) => { - let changed = false; - + const result = this.mutateTeamTasksWithLock(teamName, () => { + const beforeSignature = this.readTaskDirectorySignature(teamName); + const cached = this.resumeMembersCache.get(teamName); if ( - task.status === 'in_progress' && - memberKeys.has(normalizeMemberName(task.owner)) && - !hasOpenWorkInterval(task) + beforeSignature && + cached?.memberKey === memberKey && + cached.signatureKey === beforeSignature.key ) { - const activeStartedAt = getActiveWorkStartedAt(task); - task.workIntervals = [ - ...(Array.isArray(task.workIntervals) ? task.workIntervals : []), - { startedAt: resumeStartIso(activeStartedAt, at) }, - ]; - changed = true; + return { changedTasks: 0 }; } - const activeReview = getActiveReviewStart(task); - if ( - task.status === 'completed' && - activeReview && - memberKeys.has(normalizeMemberName(activeReview.reviewer)) && - !hasOpenReviewInterval(task, activeReview.reviewer) - ) { - task.reviewIntervals = [ - ...(Array.isArray(task.reviewIntervals) ? task.reviewIntervals : []), - { - reviewer: activeReview.reviewer, - startedAt: resumeStartIso(activeReview.startedAt, at), - }, - ]; - changed = true; - } + const mutationResult = this.mutateTeamTasksUnlocked(teamName, (task) => { + let changed = false; - return changed; + if ( + task.status === 'in_progress' && + memberKeys.has(normalizeMemberName(task.owner)) && + !hasOpenWorkInterval(task) + ) { + const activeStartedAt = getActiveWorkStartedAt(task); + task.workIntervals = [ + ...(Array.isArray(task.workIntervals) ? task.workIntervals : []), + { startedAt: resumeStartIso(activeStartedAt, at) }, + ]; + changed = true; + } + + const activeReview = getActiveReviewStart(task); + if ( + task.status === 'completed' && + activeReview && + memberKeys.has(normalizeMemberName(activeReview.reviewer)) && + !hasOpenReviewInterval(task, activeReview.reviewer) + ) { + task.reviewIntervals = [ + ...(Array.isArray(task.reviewIntervals) ? task.reviewIntervals : []), + { + reviewer: activeReview.reviewer, + startedAt: resumeStartIso(activeReview.startedAt, at), + }, + ]; + changed = true; + } + + return changed; + }); + + const nextSignature = + mutationResult.changedTasks > 0 + ? this.readTaskDirectorySignature(teamName) + : beforeSignature; + if (nextSignature) { + this.resumeMembersCache.set(teamName, { + memberKey, + signatureKey: nextSignature.key, + }); + } else { + this.resumeMembersCache.delete(teamName); + } + return mutationResult; }); + + if (result.failed) { + this.resumeMembersCache.delete(teamName); + } + return result; } repairStaleIntervalsAfterCrash( diff --git a/test/main/services/team/TeamTaskActivityIntervalService.test.ts b/test/main/services/team/TeamTaskActivityIntervalService.test.ts index be0c1141..a14c6eeb 100644 --- a/test/main/services/team/TeamTaskActivityIntervalService.test.ts +++ b/test/main/services/team/TeamTaskActivityIntervalService.test.ts @@ -517,6 +517,84 @@ describe('TeamTaskActivityIntervalService', () => { expect(carolTask.workIntervals).toHaveLength(1); }); + it('skips unchanged batched resume task reads after a no-op pass', async () => { + await writeTask('alpha', { + id: 'bob-task', + subject: 'Bob work', + owner: 'bob', + status: 'in_progress', + workIntervals: [{ startedAt: '2026-05-08T10:00:00.000Z' }], + historyEvents: [], + }); + + const service = new TeamTaskActivityIntervalService(); + expect( + service.resumeActiveIntervalsForMembers( + 'alpha', + ['bob'], + '2026-05-08T10:20:00.000Z' + ).changedTasks + ).toBe(0); + + const jsonParseSpy = vi.spyOn(JSON, 'parse'); + const secondResult = service.resumeActiveIntervalsForMembers( + 'alpha', + ['bob'], + '2026-05-08T10:25:00.000Z' + ); + + expect(secondResult.changedTasks).toBe(0); + expect(jsonParseSpy).not.toHaveBeenCalled(); + }); + + it('refreshes batched resume cache when a task file changes', async () => { + await writeTask('alpha', { + id: 'bob-task', + subject: 'Bob work', + owner: 'bob', + status: 'in_progress', + workIntervals: [{ startedAt: '2026-05-08T10:00:00.000Z' }], + historyEvents: [], + }); + + const service = new TeamTaskActivityIntervalService(); + expect( + service.resumeActiveIntervalsForMembers( + 'alpha', + ['bob'], + '2026-05-08T10:20:00.000Z' + ).changedTasks + ).toBe(0); + + await writeTask('alpha', { + id: 'bob-task', + subject: 'Bob work', + owner: 'bob', + status: 'in_progress', + workIntervals: [ + { + startedAt: '2026-05-08T10:00:00.000Z', + completedAt: '2026-05-08T10:05:00.000Z', + }, + ], + historyEvents: [], + signaturePadding: 'changed-file-signature', + }); + + const result = service.resumeActiveIntervalsForMembers( + 'alpha', + ['bob'], + '2026-05-08T10:30:00.000Z' + ); + const task = await readTask('alpha', 'bob-task'); + + expect(result.changedTasks).toBe(1); + expect(task.workIntervals).toEqual([ + { startedAt: '2026-05-08T10:00:00.000Z', completedAt: '2026-05-08T10:05:00.000Z' }, + { startedAt: '2026-05-08T10:30:00.000Z' }, + ]); + }); + it('reopens and closes lead work intervals across activity changes', async () => { await writeTask('alpha', { id: 'lead-task',