diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 5dbfec0a..897fdc5b 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -3815,6 +3815,36 @@ export class TeamProvisioningService { } } + private resumeTaskActivityIntervalsForAliveMembers( + teamName: string, + memberNames: readonly string[], + at: string + ): void { + if (memberNames.length === 0) return; + const applied = this.getMemberTaskActivityResumeAppliedSet(teamName); + const pendingNames: string[] = []; + const pendingKeys: string[] = []; + const seen = new Set(); + for (const name of memberNames) { + const memberKey = this.normalizeMemberKeyForTaskActivity(name); + if (!memberKey || applied.has(memberKey) || seen.has(memberKey)) continue; + seen.add(memberKey); + pendingNames.push(name); + pendingKeys.push(memberKey); + } + if (pendingNames.length === 0) return; + const result = this.taskActivityIntervalService.resumeActiveIntervalsForMembers( + teamName, + pendingNames, + at + ); + if (!result.failed) { + for (const key of pendingKeys) { + applied.add(key); + } + } + } + private dropTaskActivityResumeMarkerForMember(teamName: string, memberName: string): void { const memberKey = this.normalizeMemberKeyForTaskActivity(memberName); if (!memberKey) return; @@ -13983,13 +14013,19 @@ export class TeamProvisioningService { this.getOpenCodeSecondaryBootstrapPendingMemberNames(snapshot), }); const runtimeObservedAt = nowIso(); + const aliveMemberNames: string[] = []; for (const [memberName, entry] of Object.entries(nextStatuses)) { if (entry.runtimeAlive === true) { - this.resumeTaskActivityIntervalsForAliveMember(teamName, memberName, runtimeObservedAt); + aliveMemberNames.push(memberName); } else { this.dropTaskActivityResumeMarkerForMember(teamName, memberName); } } + this.resumeTaskActivityIntervalsForAliveMembers( + teamName, + aliveMemberNames, + runtimeObservedAt + ); const expectedMembers = snapshot ? this.getPersistedLaunchMemberNames(snapshot) : undefined; const summary = expectedMembers ? summarizeMemberSpawnStatusRecord(expectedMembers, nextStatuses) diff --git a/src/main/services/team/TeamTaskActivityIntervalService.ts b/src/main/services/team/TeamTaskActivityIntervalService.ts index 00b8987b..a094ece0 100644 --- a/src/main/services/team/TeamTaskActivityIntervalService.ts +++ b/src/main/services/team/TeamTaskActivityIntervalService.ts @@ -321,6 +321,58 @@ function writeTaskFile(filePath: string, task: MutableTeamTask): void { fs.renameSync(tempPath, filePath); } +function collectUniqueMemberKeys(memberNames: readonly string[]): string[] { + const seen = new Set(); + const keys: string[] = []; + for (const name of memberNames) { + const key = normalizeMemberName(name); + if (!key || seen.has(key)) continue; + seen.add(key); + keys.push(key); + } + return keys; +} + +function applyResumeIntervalsForMember( + task: MutableTeamTask, + memberKey: string, + at: string +): boolean { + 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; +} + export class TeamTaskActivityIntervalService { private mutateTeamTasks( teamName: string, @@ -403,42 +455,24 @@ export class TeamTaskActivityIntervalService { memberName: string, at = new Date().toISOString() ): ActivityIntervalResult { - const memberKey = normalizeMemberName(memberName); - if (!memberKey) return { changedTasks: 0 }; + return this.resumeActiveIntervalsForMembers(teamName, [memberName], at); + } + + resumeActiveIntervalsForMembers( + teamName: string, + memberNames: readonly string[], + at = new Date().toISOString() + ): ActivityIntervalResult { + const memberKeys = collectUniqueMemberKeys(memberNames); + if (memberKeys.length === 0) return { changedTasks: 0 }; return this.mutateTeamTasks(teamName, (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; + for (const memberKey of memberKeys) { + if (applyResumeIntervalsForMember(task, memberKey, 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; }); } diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index 5413da27..da153b6b 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -2755,6 +2755,193 @@ describe('TeamProvisioningService', () => { resumeSpy.mockRestore(); } }); + + it('routes alive-member batch through a single resumeActiveIntervalsForMembers call', () => { + const batchSpy = vi + .spyOn(TeamTaskActivityIntervalService.prototype, 'resumeActiveIntervalsForMembers') + .mockReturnValue({ changedTasks: 0 }); + try { + const svc = new TeamProvisioningService(); + const internals = svc as unknown as { + resumeTaskActivityIntervalsForAliveMembers: ( + teamName: string, + memberNames: readonly string[], + at: string + ) => void; + }; + const teamName = 'alive-members-batch-team'; + + internals.resumeTaskActivityIntervalsForAliveMembers( + teamName, + ['alice', 'bob', 'carol'], + '2026-05-29T00:00:00.000Z' + ); + + expect(batchSpy).toHaveBeenCalledTimes(1); + expect(batchSpy).toHaveBeenCalledWith( + teamName, + ['alice', 'bob', 'carol'], + '2026-05-29T00:00:00.000Z' + ); + } finally { + batchSpy.mockRestore(); + } + }); + + it('skips members that are already marked applied and dedupes name aliases', () => { + const batchSpy = vi + .spyOn(TeamTaskActivityIntervalService.prototype, 'resumeActiveIntervalsForMembers') + .mockReturnValue({ changedTasks: 0 }); + try { + const svc = new TeamProvisioningService(); + const internals = svc as unknown as { + resumeTaskActivityIntervalsForAliveMembers: ( + teamName: string, + memberNames: readonly string[], + at: string + ) => void; + resumeTaskActivityIntervalsForAliveMember: ( + teamName: string, + memberName: string, + at: string + ) => void; + }; + const teamName = 'alive-members-applied-team'; + + // Prime the applied set for 'alice' via the single-member path. + internals.resumeTaskActivityIntervalsForAliveMember( + teamName, + 'alice', + '2026-05-29T00:00:00.000Z' + ); + expect(batchSpy).toHaveBeenCalledTimes(1); + batchSpy.mockClear(); + + internals.resumeTaskActivityIntervalsForAliveMembers( + teamName, + ['ALICE', 'alice', ' bob ', 'bob', 'carol'], + '2026-05-29T00:00:10.000Z' + ); + + expect(batchSpy).toHaveBeenCalledTimes(1); + expect(batchSpy).toHaveBeenCalledWith( + teamName, + [' bob ', 'carol'], + '2026-05-29T00:00:10.000Z' + ); + } finally { + batchSpy.mockRestore(); + } + }); + + it('does not invoke the batch when there is no pending alive member', () => { + const batchSpy = vi + .spyOn(TeamTaskActivityIntervalService.prototype, 'resumeActiveIntervalsForMembers') + .mockReturnValue({ changedTasks: 0 }); + try { + const svc = new TeamProvisioningService(); + const internals = svc as unknown as { + resumeTaskActivityIntervalsForAliveMembers: ( + teamName: string, + memberNames: readonly string[], + at: string + ) => void; + }; + const teamName = 'alive-members-empty-team'; + + internals.resumeTaskActivityIntervalsForAliveMembers( + teamName, + [], + '2026-05-29T00:00:00.000Z' + ); + internals.resumeTaskActivityIntervalsForAliveMembers( + teamName, + ['', ' '], + '2026-05-29T00:00:00.000Z' + ); + + expect(batchSpy).not.toHaveBeenCalled(); + } finally { + batchSpy.mockRestore(); + } + }); + + it('retries the alive-member batch when the previous batch reported failure', () => { + const batchSpy = vi + .spyOn(TeamTaskActivityIntervalService.prototype, 'resumeActiveIntervalsForMembers') + .mockReturnValueOnce({ changedTasks: 0, failed: true }) + .mockReturnValueOnce({ changedTasks: 2 }); + try { + const svc = new TeamProvisioningService(); + const internals = svc as unknown as { + resumeTaskActivityIntervalsForAliveMembers: ( + teamName: string, + memberNames: readonly string[], + at: string + ) => void; + }; + const teamName = 'alive-members-retry-team'; + + internals.resumeTaskActivityIntervalsForAliveMembers( + teamName, + ['alice', 'bob'], + '2026-05-29T00:00:00.000Z' + ); + internals.resumeTaskActivityIntervalsForAliveMembers( + teamName, + ['alice', 'bob'], + '2026-05-29T00:00:05.000Z' + ); + + expect(batchSpy).toHaveBeenCalledTimes(2); + expect(batchSpy).toHaveBeenNthCalledWith( + 1, + teamName, + ['alice', 'bob'], + '2026-05-29T00:00:00.000Z' + ); + expect(batchSpy).toHaveBeenNthCalledWith( + 2, + teamName, + ['alice', 'bob'], + '2026-05-29T00:00:05.000Z' + ); + } finally { + batchSpy.mockRestore(); + } + }); + + it('does not re-invoke the batch on a subsequent call once members are applied', () => { + const batchSpy = vi + .spyOn(TeamTaskActivityIntervalService.prototype, 'resumeActiveIntervalsForMembers') + .mockReturnValue({ changedTasks: 0 }); + try { + const svc = new TeamProvisioningService(); + const internals = svc as unknown as { + resumeTaskActivityIntervalsForAliveMembers: ( + teamName: string, + memberNames: readonly string[], + at: string + ) => void; + }; + const teamName = 'alive-members-dedup-team'; + + internals.resumeTaskActivityIntervalsForAliveMembers( + teamName, + ['alice', 'bob'], + '2026-05-29T00:00:00.000Z' + ); + internals.resumeTaskActivityIntervalsForAliveMembers( + teamName, + ['alice', 'bob'], + '2026-05-29T00:00:05.000Z' + ); + + expect(batchSpy).toHaveBeenCalledTimes(1); + } finally { + batchSpy.mockRestore(); + } + }); }); describe('member spawn status launch reads', () => { diff --git a/test/main/services/team/TeamTaskActivityIntervalService.test.ts b/test/main/services/team/TeamTaskActivityIntervalService.test.ts index aa3af821..789d4b38 100644 --- a/test/main/services/team/TeamTaskActivityIntervalService.test.ts +++ b/test/main/services/team/TeamTaskActivityIntervalService.test.ts @@ -811,4 +811,342 @@ describe('TeamTaskActivityIntervalService', () => { expect(result).toEqual({ changedTasks: 0, failed: true }); }); + + describe('resumeActiveIntervalsForMembers (batch)', () => { + it('returns zero changes for an empty members list without scanning the tasks directory', () => { + const service = new TeamTaskActivityIntervalService(); + const mutateSpy = vi.spyOn( + service as unknown as { + mutateTeamTasks: TeamTaskActivityIntervalService['resumeActiveIntervalsForMember']; + }, + 'mutateTeamTasks' + ); + + const result = service.resumeActiveIntervalsForMembers( + 'alpha', + [], + '2026-05-08T10:20:00.000Z' + ); + + expect(result).toEqual({ changedTasks: 0 }); + expect(mutateSpy).not.toHaveBeenCalled(); + }); + + it('returns zero changes when all member names are blank', () => { + const service = new TeamTaskActivityIntervalService(); + const mutateSpy = vi.spyOn( + service as unknown as { + mutateTeamTasks: TeamTaskActivityIntervalService['resumeActiveIntervalsForMember']; + }, + 'mutateTeamTasks' + ); + + const result = service.resumeActiveIntervalsForMembers( + 'alpha', + ['', ' ', null as unknown as string], + '2026-05-08T10:20:00.000Z' + ); + + expect(result).toEqual({ changedTasks: 0 }); + expect(mutateSpy).not.toHaveBeenCalled(); + }); + + it('produces the same per-task result as sequential single-member calls', async () => { + const baseTime = '2026-05-08T10:00:00.000Z'; + const resumeAt = '2026-05-08T10:20:00.000Z'; + + async function seed(teamName: string): Promise { + await writeTask(teamName, { + id: 'work-bob', + subject: 'Bob work', + owner: 'bob', + status: 'in_progress', + workIntervals: [{ startedAt: baseTime, completedAt: '2026-05-08T10:05:00.000Z' }], + historyEvents: [], + }); + await writeTask(teamName, { + id: 'work-tom', + subject: 'Tom work', + owner: 'tom', + status: 'in_progress', + workIntervals: [{ startedAt: baseTime, completedAt: '2026-05-08T10:05:00.000Z' }], + historyEvents: [], + }); + await writeTask(teamName, { + id: 'review-task', + subject: 'Review', + owner: 'alice', + status: 'completed', + reviewIntervals: [ + { + reviewer: 'bob', + startedAt: '2026-05-08T10:06:00.000Z', + completedAt: '2026-05-08T10:08:00.000Z', + }, + ], + historyEvents: [ + { + id: 'event-review-started', + type: 'review_started', + timestamp: '2026-05-08T10:06:00.000Z', + actor: 'bob', + }, + ], + }); + } + + await seed('seq'); + const seqService = new TeamTaskActivityIntervalService(); + seqService.resumeActiveIntervalsForMember('seq', 'bob', resumeAt); + seqService.resumeActiveIntervalsForMember('seq', 'tom', resumeAt); + + await seed('batch'); + const batchService = new TeamTaskActivityIntervalService(); + const batchResult = batchService.resumeActiveIntervalsForMembers( + 'batch', + ['bob', 'tom'], + resumeAt + ); + + expect(batchResult.changedTasks).toBe(3); + for (const id of ['work-bob', 'work-tom', 'review-task']) { + const seqTask = await readTask('seq', id); + const batchTask = await readTask('batch', id); + expect(batchTask.workIntervals).toEqual(seqTask.workIntervals); + expect(batchTask.reviewIntervals).toEqual(seqTask.reviewIntervals); + expect(batchTask.status).toBe(seqTask.status); + } + }); + + it('acquires the team lock once regardless of the number of members', async () => { + await writeTask('alpha', { + id: 'task-bob', + subject: 'Bob', + owner: 'bob', + status: 'in_progress', + workIntervals: [], + historyEvents: [ + { + id: 'event-work-started-bob', + type: 'status_changed', + from: 'pending', + to: 'in_progress', + timestamp: '2026-05-08T10:00:00.000Z', + actor: 'bob', + }, + ], + }); + await writeTask('alpha', { + id: 'task-tom', + subject: 'Tom', + owner: 'tom', + status: 'in_progress', + workIntervals: [], + historyEvents: [ + { + id: 'event-work-started-tom', + type: 'status_changed', + from: 'pending', + to: 'in_progress', + timestamp: '2026-05-08T10:01:00.000Z', + actor: 'tom', + }, + ], + }); + await writeTask('alpha', { + id: 'task-zoe', + subject: 'Zoe', + owner: 'zoe', + status: 'in_progress', + workIntervals: [], + historyEvents: [ + { + id: 'event-work-started-zoe', + type: 'status_changed', + from: 'pending', + to: 'in_progress', + timestamp: '2026-05-08T10:02:00.000Z', + actor: 'zoe', + }, + ], + }); + + const service = new TeamTaskActivityIntervalService(); + const mutateSpy = vi.spyOn( + service as unknown as { + mutateTeamTasks: TeamTaskActivityIntervalService['resumeActiveIntervalsForMember']; + }, + 'mutateTeamTasks' + ); + + const result = service.resumeActiveIntervalsForMembers( + 'alpha', + ['bob', 'tom', 'zoe'], + '2026-05-08T10:20:00.000Z' + ); + + expect(mutateSpy).toHaveBeenCalledTimes(1); + expect(result.changedTasks).toBe(3); + }); + + it('deduplicates member names that normalize to the same key', async () => { + await writeTask('alpha', { + id: 'work-task', + subject: 'Build', + owner: 'bob', + status: 'in_progress', + workIntervals: [ + { startedAt: '2026-05-08T10:00:00.000Z', completedAt: '2026-05-08T10:05:00.000Z' }, + ], + historyEvents: [], + }); + + const result = new TeamTaskActivityIntervalService().resumeActiveIntervalsForMembers( + 'alpha', + ['bob', 'BOB', ' bob '], + '2026-05-08T10:20:00.000Z' + ); + const task = await readTask('alpha', 'work-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:20:00.000Z' }, + ]); + }); + + it('does not write task files when no listed member matches any task', async () => { + await writeTask('alpha', { + id: 'work-bob', + subject: 'Bob work', + owner: 'bob', + status: 'in_progress', + workIntervals: [{ startedAt: '2026-05-08T10:00:00.000Z' }], + historyEvents: [], + }); + + const taskPath = path.join(tempDir, 'tasks', 'alpha', 'work-bob.json'); + const beforeMtime = (await fs.stat(taskPath)).mtimeMs; + const beforeContents = await fs.readFile(taskPath, 'utf8'); + + const result = new TeamTaskActivityIntervalService().resumeActiveIntervalsForMembers( + 'alpha', + ['no-such-member', 'another-ghost'], + '2026-05-08T10:20:00.000Z' + ); + + const afterMtime = (await fs.stat(taskPath)).mtimeMs; + const afterContents = await fs.readFile(taskPath, 'utf8'); + + expect(result.changedTasks).toBe(0); + expect(afterMtime).toBe(beforeMtime); + expect(afterContents).toBe(beforeContents); + }); + + it('opens at most one work interval per task even when the owner is listed twice', async () => { + await writeTask('alpha', { + id: 'work-task', + subject: 'Build', + owner: 'bob', + status: 'in_progress', + workIntervals: [ + { startedAt: '2026-05-08T10:00:00.000Z', completedAt: '2026-05-08T10:05:00.000Z' }, + ], + historyEvents: [], + }); + + const result = new TeamTaskActivityIntervalService().resumeActiveIntervalsForMembers( + 'alpha', + ['bob', 'bob'], + '2026-05-08T10:20:00.000Z' + ); + const task = await readTask('alpha', 'work-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:20:00.000Z' }, + ]); + }); + + it('returns 0 changes for a non-existent team directory', async () => { + const result = new TeamTaskActivityIntervalService().resumeActiveIntervalsForMembers( + 'ghost-team', + ['bob'], + '2026-05-08T10:20:00.000Z' + ); + + expect(result).toEqual({ changedTasks: 0 }); + }); + + it('reports failure when task directory cannot be scanned', async () => { + await fs.mkdir(path.join(tempDir, 'tasks'), { recursive: true }); + await fs.writeFile(path.join(tempDir, 'tasks', 'alpha'), 'not a directory', 'utf8'); + + const result = new TeamTaskActivityIntervalService().resumeActiveIntervalsForMembers( + 'alpha', + ['bob'], + '2026-05-08T10:20:00.000Z' + ); + + expect(result).toEqual({ changedTasks: 0, failed: true }); + }); + + it('skips malformed task JSON files and still applies updates to valid ones', async () => { + await writeTask('alpha', { + id: 'good-task', + subject: 'Build', + owner: 'bob', + status: 'in_progress', + workIntervals: [ + { startedAt: '2026-05-08T10:00:00.000Z', completedAt: '2026-05-08T10:05:00.000Z' }, + ], + historyEvents: [], + }); + await fs.writeFile( + path.join(tempDir, 'tasks', 'alpha', 'broken-task.json'), + '{ this is not valid json', + 'utf8' + ); + + const result = new TeamTaskActivityIntervalService().resumeActiveIntervalsForMembers( + 'alpha', + ['bob'], + '2026-05-08T10:20:00.000Z' + ); + const goodTask = await readTask('alpha', 'good-task'); + + expect(result.changedTasks).toBe(1); + expect(goodTask.workIntervals).toEqual([ + { startedAt: '2026-05-08T10:00:00.000Z', completedAt: '2026-05-08T10:05:00.000Z' }, + { startedAt: '2026-05-08T10:20:00.000Z' }, + ]); + }); + + it('routes single-member resumeActiveIntervalsForMember through the batch implementation', async () => { + await writeTask('alpha', { + id: 'work-task', + subject: 'Build', + owner: 'bob', + status: 'in_progress', + workIntervals: [ + { startedAt: '2026-05-08T10:00:00.000Z', completedAt: '2026-05-08T10:05:00.000Z' }, + ], + historyEvents: [], + }); + + const service = new TeamTaskActivityIntervalService(); + const batchSpy = vi.spyOn(service, 'resumeActiveIntervalsForMembers'); + + const result = service.resumeActiveIntervalsForMember( + 'alpha', + 'bob', + '2026-05-08T10:20:00.000Z' + ); + + expect(batchSpy).toHaveBeenCalledTimes(1); + expect(batchSpy).toHaveBeenCalledWith('alpha', ['bob'], '2026-05-08T10:20:00.000Z'); + expect(result.changedTasks).toBe(1); + }); + }); });