diff --git a/src/main/services/team/TeamTaskActivityIntervalService.ts b/src/main/services/team/TeamTaskActivityIntervalService.ts index 68057def..e51415cd 100644 --- a/src/main/services/team/TeamTaskActivityIntervalService.ts +++ b/src/main/services/team/TeamTaskActivityIntervalService.ts @@ -27,6 +27,8 @@ interface ResumeMembersCacheEntry { signatureKey: string; } +type MemberActivityNoopOperation = 'pause-member' | 'resume-member'; + type MutableTeamTask = TeamTask & { reviewIntervals?: TaskReviewInterval[]; }; @@ -332,11 +334,34 @@ function writeTaskFile(filePath: string, task: MutableTeamTask): void { export class TeamTaskActivityIntervalService { private readonly resumeMembersCache = new Map(); + private readonly memberActivityNoopCache = new Map(); private getBoardStateLockPath(teamName: string): string { return `${path.join(getTeamsBasePath(), teamName, 'board-state')}.lock`; } + private getMemberActivityNoopCacheKey( + teamName: string, + operation: MemberActivityNoopOperation, + memberKey: string + ): string { + return `${teamName}\u0000${operation}\u0000${memberKey}`; + } + + private clearMemberActivityNoopCacheForTeam(teamName: string): void { + const prefix = `${teamName}\u0000`; + for (const key of this.memberActivityNoopCache.keys()) { + if (key.startsWith(prefix)) { + this.memberActivityNoopCache.delete(key); + } + } + } + + private clearActivityNoopCachesForTeam(teamName: string): void { + this.clearMemberActivityNoopCacheForTeam(teamName); + this.resumeMembersCache.delete(teamName); + } + private mutateTeamTasksWithLock( teamName: string, run: () => ActivityIntervalResult @@ -362,7 +387,53 @@ export class TeamTaskActivityIntervalService { this.mutateTeamTasksUnlocked(teamName, mutate) ); if (result.changedTasks > 0 || result.failed) { - this.resumeMembersCache.delete(teamName); + this.clearActivityNoopCachesForTeam(teamName); + } + return result; + } + + private mutateMemberTasksWithNoopCache( + teamName: string, + operation: MemberActivityNoopOperation, + memberKey: string, + mutate: (task: MutableTeamTask) => boolean + ): ActivityIntervalResult { + const cacheKey = this.getMemberActivityNoopCacheKey(teamName, operation, memberKey); + const cachedSignatureKey = this.memberActivityNoopCache.get(cacheKey); + if (cachedSignatureKey) { + const beforeLockSignature = this.readTaskDirectorySignature(teamName); + if ( + beforeLockSignature && + beforeLockSignature.key === cachedSignatureKey && + !fs.existsSync(this.getBoardStateLockPath(teamName)) + ) { + return { changedTasks: 0 }; + } + } + + const result = this.mutateTeamTasksWithLock(teamName, () => { + const beforeSignature = this.readTaskDirectorySignature(teamName); + if (beforeSignature && this.memberActivityNoopCache.get(cacheKey) === beforeSignature.key) { + return { changedTasks: 0 }; + } + + const mutationResult = this.mutateTeamTasksUnlocked(teamName, mutate); + if (mutationResult.changedTasks > 0) { + this.clearActivityNoopCachesForTeam(teamName); + return mutationResult; + } + + const nextSignature = beforeSignature ?? this.readTaskDirectorySignature(teamName); + if (nextSignature) { + this.memberActivityNoopCache.set(cacheKey, nextSignature.key); + } else { + this.memberActivityNoopCache.delete(cacheKey); + } + return mutationResult; + }); + + if (result.changedTasks > 0 || result.failed) { + this.clearActivityNoopCachesForTeam(teamName); } return result; } @@ -449,13 +520,19 @@ export class TeamTaskActivityIntervalService { memberName: string, at = new Date().toISOString() ): ActivityIntervalResult { - return this.mutateTeamTasks(teamName, (task) => { + const memberKey = normalizeMemberName(memberName); + const mutate = (task: MutableTeamTask): boolean => { const changedWork = closeOpenWorkIntervals(task, at, memberName); const changedReview = closeOpenReviewIntervals(task, at, memberName); const materializedWork = materializePausedWorkInterval(task, at, memberName); const materializedReview = materializePausedReviewInterval(task, at, memberName); return changedWork || changedReview || materializedWork || materializedReview; - }); + }; + + if (!memberKey) { + return this.mutateTeamTasks(teamName, mutate); + } + return this.mutateMemberTasksWithNoopCache(teamName, 'pause-member', memberKey, mutate); } resumeActiveIntervalsForMember( @@ -466,7 +543,7 @@ export class TeamTaskActivityIntervalService { const memberKey = normalizeMemberName(memberName); if (!memberKey) return { changedTasks: 0 }; - return this.mutateTeamTasks(teamName, (task) => { + return this.mutateMemberTasksWithNoopCache(teamName, 'resume-member', memberKey, (task) => { let changed = false; if ( @@ -599,7 +676,9 @@ export class TeamTaskActivityIntervalService { }); if (result.failed) { - this.resumeMembersCache.delete(teamName); + this.clearActivityNoopCachesForTeam(teamName); + } else if (result.changedTasks > 0) { + this.clearMemberActivityNoopCacheForTeam(teamName); } return result; } diff --git a/test/main/services/team/TeamTaskActivityIntervalService.test.ts b/test/main/services/team/TeamTaskActivityIntervalService.test.ts index 64ea5f34..c5fccfef 100644 --- a/test/main/services/team/TeamTaskActivityIntervalService.test.ts +++ b/test/main/services/team/TeamTaskActivityIntervalService.test.ts @@ -585,6 +585,130 @@ describe('TeamTaskActivityIntervalService', () => { expect(mutateWithLockSpy).not.toHaveBeenCalled(); }); + it('skips the task lock after an unchanged single-member resume 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.resumeActiveIntervalsForMember( + 'alpha', + 'bob', + '2026-05-08T10:20:00.000Z' + ).changedTasks + ).toBe(0); + + const mutateWithLockSpy = vi.spyOn( + TeamTaskActivityIntervalService.prototype as unknown as { + mutateTeamTasksWithLock: ( + teamName: string, + run: () => { changedTasks: number; failed?: boolean } + ) => { changedTasks: number; failed?: boolean }; + }, + 'mutateTeamTasksWithLock' + ); + const secondResult = service.resumeActiveIntervalsForMember( + 'alpha', + 'bob', + '2026-05-08T10:25:00.000Z' + ); + + expect(secondResult.changedTasks).toBe(0); + expect(mutateWithLockSpy).not.toHaveBeenCalled(); + }); + + it('refreshes single-member resume no-op 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.resumeActiveIntervalsForMember( + '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.resumeActiveIntervalsForMember( + '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('skips the task lock after an unchanged single-member pause no-op pass', async () => { + await writeTask('alpha', { + id: 'alice-task', + subject: 'Alice work', + owner: 'alice', + status: 'in_progress', + workIntervals: [{ startedAt: '2026-05-08T10:00:00.000Z' }], + historyEvents: [], + }); + + const service = new TeamTaskActivityIntervalService(); + expect( + service.pauseActiveIntervalsForMember( + 'alpha', + 'bob', + '2026-05-08T10:20:00.000Z' + ).changedTasks + ).toBe(0); + + const mutateWithLockSpy = vi.spyOn( + TeamTaskActivityIntervalService.prototype as unknown as { + mutateTeamTasksWithLock: ( + teamName: string, + run: () => { changedTasks: number; failed?: boolean } + ) => { changedTasks: number; failed?: boolean }; + }, + 'mutateTeamTasksWithLock' + ); + const secondResult = service.pauseActiveIntervalsForMember( + 'alpha', + 'bob', + '2026-05-08T10:25:00.000Z' + ); + + expect(secondResult.changedTasks).toBe(0); + expect(mutateWithLockSpy).not.toHaveBeenCalled(); + }); + it('refreshes batched resume cache when a task file changes', async () => { await writeTask('alpha', { id: 'bob-task',