diff --git a/src/main/services/team/ChangeExtractorService.ts b/src/main/services/team/ChangeExtractorService.ts index fe46e6b0..493b0094 100644 --- a/src/main/services/team/ChangeExtractorService.ts +++ b/src/main/services/team/ChangeExtractorService.ts @@ -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 >, - sourceGeneration: string | null + sourceGeneration: string | null, + backfillMemberName?: string ): Promise { 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> + ): 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 diff --git a/test/main/services/team/ChangeExtractorService.test.ts b/test/main/services/team/ChangeExtractorService.test.ts index 90238931..81f8a1d3 100644 --- a/test/main/services/team/ChangeExtractorService.test.ts +++ b/test/main/services/team/ChangeExtractorService.test.ts @@ -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);