perf(team): batch task activity interval resume across alive members

Collapses the per-member resume scan in getMemberSpawnStatuses into a single
readdir + file lock + pass over the team's tasks. Avoids N x IO when multiple
members become alive at launch. Semantics of the applied-set guard are
preserved 1:1; the single-member API stays as a wrapper around the batch.
This commit is contained in:
777genius 2026-05-29 00:44:31 +03:00
parent d9479b5c61
commit 9e3efa18ce
4 changed files with 628 additions and 33 deletions

View file

@ -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<string>();
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)

View file

@ -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<string>();
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;
});
}

View file

@ -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', () => {

View file

@ -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<void> {
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);
});
});
});