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:
parent
d9479b5c61
commit
9e3efa18ce
4 changed files with 628 additions and 33 deletions
|
|
@ -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 {
|
private dropTaskActivityResumeMarkerForMember(teamName: string, memberName: string): void {
|
||||||
const memberKey = this.normalizeMemberKeyForTaskActivity(memberName);
|
const memberKey = this.normalizeMemberKeyForTaskActivity(memberName);
|
||||||
if (!memberKey) return;
|
if (!memberKey) return;
|
||||||
|
|
@ -13983,13 +14013,19 @@ export class TeamProvisioningService {
|
||||||
this.getOpenCodeSecondaryBootstrapPendingMemberNames(snapshot),
|
this.getOpenCodeSecondaryBootstrapPendingMemberNames(snapshot),
|
||||||
});
|
});
|
||||||
const runtimeObservedAt = nowIso();
|
const runtimeObservedAt = nowIso();
|
||||||
|
const aliveMemberNames: string[] = [];
|
||||||
for (const [memberName, entry] of Object.entries(nextStatuses)) {
|
for (const [memberName, entry] of Object.entries(nextStatuses)) {
|
||||||
if (entry.runtimeAlive === true) {
|
if (entry.runtimeAlive === true) {
|
||||||
this.resumeTaskActivityIntervalsForAliveMember(teamName, memberName, runtimeObservedAt);
|
aliveMemberNames.push(memberName);
|
||||||
} else {
|
} else {
|
||||||
this.dropTaskActivityResumeMarkerForMember(teamName, memberName);
|
this.dropTaskActivityResumeMarkerForMember(teamName, memberName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
this.resumeTaskActivityIntervalsForAliveMembers(
|
||||||
|
teamName,
|
||||||
|
aliveMemberNames,
|
||||||
|
runtimeObservedAt
|
||||||
|
);
|
||||||
const expectedMembers = snapshot ? this.getPersistedLaunchMemberNames(snapshot) : undefined;
|
const expectedMembers = snapshot ? this.getPersistedLaunchMemberNames(snapshot) : undefined;
|
||||||
const summary = expectedMembers
|
const summary = expectedMembers
|
||||||
? summarizeMemberSpawnStatusRecord(expectedMembers, nextStatuses)
|
? summarizeMemberSpawnStatusRecord(expectedMembers, nextStatuses)
|
||||||
|
|
|
||||||
|
|
@ -321,6 +321,58 @@ function writeTaskFile(filePath: string, task: MutableTeamTask): void {
|
||||||
fs.renameSync(tempPath, filePath);
|
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 {
|
export class TeamTaskActivityIntervalService {
|
||||||
private mutateTeamTasks(
|
private mutateTeamTasks(
|
||||||
teamName: string,
|
teamName: string,
|
||||||
|
|
@ -403,42 +455,24 @@ export class TeamTaskActivityIntervalService {
|
||||||
memberName: string,
|
memberName: string,
|
||||||
at = new Date().toISOString()
|
at = new Date().toISOString()
|
||||||
): ActivityIntervalResult {
|
): ActivityIntervalResult {
|
||||||
const memberKey = normalizeMemberName(memberName);
|
return this.resumeActiveIntervalsForMembers(teamName, [memberName], at);
|
||||||
if (!memberKey) return { changedTasks: 0 };
|
}
|
||||||
|
|
||||||
|
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) => {
|
return this.mutateTeamTasks(teamName, (task) => {
|
||||||
let changed = false;
|
let changed = false;
|
||||||
|
for (const memberKey of memberKeys) {
|
||||||
if (
|
if (applyResumeIntervalsForMember(task, memberKey, at)) {
|
||||||
task.status === 'in_progress' &&
|
changed = true;
|
||||||
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;
|
return changed;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2755,6 +2755,193 @@ describe('TeamProvisioningService', () => {
|
||||||
resumeSpy.mockRestore();
|
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', () => {
|
describe('member spawn status launch reads', () => {
|
||||||
|
|
|
||||||
|
|
@ -811,4 +811,342 @@ describe('TeamTaskActivityIntervalService', () => {
|
||||||
|
|
||||||
expect(result).toEqual({ changedTasks: 0, failed: true });
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue