From f79ea145d7321b6eec49c7fd4e5e475c077e5c60 Mon Sep 17 00:00:00 2001 From: 777genius Date: Sat, 30 May 2026 10:02:01 +0300 Subject: [PATCH] perf: batch per-member task-interval resume into one locked pass During launch the live-status loop resumes every alive member every audit cycle. resumeActiveIntervalsForMember runs a synchronous file-lock + full read of every task file, so for an N-member team with M task files it did N locked passes x M readFileSync per cycle (e.g. 6 members x 20 task files), blocking the main event loop. Profiling a 6-member mixed launch showed mutateTeamTasks/withFileLockSync as a top main-thread cost (~14%). Add resumeActiveIntervalsForMembers that applies the identical per-member resume logic against a member set in a single locked pass, and use it in the live-status loop. Same mutations, but one lock + task read per cycle instead of one per member. Adds a test covering multi-member resume in one pass. --- .../services/team/TeamProvisioningService.ts | 19 ++++--- .../team/TeamTaskActivityIntervalService.ts | 56 +++++++++++++++++++ .../TeamTaskActivityIntervalService.test.ts | 52 +++++++++++++++++ 3 files changed, 119 insertions(+), 8 deletions(-) diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index f66bf020..e998befd 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -13895,14 +13895,17 @@ export class TeamProvisioningService { this.getOpenCodeSecondaryBootstrapPendingMemberNames(snapshot), }); const runtimeObservedAt = nowIso(); - for (const [memberName, entry] of Object.entries(nextStatuses)) { - if (entry.runtimeAlive === true) { - this.taskActivityIntervalService.resumeActiveIntervalsForMember( - teamName, - memberName, - runtimeObservedAt - ); - } + const aliveMemberNames = Object.entries(nextStatuses) + .filter(([, entry]) => entry.runtimeAlive === true) + .map(([memberName]) => memberName); + if (aliveMemberNames.length > 0) { + // Resume all alive members in a single locked task-file pass per cycle + // instead of one synchronous lock + full task read per member. + this.taskActivityIntervalService.resumeActiveIntervalsForMembers( + teamName, + aliveMemberNames, + runtimeObservedAt + ); } const expectedMembers = snapshot ? this.getPersistedLaunchMemberNames(snapshot) : undefined; const summary = expectedMembers diff --git a/src/main/services/team/TeamTaskActivityIntervalService.ts b/src/main/services/team/TeamTaskActivityIntervalService.ts index 00b8987b..d36581a9 100644 --- a/src/main/services/team/TeamTaskActivityIntervalService.ts +++ b/src/main/services/team/TeamTaskActivityIntervalService.ts @@ -443,6 +443,62 @@ export class TeamTaskActivityIntervalService { }); } + /** + * 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. + */ + 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 }; + + return this.mutateTeamTasks(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; + }); + } + repairStaleIntervalsAfterCrash( teamName: string, launchSnapshot?: PersistedTeamLaunchSnapshot | null diff --git a/test/main/services/team/TeamTaskActivityIntervalService.test.ts b/test/main/services/team/TeamTaskActivityIntervalService.test.ts index aa3af821..be0c1141 100644 --- a/test/main/services/team/TeamTaskActivityIntervalService.test.ts +++ b/test/main/services/team/TeamTaskActivityIntervalService.test.ts @@ -465,6 +465,58 @@ describe('TeamTaskActivityIntervalService', () => { ]); }); + it('resumes active intervals for multiple members in a single pass', async () => { + 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: [], + }); + await writeTask('alpha', { + id: 'alice-task', + subject: 'Alice work', + owner: 'alice', + status: 'in_progress', + workIntervals: [ + { startedAt: '2026-05-08T11:00:00.000Z', completedAt: '2026-05-08T11:05:00.000Z' }, + ], + historyEvents: [], + }); + await writeTask('alpha', { + id: 'carol-task', + subject: 'Carol work', + owner: 'carol', + status: 'in_progress', + workIntervals: [ + { startedAt: '2026-05-08T12:00:00.000Z', completedAt: '2026-05-08T12:05:00.000Z' }, + ], + historyEvents: [], + }); + + const result = new TeamTaskActivityIntervalService().resumeActiveIntervalsForMembers( + 'alpha', + ['bob', 'alice'], + '2026-05-08T10:20:00.000Z' + ); + const bobTask = await readTask('alpha', 'bob-task'); + const aliceTask = await readTask('alpha', 'alice-task'); + const carolTask = await readTask('alpha', 'carol-task'); + + // Both listed members resumed in one pass; a member outside the set is untouched. + expect(result.changedTasks).toBe(2); + expect((bobTask.workIntervals as unknown[]).at(-1)).toEqual({ + startedAt: '2026-05-08T10:20:00.000Z', + }); + expect((aliceTask.workIntervals as unknown[]).at(-1)).toEqual({ + startedAt: '2026-05-08T10:20:00.000Z', + }); + expect(carolTask.workIntervals).toHaveLength(1); + }); + it('reopens and closes lead work intervals across activity changes', async () => { await writeTask('alpha', { id: 'lead-task',