import { getTasksBasePath, getTeamsBasePath } from '@main/utils/pathDecoder'; import { createLogger } from '@shared/utils/logger'; import * as fs from 'fs'; import * as path from 'path'; import { withFileLockSync } from './fileLock'; import { TeamTaskReader } from './TeamTaskReader'; import type { PersistedTeamLaunchMemberState, PersistedTeamLaunchSnapshot, TaskReviewInterval, TeamTask, } from '@shared/types'; interface ActivityIntervalResult { changedTasks: number; failed?: boolean; } interface TaskDirectorySignature { key: string; } interface TaskFileSignature { size: number; mtimeMs: number; ctimeMs: number; dev: number; ino: number; } interface CachedActivityTaskFile { signature: TaskFileSignature; task: MutableTeamTask | null; } interface ResumeMembersCacheEntry { memberKey: string; signatureKey: string; } type MemberActivityNoopOperation = 'pause-member' | 'resume-member'; type MutableTeamTask = TeamTask & { reviewIntervals?: TaskReviewInterval[]; }; const CRASH_REPAIR_GRACE_MS = 5_000; const TASK_FILE_CACHE_MAX_ENTRIES = 8_192; const logger = createLogger('Service:TeamTaskActivityIntervalService'); function normalizeMemberName(value: string | null | undefined): string { return typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : ''; } function parseIsoMs(value: string | null | undefined): number { if (!value) return 0; const parsed = Date.parse(value); return Number.isFinite(parsed) ? parsed : 0; } function toIso(ms: number): string { return new Date(ms).toISOString(); } function isClosedInterval(interval: { completedAt?: unknown } | null | undefined): boolean { return typeof interval?.completedAt === 'string' && parseIsoMs(interval.completedAt) > 0; } function hasValidStartedAt(interval: { startedAt?: unknown } | null | undefined): boolean { return typeof interval?.startedAt === 'string' && parseIsoMs(interval.startedAt) > 0; } function ensureCloseIso(startedAt: string, at: string): string { const startedAtMs = parseIsoMs(startedAt); const atMs = parseIsoMs(at); if (startedAtMs <= 0) return at; if (atMs <= startedAtMs) return toIso(startedAtMs); return toIso(atMs); } function resumeStartIso(activeStartedAt: string | null | undefined, at: string): string { const activeStartedAtMs = parseIsoMs(activeStartedAt ?? undefined); const atMs = parseIsoMs(at); if (activeStartedAtMs > 0 && activeStartedAtMs > atMs) { return toIso(activeStartedAtMs); } return atMs > 0 ? toIso(atMs) : toIso(Date.now()); } function getStartedAtString(interval: { startedAt?: unknown } | null | undefined): string { return typeof interval?.startedAt === 'string' ? interval.startedAt : ''; } function hasUsableCompletedAt(interval: { completedAt?: unknown } | null | undefined): boolean { return interval?.completedAt === undefined || isClosedInterval(interval); } function pauseCloseIso( interval: { startedAt?: unknown; completedAt?: unknown } | null | undefined, at: string ): string { const startedAt = getStartedAtString(interval); const closeAt = interval?.completedAt === undefined ? at : startedAt || at; return ensureCloseIso(startedAt, closeAt); } function crashRepairCloseIso(startedAt: string, member?: PersistedTeamLaunchMemberState): string { const startedAtMs = parseIsoMs(startedAt); const safeStartedAtMs = startedAtMs > 0 ? startedAtMs : Date.now(); const evidenceMs = Math.max( parseIsoMs(member?.lastHeartbeatAt), parseIsoMs(member?.runtimeLastSeenAt), parseIsoMs(member?.lastRuntimeAliveAt) ); const closeMs = evidenceMs > 0 ? Math.max(safeStartedAtMs, evidenceMs + CRASH_REPAIR_GRACE_MS) : safeStartedAtMs + CRASH_REPAIR_GRACE_MS; const boundedCloseMs = Math.max(safeStartedAtMs, Math.min(Date.now(), closeMs)); return toIso(boundedCloseMs); } function crashRepairIntervalCloseIso( interval: { startedAt?: unknown; completedAt?: unknown } | null | undefined, member?: PersistedTeamLaunchMemberState ): string { const startedAt = getStartedAtString(interval); if (interval?.completedAt === undefined) { return crashRepairCloseIso(startedAt, member); } return ensureCloseIso(startedAt, startedAt || crashRepairCloseIso(startedAt, member)); } function hasOpenWorkInterval(task: MutableTeamTask): boolean { return ( Array.isArray(task.workIntervals) && task.workIntervals.some( (interval) => hasValidStartedAt(interval) && interval.completedAt === undefined ) ); } function hasOpenReviewInterval(task: MutableTeamTask, reviewer: string): boolean { const reviewerKey = normalizeMemberName(reviewer); return ( Array.isArray(task.reviewIntervals) && task.reviewIntervals.some( (interval) => hasValidStartedAt(interval) && interval.completedAt === undefined && normalizeMemberName(interval.reviewer) === reviewerKey ) ); } function closeOpenWorkIntervals(task: MutableTeamTask, at: string, owner?: string): boolean { if (!Array.isArray(task.workIntervals)) return false; if (owner && normalizeMemberName(task.owner) !== normalizeMemberName(owner)) return false; let changed = false; task.workIntervals = task.workIntervals.map((interval) => { if (isClosedInterval(interval)) return interval; changed = true; return { ...interval, completedAt: pauseCloseIso(interval, at) }; }); return changed; } function closeOpenReviewIntervals(task: MutableTeamTask, at: string, reviewer?: string): boolean { if (!Array.isArray(task.reviewIntervals)) return false; const reviewerKey = normalizeMemberName(reviewer); let changed = false; task.reviewIntervals = task.reviewIntervals.map((interval) => { if (isClosedInterval(interval)) return interval; if (reviewerKey && normalizeMemberName(interval.reviewer) !== reviewerKey) return interval; changed = true; return { ...interval, completedAt: pauseCloseIso(interval, at) }; }); return changed; } function getActiveWorkStartedAt(task: MutableTeamTask): string | null { const events = Array.isArray(task.historyEvents) ? task.historyEvents : []; for (let index = events.length - 1; index >= 0; index -= 1) { const event = events[index]; if (event.type === 'status_changed') { if (event.to === 'in_progress') { return parseIsoMs(event.timestamp) > 0 ? event.timestamp : null; } return null; } if (event.type === 'task_created') { return event.status === 'in_progress' && parseIsoMs(event.timestamp) > 0 ? event.timestamp : null; } } return null; } function getActiveReviewStart( task: MutableTeamTask ): { reviewer: string; startedAt: string } | null { const events = Array.isArray(task.historyEvents) ? task.historyEvents : []; for (let index = events.length - 1; index >= 0; index -= 1) { const event = events[index]; if (event.type === 'review_started') { const reviewer = typeof event.actor === 'string' && event.actor.trim() ? event.actor.trim() : ''; return reviewer && parseIsoMs(event.timestamp) > 0 ? { reviewer, startedAt: event.timestamp } : null; } if ( event.type === 'review_approved' || event.type === 'review_changes_requested' || (event.type === 'status_changed' && (event.to === 'in_progress' || event.to === 'pending' || event.to === 'deleted')) || event.type === 'task_created' ) { return null; } } return null; } function hasWorkIntervalForStart(task: MutableTeamTask, startedAt: string): boolean { const startedAtMs = parseIsoMs(startedAt); return ( startedAtMs > 0 && Array.isArray(task.workIntervals) && task.workIntervals.some((interval) => parseIsoMs(interval.startedAt) === startedAtMs) ); } function hasPersistedWorkIntervalAtOrAfter(task: MutableTeamTask, startedAt: string): boolean { const startedAtMs = parseIsoMs(startedAt); return ( startedAtMs > 0 && Array.isArray(task.workIntervals) && task.workIntervals.some( (interval) => hasValidStartedAt(interval) && hasUsableCompletedAt(interval) && parseIsoMs(interval.startedAt) >= startedAtMs ) ); } function hasReviewIntervalForStart( task: MutableTeamTask, reviewer: string, startedAt: string ): boolean { const reviewerKey = normalizeMemberName(reviewer); const startedAtMs = parseIsoMs(startedAt); return ( reviewerKey.length > 0 && startedAtMs > 0 && Array.isArray(task.reviewIntervals) && task.reviewIntervals.some( (interval) => normalizeMemberName(interval.reviewer) === reviewerKey && parseIsoMs(interval.startedAt) === startedAtMs ) ); } function hasPersistedReviewIntervalAtOrAfter(task: MutableTeamTask, startedAt: string): boolean { const startedAtMs = parseIsoMs(startedAt); return ( startedAtMs > 0 && Array.isArray(task.reviewIntervals) && task.reviewIntervals.some( (interval) => normalizeMemberName(interval?.reviewer).length > 0 && hasValidStartedAt(interval) && hasUsableCompletedAt(interval) && parseIsoMs(interval.startedAt) >= startedAtMs ) ); } function materializePausedWorkInterval(task: MutableTeamTask, at: string, owner?: string): boolean { if (task.status !== 'in_progress') return false; if (owner && normalizeMemberName(task.owner) !== normalizeMemberName(owner)) return false; const startedAt = getActiveWorkStartedAt(task); if ( !startedAt || hasPersistedWorkIntervalAtOrAfter(task, startedAt) || hasWorkIntervalForStart(task, startedAt) ) { return false; } task.workIntervals = [ ...(Array.isArray(task.workIntervals) ? task.workIntervals : []), { startedAt, completedAt: ensureCloseIso(startedAt, at) }, ]; return true; } function materializePausedReviewInterval( task: MutableTeamTask, at: string, reviewer?: string ): boolean { if (task.status !== 'completed') return false; const activeReview = getActiveReviewStart(task); if (!activeReview) return false; if (reviewer && normalizeMemberName(activeReview.reviewer) !== normalizeMemberName(reviewer)) { return false; } if ( hasPersistedReviewIntervalAtOrAfter(task, activeReview.startedAt) || hasReviewIntervalForStart(task, activeReview.reviewer, activeReview.startedAt) ) { return false; } task.reviewIntervals = [ ...(Array.isArray(task.reviewIntervals) ? task.reviewIntervals : []), { reviewer: activeReview.reviewer, startedAt: activeReview.startedAt, completedAt: ensureCloseIso(activeReview.startedAt, at), }, ]; return true; } function writeTaskFile(filePath: string, task: MutableTeamTask): void { const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`; fs.writeFileSync(tempPath, JSON.stringify(task, null, 2)); fs.renameSync(tempPath, filePath); } 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 ); } export class TeamTaskActivityIntervalService { private readonly resumeMembersCache = new Map(); private readonly memberActivityNoopCache = new Map(); private readonly taskFileCache = 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 getCachedTaskFile( filePath: string, signature: TaskFileSignature ): MutableTeamTask | null | undefined { const cached = this.taskFileCache.get(filePath); if (!cached) return undefined; if (!taskFileSignaturesEqual(cached.signature, signature)) { this.taskFileCache.delete(filePath); return undefined; } return cached.task ? structuredClone(cached.task) : null; } private setCachedTaskFile( filePath: string, signature: TaskFileSignature, task: MutableTeamTask | null ): void { if ( !this.taskFileCache.has(filePath) && this.taskFileCache.size >= TASK_FILE_CACHE_MAX_ENTRIES ) { const oldestKey = this.taskFileCache.keys().next().value; if (oldestKey) { this.taskFileCache.delete(oldestKey); } } this.taskFileCache.set(filePath, { signature, task: task ? structuredClone(task) : null, }); } private readTaskFile(filePath: string): MutableTeamTask | null { let signature: TaskFileSignature; try { const stat = fs.statSync(filePath); if (!stat.isFile()) { this.taskFileCache.delete(filePath); return null; } signature = buildTaskFileSignature(stat); const cached = this.getCachedTaskFile(filePath, signature); if (cached !== undefined) { return cached; } } catch { this.taskFileCache.delete(filePath); return null; } try { const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8')) as unknown; const task = parsed && typeof parsed === 'object' ? (parsed as MutableTeamTask) : null; this.setCachedTaskFile(filePath, signature, task); return task; } catch { this.setCachedTaskFile(filePath, signature, null); return null; } } private cacheWrittenTaskFile(filePath: string, task: MutableTeamTask): void { try { const stat = fs.statSync(filePath); if (!stat.isFile()) { this.taskFileCache.delete(filePath); return; } this.setCachedTaskFile(filePath, buildTaskFileSignature(stat), task); } catch { this.taskFileCache.delete(filePath); } } private mutateTeamTasksWithLock( teamName: string, run: () => ActivityIntervalResult ): ActivityIntervalResult { const lockScope = path.join(getTeamsBasePath(), teamName, 'board-state'); try { return withFileLockSync(lockScope, run); } catch (error) { logger.warn( `[${teamName}] Failed to update task activity intervals: ${ error instanceof Error ? error.message : String(error) }` ); return { changedTasks: 0, failed: true }; } } 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.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; } private mutateTeamTasksUnlocked( teamName: string, mutate: (task: MutableTeamTask) => boolean ): ActivityIntervalResult { const tasksDir = path.join(getTasksBasePath(), teamName); let entries: string[]; try { entries = fs.readdirSync(tasksDir); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { return { changedTasks: 0 }; } throw error; } let changedTasks = 0; for (const fileName of entries) { if (!fileName.endsWith('.json') || fileName.startsWith('.')) continue; const filePath = path.join(tasksDir, fileName); const task = this.readTaskFile(filePath); if (!task) continue; if (!mutate(task)) continue; writeTaskFile(filePath, task); this.cacheWrittenTaskFile(filePath, task); changedTasks += 1; } if (changedTasks > 0) { TeamTaskReader.invalidateAllTasksCache(); } 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() ): ActivityIntervalResult { return this.mutateTeamTasks(teamName, (task) => { const changedWork = closeOpenWorkIntervals(task, at); const changedReview = closeOpenReviewIntervals(task, at); const materializedWork = materializePausedWorkInterval(task, at); const materializedReview = materializePausedReviewInterval(task, at); return changedWork || changedReview || materializedWork || materializedReview; }); } pauseActiveIntervalsForMember( teamName: string, memberName: string, at = new Date().toISOString() ): ActivityIntervalResult { 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( teamName: string, memberName: string, at = new Date().toISOString() ): ActivityIntervalResult { const memberKey = normalizeMemberName(memberName); if (!memberKey) return { changedTasks: 0 }; return this.mutateMemberTasksWithNoopCache(teamName, 'resume-member', memberKey, (task) => { let changed = false; if ( task.status === 'in_progress' && normalizeMemberName(task.owner) === memberKey && !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 && normalizeMemberName(activeReview.reviewer) === memberKey && !hasOpenReviewInterval(task, activeReview.reviewer) ) { task.reviewIntervals = [ ...(Array.isArray(task.reviewIntervals) ? task.reviewIntervals : []), { reviewer: activeReview.reviewer, startedAt: resumeStartIso(activeReview.startedAt, at), }, ]; changed = true; } return changed; }); } /** * Batched equivalent of resumeActiveIntervalsForMember for several members in a * single task-file pass. During launch the live-status loop resumes every alive * member every audit cycle; doing that per member meant one synchronous * 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. After a no-op pass, a task-file signature skips * unchanged repeat cycles without parsing every task JSON again. */ resumeActiveIntervalsForMembers( teamName: string, memberNames: readonly string[], at = new Date().toISOString() ): ActivityIntervalResult { const memberKeys = new Set( memberNames.map((name) => normalizeMemberName(name)).filter((key): key is string => !!key) ); if (memberKeys.size === 0) return { changedTasks: 0 }; const memberKey = this.makeMemberSetKey(memberKeys); const cachedBeforeLock = this.resumeMembersCache.get(teamName); if (cachedBeforeLock?.memberKey === memberKey) { const beforeLockSignature = this.readTaskDirectorySignature(teamName); if ( beforeLockSignature && cachedBeforeLock.signatureKey === beforeLockSignature.key && !fs.existsSync(this.getBoardStateLockPath(teamName)) ) { return { changedTasks: 0 }; } } const result = this.mutateTeamTasksWithLock(teamName, () => { const beforeSignature = this.readTaskDirectorySignature(teamName); const cached = this.resumeMembersCache.get(teamName); if ( beforeSignature && cached?.memberKey === memberKey && cached.signatureKey === beforeSignature.key ) { return { changedTasks: 0 }; } const mutationResult = this.mutateTeamTasksUnlocked(teamName, (task) => { let changed = false; 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.clearActivityNoopCachesForTeam(teamName); } else if (result.changedTasks > 0) { this.clearMemberActivityNoopCacheForTeam(teamName); } return result; } repairStaleIntervalsAfterCrash( teamName: string, launchSnapshot?: PersistedTeamLaunchSnapshot | null ): ActivityIntervalResult { const memberByName = new Map(); for (const member of Object.values(launchSnapshot?.members ?? {})) { memberByName.set(normalizeMemberName(member.name), member); } return this.mutateTeamTasks(teamName, (task) => { let changed = false; if (Array.isArray(task.workIntervals)) { const ownerMember = memberByName.get(normalizeMemberName(task.owner)); task.workIntervals = task.workIntervals.map((interval) => { if (isClosedInterval(interval)) return interval; changed = true; return { ...interval, completedAt: crashRepairIntervalCloseIso(interval, ownerMember) }; }); } if (task.status === 'in_progress') { const ownerMember = memberByName.get(normalizeMemberName(task.owner)); const startedAt = getActiveWorkStartedAt(task); if ( startedAt && !hasPersistedWorkIntervalAtOrAfter(task, startedAt) && !hasWorkIntervalForStart(task, startedAt) ) { task.workIntervals = [ ...(Array.isArray(task.workIntervals) ? task.workIntervals : []), { startedAt, completedAt: crashRepairCloseIso(startedAt, ownerMember) }, ]; changed = true; } } if (Array.isArray(task.reviewIntervals)) { task.reviewIntervals = task.reviewIntervals.map((interval) => { if (isClosedInterval(interval)) return interval; const reviewerMember = memberByName.get(normalizeMemberName(interval.reviewer)); changed = true; return { ...interval, completedAt: crashRepairIntervalCloseIso(interval, reviewerMember), }; }); } if (task.status === 'completed') { const activeReview = getActiveReviewStart(task); if ( activeReview && !hasPersistedReviewIntervalAtOrAfter(task, activeReview.startedAt) && !hasReviewIntervalForStart(task, activeReview.reviewer, activeReview.startedAt) ) { const reviewerMember = memberByName.get(normalizeMemberName(activeReview.reviewer)); task.reviewIntervals = [ ...(Array.isArray(task.reviewIntervals) ? task.reviewIntervals : []), { reviewer: activeReview.reviewer, startedAt: activeReview.startedAt, completedAt: crashRepairCloseIso(activeReview.startedAt, reviewerMember), }, ]; changed = true; } } return changed; }); } }