diff --git a/src/main/services/team/ChangeExtractorService.ts b/src/main/services/team/ChangeExtractorService.ts index 493b0094..a84d570d 100644 --- a/src/main/services/team/ChangeExtractorService.ts +++ b/src/main/services/team/ChangeExtractorService.ts @@ -70,6 +70,11 @@ interface OpenCodeBackfillCacheEntry { expiresAt: number; } +interface OpenCodeBackfillAttempt { + attempted: boolean; + backfilled: boolean; +} + interface OpenCodeDeliveryContextTempFile { filePath: string | null; cleanup: () => Promise; @@ -81,7 +86,7 @@ export class ChangeExtractorService { private taskChangeSummaryInFlight = new Map>(); private taskChangeSummaryVersionByTask = new Map(); private taskChangeSummaryValidationInFlight = new Set(); - private openCodeBackfillInFlight = new Map>(); + private openCodeBackfillInFlight = new Map>(); private openCodeBackfillCache = new Map(); private openCodeTeamEligibilityCache = new Map(); private readonly cacheTtl = 30 * 1000; // 30 сек — shorter TTL to reduce stale data risk @@ -209,7 +214,8 @@ export class ChangeExtractorService { return ledgerResult; } - if (await this.tryBackfillOpenCodeLedger(resolvedInput)) { + const openCodeBackfill = await this.tryBackfillOpenCodeLedger(resolvedInput); + if (openCodeBackfill.backfilled || openCodeBackfill.attempted) { const backfilledLedgerResult = await this.readLedgerTaskChanges(resolvedInput); if (backfilledLedgerResult) { await this.recordTaskChangePresence( @@ -378,15 +384,17 @@ export class ChangeExtractorService { } } - private async tryBackfillOpenCodeLedger(input: ResolvedTaskChangeComputeInput): Promise { + private async tryBackfillOpenCodeLedger( + input: ResolvedTaskChangeComputeInput + ): Promise { if (!this.openCodeLedgerBackfillPort) { - return false; + return { attempted: false, backfilled: false }; } if (!(await this.isOpenCodeTeamCandidate(input.teamName))) { - return false; + return { attempted: false, backfilled: false }; } if (typeof this.logsFinder.getLogSourceWatchContext !== 'function') { - return false; + return { attempted: false, backfilled: false }; } const context = await this.logsFinder @@ -400,7 +408,7 @@ export class ChangeExtractorService { !path.isAbsolute(projectDir) || !path.isAbsolute(workspaceRoot) ) { - return false; + return { attempted: false, backfilled: false }; } const sourceGeneration = this.teamLogSourceTracker @@ -433,7 +441,7 @@ export class ChangeExtractorService { const now = Date.now(); const cached = this.openCodeBackfillCache.get(cacheKey); if (cached && cached.expiresAt > now) { - return cached.backfilledAt > 0; + return { attempted: false, backfilled: cached.backfilledAt > 0 }; } this.openCodeBackfillCache.delete(cacheKey); @@ -456,7 +464,7 @@ export class ChangeExtractorService { deliveryContextFingerprint, attributionMode: OPEN_CODE_AUTO_BACKFILL_ATTRIBUTION_MODE, }).catch(() => undefined); - return false; + return { attempted: false, backfilled: false }; } const existing = this.openCodeBackfillInFlight.get(cacheKey); @@ -489,7 +497,7 @@ export class ChangeExtractorService { >, sourceGeneration: string | null, backfillMemberName?: string - ): Promise { + ): Promise { const deliveryContext = await this.createOpenCodeDeliveryContextTempFile( input.teamName, input.taskId, @@ -557,7 +565,7 @@ export class ChangeExtractorService { `OpenCode ledger backfill for ${input.teamName}/${input.taskId}: ${result.outcome}; ${result.diagnostics.join('; ')}` ); } - return backfilled; + return { attempted: true, backfilled }; } catch (error) { logger.warn( `OpenCode ledger backfill failed for ${input.teamName}/${input.taskId}: ${error instanceof Error ? error.message : String(error)}` @@ -583,7 +591,7 @@ export class ChangeExtractorService { } else { this.openCodeBackfillCache.delete(cacheKey); } - return false; + return { attempted: true, backfilled: false }; } finally { await deliveryContext.cleanup(); } diff --git a/test/main/services/team/ChangeExtractorService.test.ts b/test/main/services/team/ChangeExtractorService.test.ts index 81f8a1d3..384dcf03 100644 --- a/test/main/services/team/ChangeExtractorService.test.ts +++ b/test/main/services/team/ChangeExtractorService.test.ts @@ -126,6 +126,70 @@ async function writeOpenCodeDeliveryLedger( return filePath; } +async function writeOpenCodeLedgerBundle( + projectDir: string, + projectPath: string, + taskId: string = TASK_ID +): Promise { + const bundleDir = path.join(projectDir, '.board-task-changes', 'bundles'); + await fs.mkdir(bundleDir, { recursive: true }); + await fs.writeFile( + path.join(bundleDir, `${encodeURIComponent(taskId)}.json`), + JSON.stringify({ + schemaVersion: 1, + source: 'task-change-ledger', + taskId, + generatedAt: '2026-03-01T10:00:00.000Z', + eventCount: 1, + files: [ + { + filePath: path.join(projectPath, 'src/opencode.ts'), + relativePath: 'src/opencode.ts', + eventIds: ['event-1'], + linesAdded: 1, + linesRemoved: 0, + isNewFile: true, + latestAfterHash: null, + }, + ], + totalLinesAdded: 1, + totalLinesRemoved: 0, + totalFiles: 1, + confidence: 'high', + warnings: [], + events: [ + { + schemaVersion: 1, + eventId: 'event-1', + taskId, + taskRef: taskId, + taskRefKind: 'canonical', + phase: 'work', + executionSeq: 0, + sessionId: 'opencode-session-1', + memberName: 'bob', + toolUseId: 'part-1', + source: 'opencode_toolpart_write', + operation: 'create', + confidence: 'exact', + workspaceRoot: projectPath, + filePath: path.join(projectPath, 'src/opencode.ts'), + relativePath: 'src/opencode.ts', + timestamp: '2026-03-01T10:00:00.000Z', + toolStatus: 'succeeded', + before: null, + after: null, + oldString: '', + newString: 'export const source = "opencode";\n', + linesAdded: 1, + linesRemoved: 0, + }, + ], + }), + 'utf8' + ); +} + function persistedEntryPath(baseDir: string): string { return path.join(baseDir, 'task-change-summaries', encodeURIComponent(TEAM_NAME), `${TASK_ID}.json`); } @@ -935,63 +999,7 @@ describe('ChangeExtractorService', () => { await writeOpenCodeDeliveryLedger(tmpDir); const backfillOpenCodeTaskLedger = vi.fn(async (input: any) => { - const bundleDir = path.join(input.projectDir, '.board-task-changes', 'bundles'); - await fs.mkdir(bundleDir, { recursive: true }); - await fs.writeFile( - path.join(bundleDir, `${encodeURIComponent(TASK_ID)}.json`), - JSON.stringify({ - schemaVersion: 1, - source: 'task-change-ledger', - taskId: TASK_ID, - generatedAt: '2026-03-01T10:00:00.000Z', - eventCount: 1, - files: [ - { - filePath: path.join(projectPath, 'src/opencode.ts'), - relativePath: 'src/opencode.ts', - eventIds: ['event-1'], - linesAdded: 1, - linesRemoved: 0, - isNewFile: true, - latestAfterHash: null, - }, - ], - totalLinesAdded: 1, - totalLinesRemoved: 0, - totalFiles: 1, - confidence: 'high', - warnings: [], - events: [ - { - schemaVersion: 1, - eventId: 'event-1', - taskId: TASK_ID, - taskRef: TASK_ID, - taskRefKind: 'canonical', - phase: 'work', - executionSeq: 0, - sessionId: 'opencode-session-1', - memberName: 'bob', - toolUseId: 'part-1', - source: 'opencode_toolpart_write', - operation: 'create', - confidence: 'exact', - workspaceRoot: projectPath, - filePath: path.join(projectPath, 'src/opencode.ts'), - relativePath: 'src/opencode.ts', - timestamp: '2026-03-01T10:00:00.000Z', - toolStatus: 'succeeded', - before: null, - after: null, - oldString: '', - newString: 'export const source = "opencode";\n', - linesAdded: 1, - linesRemoved: 0, - }, - ], - }), - 'utf8' - ); + await writeOpenCodeLedgerBundle(input.projectDir, projectPath); return { schemaVersion: 1, providerId: 'opencode', @@ -1064,6 +1072,64 @@ describe('ChangeExtractorService', () => { expect(workerClient.computeTaskChanges).not.toHaveBeenCalled(); }); + it('rereads ledger when OpenCode backfill writes artifacts and then fails', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-')); + setClaudeBasePathOverride(tmpDir); + await writeTaskFile(tmpDir, { displayId: 'abc12345', owner: 'bob' }); + 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); + + const backfillOpenCodeTaskLedger = vi.fn(async (input: any) => { + await writeOpenCodeLedgerBundle(input.projectDir, projectPath); + throw new Error('timeout after import'); + }); + 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 + ); + + const result = await service.getTaskChanges(TEAM_NAME, TASK_ID, { + owner: 'bob', + status: 'completed', + }); + + expect(result.files).toHaveLength(1); + expect(result.files[0]?.snippets[0]?.toolName).toBe('Write'); + expect(backfillOpenCodeTaskLedger).toHaveBeenCalledTimes(1); + 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);