From d9479b5c61647b1040b3f71dde7dc47426b2c338 Mon Sep 17 00:00:00 2001 From: 777genius Date: Fri, 29 May 2026 00:14:40 +0300 Subject: [PATCH] perf(team): skip redundant task interval resumes for stable alive members MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit readPersistedStatuses каждый раз делал полный sync-scan всех task JSON под file lock и звал resumeActiveIntervalsForMember для каждого member с runtimeAlive=true — на больших командах блокировал main до 8s. Теперь маркируем member как 'resume applied' пока он остаётся alive, сбрасываем маркер при переходе в not-alive (через syncMemberTaskActivityForRuntimeTransition и в readPersistedStatuses loop). Resume остаётся идемпотентным и материализует интервалы из истории один раз за цикл alive. --- .../services/team/TeamProvisioningService.ts | 55 +++++++++-- .../team/TeamProvisioningService.test.ts | 92 +++++++++++++++++++ 2 files changed, 141 insertions(+), 6 deletions(-) diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 939de21c..5dbfec0a 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -3549,6 +3549,7 @@ export class TeamProvisioningService { private readonly transcriptProjectResolver: TeamTranscriptProjectResolver; private readonly taskActivityIntervalService = new TeamTaskActivityIntervalService(); private readonly leadTaskActivitySyncedRunKeys = new Set(); + private readonly memberTaskActivityResumeAppliedByTeam = new Map>(); private readonly crashRepairedActivityIntervalsByTeam = new Set(); private readonly pendingCrashRepairSnapshotByTeam = new Map< string, @@ -3782,6 +3783,49 @@ export class TeamProvisioningService { this.runtimeProcessRowsForUsageSnapshotByTeam.delete(teamName); } + private normalizeMemberKeyForTaskActivity(memberName: string): string { + return memberName.trim().toLowerCase(); + } + + private getMemberTaskActivityResumeAppliedSet(teamName: string): Set { + let applied = this.memberTaskActivityResumeAppliedByTeam.get(teamName); + if (!applied) { + applied = new Set(); + this.memberTaskActivityResumeAppliedByTeam.set(teamName, applied); + } + return applied; + } + + private resumeTaskActivityIntervalsForAliveMember( + teamName: string, + memberName: string, + at: string + ): void { + const memberKey = this.normalizeMemberKeyForTaskActivity(memberName); + if (!memberKey) return; + const applied = this.getMemberTaskActivityResumeAppliedSet(teamName); + if (applied.has(memberKey)) return; + const result = this.taskActivityIntervalService.resumeActiveIntervalsForMember( + teamName, + memberName, + at + ); + if (!result.failed) { + applied.add(memberKey); + } + } + + private dropTaskActivityResumeMarkerForMember(teamName: string, memberName: string): void { + const memberKey = this.normalizeMemberKeyForTaskActivity(memberName); + if (!memberKey) return; + const applied = this.memberTaskActivityResumeAppliedByTeam.get(teamName); + if (!applied) return; + applied.delete(memberKey); + if (applied.size === 0) { + this.memberTaskActivityResumeAppliedByTeam.delete(teamName); + } + } + private cloneMemberSpawnStatusesSnapshot( snapshot: MemberSpawnStatusesSnapshot ): MemberSpawnStatusesSnapshot { @@ -13587,6 +13631,7 @@ export class TeamProvisioningService { ): void { if (previous.runtimeAlive === true && next.runtimeAlive !== true) { this.pauseMemberTaskActivityForRuntimeLoss(run, memberName, previous, observedAt); + this.dropTaskActivityResumeMarkerForMember(run.teamName, memberName); } else if (previous.runtimeAlive !== true && next.runtimeAlive === true) { const nextUpdatedMs = parseOptionalIsoMs(next.updatedAt); const previousUpdatedMs = parseOptionalIsoMs(previous.updatedAt); @@ -13594,7 +13639,7 @@ export class TeamProvisioningService { nextUpdatedMs > 0 && (previousUpdatedMs <= 0 || nextUpdatedMs > previousUpdatedMs) ? next.updatedAt : nowIso(); - this.taskActivityIntervalService.resumeActiveIntervalsForMember( + this.resumeTaskActivityIntervalsForAliveMember( run.teamName, memberName, deriveTaskActivityResumeAt(previous, observedAt, resumeFallbackAt) @@ -13940,11 +13985,9 @@ export class TeamProvisioningService { const runtimeObservedAt = nowIso(); for (const [memberName, entry] of Object.entries(nextStatuses)) { if (entry.runtimeAlive === true) { - this.taskActivityIntervalService.resumeActiveIntervalsForMember( - teamName, - memberName, - runtimeObservedAt - ); + this.resumeTaskActivityIntervalsForAliveMember(teamName, memberName, runtimeObservedAt); + } else { + this.dropTaskActivityResumeMarkerForMember(teamName, memberName); } } const expectedMembers = snapshot ? this.getPersistedLaunchMemberNames(snapshot) : undefined; diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index 54baf1b6..5413da27 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -2663,6 +2663,98 @@ describe('TeamProvisioningService', () => { pauseSpy.mockRestore(); } }); + + it('skips redundant resumeActiveIntervalsForMember calls while a member stays alive', () => { + const resumeSpy = vi + .spyOn(TeamTaskActivityIntervalService.prototype, 'resumeActiveIntervalsForMember') + .mockReturnValue({ changedTasks: 0 }); + try { + const svc = new TeamProvisioningService(); + const internals = svc as unknown as { + resumeTaskActivityIntervalsForAliveMember: ( + teamName: string, + memberName: string, + at: string + ) => void; + dropTaskActivityResumeMarkerForMember: (teamName: string, memberName: string) => void; + }; + const teamName = 'member-task-activity-resume-dedup-team'; + + internals.resumeTaskActivityIntervalsForAliveMember( + teamName, + 'alice', + '2026-05-29T00:00:00.000Z' + ); + internals.resumeTaskActivityIntervalsForAliveMember( + teamName, + 'alice', + '2026-05-29T00:00:05.000Z' + ); + internals.resumeTaskActivityIntervalsForAliveMember( + teamName, + 'ALICE', + '2026-05-29T00:00:10.000Z' + ); + + expect(resumeSpy).toHaveBeenCalledTimes(1); + expect(resumeSpy).toHaveBeenNthCalledWith( + 1, + teamName, + 'alice', + '2026-05-29T00:00:00.000Z' + ); + + internals.dropTaskActivityResumeMarkerForMember(teamName, 'alice'); + internals.resumeTaskActivityIntervalsForAliveMember( + teamName, + 'alice', + '2026-05-29T00:01:00.000Z' + ); + + expect(resumeSpy).toHaveBeenCalledTimes(2); + expect(resumeSpy).toHaveBeenNthCalledWith( + 2, + teamName, + 'alice', + '2026-05-29T00:01:00.000Z' + ); + } finally { + resumeSpy.mockRestore(); + } + }); + + it('retries resumeActiveIntervalsForMember when the previous call reported failure', () => { + const resumeSpy = vi + .spyOn(TeamTaskActivityIntervalService.prototype, 'resumeActiveIntervalsForMember') + .mockReturnValueOnce({ changedTasks: 0, failed: true }) + .mockReturnValueOnce({ changedTasks: 1 }); + try { + const svc = new TeamProvisioningService(); + const internals = svc as unknown as { + resumeTaskActivityIntervalsForAliveMember: ( + teamName: string, + memberName: string, + at: string + ) => void; + }; + const teamName = 'member-task-activity-resume-retry-team'; + + internals.resumeTaskActivityIntervalsForAliveMember( + teamName, + 'bob', + '2026-05-29T00:00:00.000Z' + ); + internals.resumeTaskActivityIntervalsForAliveMember( + teamName, + 'bob', + '2026-05-29T00:00:05.000Z' + ); + + expect(resumeSpy).toHaveBeenCalledTimes(2); + } finally { + resumeSpy.mockRestore(); + } + }); }); describe('member spawn status launch reads', () => {