fix(changes): derive opencode backfill member from delivery

This commit is contained in:
777genius 2026-04-28 22:28:58 +03:00
parent 819a1f6e8f
commit ba09010fcb
2 changed files with 170 additions and 6 deletions

View file

@ -413,6 +413,10 @@ export class ChangeExtractorService {
input.teamName,
input.taskId
);
const backfillMemberName = this.resolveOpenCodeBackfillMemberName(
input.effectiveOptions.owner,
deliveryContextRecords
);
const deliveryContextFingerprint =
this.hashOpenCodeDeliveryContextRecords(deliveryContextRecords);
@ -444,7 +448,7 @@ export class ChangeExtractorService {
teamName: input.teamName,
taskId: input.taskId,
displayId: input.taskMeta?.displayId ?? null,
memberName: input.effectiveOptions.owner ?? null,
memberName: backfillMemberName ?? input.effectiveOptions.owner ?? null,
projectDir,
workspaceRoot,
sourceGeneration,
@ -466,7 +470,8 @@ export class ChangeExtractorService {
workspaceRoot,
cacheKey,
deliveryContextRecords,
sourceGeneration
sourceGeneration,
backfillMemberName
).finally(() => {
this.openCodeBackfillInFlight.delete(cacheKey);
});
@ -482,7 +487,8 @@ export class ChangeExtractorService {
deliveryContextRecords: Awaited<
ReturnType<ChangeExtractorService['readOpenCodeDeliveryContextRecords']>
>,
sourceGeneration: string | null
sourceGeneration: string | null,
backfillMemberName?: string
): Promise<boolean> {
const deliveryContext = await this.createOpenCodeDeliveryContextTempFile(
input.teamName,
@ -495,10 +501,10 @@ export class ChangeExtractorService {
teamName: input.teamName,
taskId: input.taskId,
taskDisplayId: input.taskMeta?.displayId,
memberName: input.effectiveOptions.owner,
projectDir,
workspaceRoot,
attributionMode: OPEN_CODE_AUTO_BACKFILL_ATTRIBUTION_MODE,
...(backfillMemberName ? { memberName: backfillMemberName } : {}),
...(deliveryContext.filePath ? { deliveryContextPath: deliveryContext.filePath } : {}),
});
void appendOpenCodeTaskChangeDiag({
@ -507,7 +513,7 @@ export class ChangeExtractorService {
teamName: input.teamName,
taskId: input.taskId,
displayId: input.taskMeta?.displayId ?? null,
memberName: input.effectiveOptions.owner ?? null,
memberName: backfillMemberName ?? input.effectiveOptions.owner ?? null,
projectDir,
workspaceRoot,
sourceGeneration,
@ -562,7 +568,7 @@ export class ChangeExtractorService {
teamName: input.teamName,
taskId: input.taskId,
displayId: input.taskMeta?.displayId ?? null,
memberName: input.effectiveOptions.owner ?? null,
memberName: backfillMemberName ?? input.effectiveOptions.owner ?? null,
projectDir,
workspaceRoot,
deliveryRecordCount: deliveryContextRecords.length,
@ -745,6 +751,18 @@ export class ChangeExtractorService {
return records.slice(-200);
}
private resolveOpenCodeBackfillMemberName(
owner: string | undefined,
records: Awaited<ReturnType<ChangeExtractorService['readOpenCodeDeliveryContextRecords']>>
): string | undefined {
const members = [...new Set(records.map((record) => record.memberName.trim()).filter(Boolean))];
const normalizedOwner = owner?.trim();
if (normalizedOwner && members.includes(normalizedOwner)) {
return normalizedOwner;
}
return members.length === 1 ? members[0] : undefined;
}
private async readOpenCodeRuntimeLaneIdsFromDisk(
teamsBasePath: string,
teamName: string

View file

@ -1064,6 +1064,152 @@ describe('ChangeExtractorService', () => {
expect(workerClient.computeTaskChanges).not.toHaveBeenCalled();
});
it('uses the OpenCode delivery member when the current task owner changed later', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
setClaudeBasePathOverride(tmpDir);
await writeTaskFile(tmpDir, { displayId: 'abc12345', owner: 'alice' });
const projectDir = path.join(tmpDir, 'project-dir');
const projectPath = path.join(tmpDir, 'repo');
await fs.mkdir(projectDir, { recursive: true });
await fs.mkdir(projectPath, { recursive: true });
await writeOpenCodeDeliveryLedger(tmpDir, { memberName: 'bob' });
const backfillOpenCodeTaskLedger = vi.fn(async (input: any) => ({
schemaVersion: 1,
providerId: 'opencode',
teamName: input.teamName,
taskId: input.taskId,
projectDir: input.projectDir,
workspaceRoot: input.workspaceRoot,
dryRun: false,
attributionMode: input.attributionMode,
scannedSessions: 0,
scannedToolparts: 0,
candidateEvents: 0,
importedEvents: 0,
skippedEvents: 0,
outcome: 'no-history',
notices: [],
diagnostics: [],
}));
const workerClient = {
isAvailable: vi.fn(() => true),
computeTaskChanges: vi.fn(async () =>
makeTaskChangeResult(TASK_ID, { content: '', confidence: 'fallback' })
),
};
const service = new ChangeExtractorService(
{
getLogSourceWatchContext: vi.fn(async () => ({
projectDir,
projectPath,
sessionIds: [],
})),
findLogFileRefsForTask: vi.fn(async () => []),
findMemberLogPaths: vi.fn(async () => []),
} as any,
{
parseBoundaries: vi.fn(async () => ({
boundaries: [],
scopes: [],
isSingleTaskSession: true,
detectedMechanism: 'none' as const,
})),
} as any,
{ getConfig: vi.fn(async () => ({ projectPath })) } as any,
undefined,
workerClient as any,
{ backfillOpenCodeTaskLedger } as any,
{ getMeta: vi.fn(async () => ({ providerId: 'opencode' })) } as any
);
await service.getTaskChanges(TEAM_NAME, TASK_ID, {
owner: 'alice',
status: 'completed',
});
expect(backfillOpenCodeTaskLedger).toHaveBeenCalledWith(
expect.objectContaining({
memberName: 'bob',
attributionMode: 'strict-delivery',
})
);
});
it('omits member filter when multiple OpenCode delivery members match the task', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
setClaudeBasePathOverride(tmpDir);
await writeTaskFile(tmpDir, { displayId: 'abc12345', owner: 'alice' });
const projectDir = path.join(tmpDir, 'project-dir');
const projectPath = path.join(tmpDir, 'repo');
await fs.mkdir(projectDir, { recursive: true });
await fs.mkdir(projectPath, { recursive: true });
await writeOpenCodeDeliveryLedger(tmpDir, { memberName: 'bob', runtimeSessionId: 'session-1' });
await writeOpenCodeDeliveryLedger(tmpDir, {
memberName: 'carol',
runtimeSessionId: 'session-2',
});
const backfillOpenCodeTaskLedger = vi.fn(async (input: any) => ({
schemaVersion: 1,
providerId: 'opencode',
teamName: input.teamName,
taskId: input.taskId,
projectDir: input.projectDir,
workspaceRoot: input.workspaceRoot,
dryRun: false,
attributionMode: input.attributionMode,
scannedSessions: 0,
scannedToolparts: 0,
candidateEvents: 0,
importedEvents: 0,
skippedEvents: 0,
outcome: 'no-history',
notices: [],
diagnostics: [],
}));
const workerClient = {
isAvailable: vi.fn(() => true),
computeTaskChanges: vi.fn(async () =>
makeTaskChangeResult(TASK_ID, { content: '', confidence: 'fallback' })
),
};
const service = new ChangeExtractorService(
{
getLogSourceWatchContext: vi.fn(async () => ({
projectDir,
projectPath,
sessionIds: [],
})),
findLogFileRefsForTask: vi.fn(async () => []),
findMemberLogPaths: vi.fn(async () => []),
} as any,
{
parseBoundaries: vi.fn(async () => ({
boundaries: [],
scopes: [],
isSingleTaskSession: true,
detectedMechanism: 'none' as const,
})),
} as any,
{ getConfig: vi.fn(async () => ({ projectPath })) } as any,
undefined,
workerClient as any,
{ backfillOpenCodeTaskLedger } as any,
{ getMeta: vi.fn(async () => ({ providerId: 'opencode' })) } as any
);
await service.getTaskChanges(TEAM_NAME, TASK_ID, {
owner: 'alice',
status: 'completed',
});
expect(backfillOpenCodeTaskLedger).toHaveBeenCalledTimes(1);
expect(backfillOpenCodeTaskLedger.mock.calls[0]?.[0]).not.toHaveProperty('memberName');
});
it('does not run OpenCode backfill for explicit non-OpenCode teams even if stale runtime files exist', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
setClaudeBasePathOverride(tmpDir);