import * as os from 'os'; import * as path from 'path'; import { createHash } from 'crypto'; import { afterEach, describe, expect, it, vi } from 'vitest'; import * as fs from 'fs/promises'; import { ChangeExtractorService } from '../../../../src/main/services/team/ChangeExtractorService'; import { OPEN_CODE_TASK_LEDGER_EVIDENCE_CONTRACT_VERSION } from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract'; import { buildTaskChangePresenceDescriptor } from '../../../../src/main/services/team/taskChangePresenceUtils'; import { setClaudeBasePathOverride } from '../../../../src/main/utils/pathDecoder'; const TEAM_NAME = 'team-a'; const TASK_ID = '1'; const PROJECT_PATH = '/repo'; const SUMMARY_OPTIONS = { owner: 'alice', status: 'completed', stateBucket: 'completed' as const, summaryOnly: true, }; function buildAssistantWriteEntry(toolUseId: string, filePath: string, content: string, timestamp: string) { return { timestamp, type: 'assistant', message: { role: 'assistant', content: [ { type: 'tool_use', id: toolUseId, name: 'Write', input: { file_path: filePath, content }, }, ], }, }; } async function writeJsonl(filePath: string, entries: object[]): Promise { await fs.writeFile(filePath, entries.map((entry) => JSON.stringify(entry)).join('\n') + '\n', 'utf8'); } async function writeTaskFile( baseDir: string, overrides?: Record ): Promise { const taskPath = path.join(baseDir, 'tasks', TEAM_NAME, `${TASK_ID}.json`); await fs.mkdir(path.dirname(taskPath), { recursive: true }); await fs.writeFile( taskPath, JSON.stringify( { id: TASK_ID, owner: 'alice', status: 'completed', createdAt: '2026-03-01T09:55:00.000Z', updatedAt: '2026-03-01T10:10:00.000Z', workIntervals: [{ startedAt: '2026-03-01T10:00:00.000Z', completedAt: '2026-03-01T10:10:00.000Z' }], historyEvents: [], ...overrides, }, null, 2 ), 'utf8' ); return taskPath; } async function writeOpenCodeDeliveryLedger( baseDir: string, overrides?: Partial<{ memberName: string; laneId: string; runtimeSessionId: string; inboxMessageId: string; deliveredUserMessageId: string; observedAssistantMessageId: string | null; taskId: string; displayId: string; teamName: string; taskRefs: { taskId: string; displayId: string; teamName: string }[]; }> ): Promise { const memberName = overrides?.memberName ?? 'bob'; const laneId = overrides?.laneId ?? `secondary:opencode:${memberName}`; const filePath = path.join( baseDir, 'teams', overrides?.teamName ?? TEAM_NAME, '.opencode-runtime', 'lanes', encodeURIComponent(laneId), 'opencode-prompt-delivery-ledger.json' ); await fs.mkdir(path.dirname(filePath), { recursive: true }); await fs.writeFile( filePath, JSON.stringify( { data: [ { teamName: overrides?.teamName ?? TEAM_NAME, memberName, laneId, runtimeSessionId: overrides?.runtimeSessionId ?? 'session-1', inboxMessageId: overrides?.inboxMessageId ?? 'user-1', deliveredUserMessageId: overrides?.deliveredUserMessageId ?? 'user-1', observedAssistantMessageId: overrides?.observedAssistantMessageId ?? null, prePromptCursor: null, postPromptCursor: null, taskRefs: overrides?.taskRefs ?? [ { taskId: overrides?.taskId ?? TASK_ID, displayId: overrides?.displayId ?? 'abc12345', teamName: overrides?.teamName ?? TEAM_NAME, }, ], }, ], }, null, 2 ), 'utf8' ); 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`); } function deferred() { let resolve!: (value: T) => void; let reject!: (error?: unknown) => void; const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); return { promise, resolve, reject }; } function makeTaskChangeResult( taskId = TASK_ID, overrides: Partial<{ teamName: string; taskId: string; filePath: string; confidence: 'high' | 'medium' | 'low' | 'fallback'; content: string; warning: string; scope: Partial<{ memberName: string; startTimestamp: string; endTimestamp: string; toolUseIds: string[]; filePaths: string[]; confidence: { tier: 1 | 2 | 3 | 4; label: 'high' | 'medium' | 'low' | 'fallback'; reason: string; }; }>; }> = {} ) { const teamName = overrides.teamName ?? TEAM_NAME; const targetTaskId = overrides.taskId ?? taskId; const filePath = overrides.filePath ?? '/repo/src/file.ts'; const content = overrides.content ?? 'export const value = 1;\n'; const confidence = overrides.confidence ?? 'high'; const confidenceTierByLabel = { high: 1, medium: 2, low: 3, fallback: 4, } as const; const files = content.length > 0 ? [ { filePath, relativePath: 'src/file.ts', snippets: [], linesAdded: 1, linesRemoved: 0, isNewFile: true, }, ] : []; return { teamName, taskId: targetTaskId, files, totalFiles: files.length, totalLinesAdded: files.reduce((sum, file) => sum + file.linesAdded, 0), totalLinesRemoved: files.reduce((sum, file) => sum + file.linesRemoved, 0), confidence, computedAt: '2026-03-01T12:00:00.000Z', scope: { taskId: targetTaskId, memberName: overrides.scope?.memberName ?? 'alice', startLine: 0, endLine: 0, startTimestamp: overrides.scope?.startTimestamp ?? '', endTimestamp: overrides.scope?.endTimestamp ?? '', toolUseIds: overrides.scope?.toolUseIds ?? [], filePaths: overrides.scope?.filePaths ?? files.map((file) => file.filePath), confidence: overrides.scope?.confidence ?? { tier: confidenceTierByLabel[confidence], label: confidence, reason: 'test fixture', }, }, warnings: overrides.warning ? [overrides.warning] : [], }; } function createService(params: { logPaths: string[]; projectPath?: string; findLogFileRefsForTask?: (teamName: string, taskId: string, options?: unknown) => Promise; taskChangePresenceRepository?: { upsertEntry: ReturnType }; teamLogSourceTracker?: { ensureTracking: ReturnType< typeof vi.fn<() => Promise<{ projectFingerprint: string | null; logSourceGeneration: string | null }>> >; }; taskChangeWorkerClient?: { isAvailable: ReturnType boolean>>; computeTaskChanges: ReturnType Promise>>; }; }) { const findLogFileRefsForTask = params.findLogFileRefsForTask ?? vi.fn(async () => params.logPaths.map((filePath) => ({ filePath, memberName: 'alice' }))); const taskChangeWorkerClient = params.taskChangeWorkerClient ?? ({ isAvailable: vi.fn(() => false), computeTaskChanges: vi.fn(async () => { throw new Error('worker disabled in test'); }), } as const); const service = new ChangeExtractorService( { findLogFileRefsForTask, 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: params.projectPath ?? PROJECT_PATH })) } as any, undefined, taskChangeWorkerClient as any ); if (params.taskChangePresenceRepository && params.teamLogSourceTracker) { service.setTaskChangePresenceServices( params.taskChangePresenceRepository as any, params.teamLogSourceTracker as any ); } return { findLogFileRefsForTask, service, }; } describe('ChangeExtractorService', () => { let tmpDir: string | null = null; afterEach(async () => { setClaudeBasePathOverride(null); if (tmpDir) { await fs.rm(tmpDir, { recursive: true, force: true }); tmpDir = null; } }); it('does not reuse detailed task-change cache across different scope inputs', async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-')); setClaudeBasePathOverride(tmpDir); const aliceLogPath = path.join(tmpDir, 'alice.jsonl'); await writeJsonl(aliceLogPath, [ buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'), ]); const findLogFileRefsForTask = vi.fn(async (_teamName: string, _taskId: string, options?: any) => options?.owner === 'alice' ? [{ filePath: aliceLogPath, memberName: 'alice' }] : [] ); const service = createService({ logPaths: [aliceLogPath], findLogFileRefsForTask }).service; const empty = await service.getTaskChanges(TEAM_NAME, TASK_ID, { owner: 'bob', status: 'completed' }); const populated = await service.getTaskChanges(TEAM_NAME, TASK_ID, { owner: 'alice', status: 'completed', }); expect(empty.files).toHaveLength(0); expect(populated.files).toHaveLength(1); expect(findLogFileRefsForTask).toHaveBeenCalledTimes(2); }); it('caches terminal summary requests in memory but keeps detailed requests fresh', async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-')); setClaudeBasePathOverride(tmpDir); const logPath = path.join(tmpDir, 'alice-summary.jsonl'); await writeJsonl(logPath, [ buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'), ]); const { service, findLogFileRefsForTask } = createService({ logPaths: [logPath] }); await service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS); await service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS); await service.getTaskChanges(TEAM_NAME, TASK_ID, { owner: 'alice', status: 'completed', stateBucket: 'completed', }); await service.getTaskChanges(TEAM_NAME, TASK_ID, { owner: 'alice', status: 'completed', stateBucket: 'completed', }); expect(findLogFileRefsForTask).toHaveBeenCalledTimes(3); }); it('restores a persisted terminal summary after a simulated restart', async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-')); setClaudeBasePathOverride(tmpDir); await writeTaskFile(tmpDir); const logPath = path.join(tmpDir, 'alice-restart.jsonl'); await writeJsonl(logPath, [ buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'), ]); const first = createService({ logPaths: [logPath] }); const initial = await first.service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS); const second = createService({ logPaths: [logPath] }); const restored = await second.service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS); expect(initial.files).toHaveLength(1); expect(restored.files).toHaveLength(1); expect(await fs.readFile(persistedEntryPath(tmpDir), 'utf8')).toContain('"taskId": "1"'); // The second service restores from persisted cache; findLogFileRefsForTask may be called // at most once for background validation (setTimeout(0) in schedulePersistedTaskChangeSummaryValidation) expect((second.findLogFileRefsForTask as any).mock.calls.length).toBeLessThanOrEqual(1); }); it('forceFresh overwrites the persisted terminal summary snapshot', async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-')); setClaudeBasePathOverride(tmpDir); await writeTaskFile(tmpDir); const logPath = path.join(tmpDir, 'alice-refresh.jsonl'); await writeJsonl(logPath, [ buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'), ]); const { service } = createService({ logPaths: [logPath] }); await service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS); await writeJsonl(logPath, [ buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 2;\n', '2026-03-01T10:00:00.000Z'), buildAssistantWriteEntry('tool-2', '/repo/src/extra.ts', 'export const extra = true;\n', '2026-03-01T10:02:00.000Z'), ]); const refreshed = await service.getTaskChanges(TEAM_NAME, TASK_ID, { ...SUMMARY_OPTIONS, forceFresh: true, }); const after = await createService({ logPaths: [logPath] }).service.getTaskChanges( TEAM_NAME, TASK_ID, SUMMARY_OPTIONS ); expect(refreshed.totalFiles).toBe(2); expect(after.totalFiles).toBe(2); }); it('invalidates old terminal summaries when the task moves into review', async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-')); setClaudeBasePathOverride(tmpDir); await writeTaskFile(tmpDir); const logPath = path.join(tmpDir, 'alice-review.jsonl'); await writeJsonl(logPath, [ buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'), ]); const { service } = createService({ logPaths: [logPath] }); await service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS); await writeTaskFile(tmpDir, { historyEvents: [ { id: 'evt-review', type: 'review_requested', to: 'review', timestamp: '2026-03-01T11:00:00.000Z', }, ], }); await service.getTaskChanges(TEAM_NAME, TASK_ID, { owner: 'alice', status: 'completed', stateBucket: 'review', summaryOnly: true, }); await expect(fs.stat(persistedEntryPath(tmpDir))).rejects.toMatchObject({ code: 'ENOENT' }); }); it('rejects persisted summaries after project/worktree drift', async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-')); setClaudeBasePathOverride(tmpDir); await writeTaskFile(tmpDir); const logPath = path.join(tmpDir, 'alice-project-drift.jsonl'); await writeJsonl(logPath, [ buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'), ]); await createService({ logPaths: [logPath], projectPath: '/repo-a' }).service.getTaskChanges( TEAM_NAME, TASK_ID, SUMMARY_OPTIONS ); const drifted = createService({ logPaths: [logPath], projectPath: '/repo-b' }); await drifted.service.getTaskChanges( TEAM_NAME, TASK_ID, SUMMARY_OPTIONS ); expect((drifted.findLogFileRefsForTask as any).mock.calls.length).toBeGreaterThan(1); }); it('rejects persisted summaries when the task file is missing on restart', async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-')); setClaudeBasePathOverride(tmpDir); const taskPath = await writeTaskFile(tmpDir); const logPath = path.join(tmpDir, 'alice-missing-task.jsonl'); await writeJsonl(logPath, [ buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'), ]); await createService({ logPaths: [logPath] }).service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS); await fs.unlink(taskPath); await createService({ logPaths: [logPath] }).service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS); await expect(fs.stat(persistedEntryPath(tmpDir))).rejects.toMatchObject({ code: 'ENOENT' }); }); it('falls back safely when the persisted summary file is corrupted', async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-')); setClaudeBasePathOverride(tmpDir); await writeTaskFile(tmpDir); const logPath = path.join(tmpDir, 'alice-corrupt.jsonl'); await writeJsonl(logPath, [ buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'), ]); await createService({ logPaths: [logPath] }).service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS); vi.spyOn(console, 'warn').mockImplementation(() => {}); await fs.writeFile(persistedEntryPath(tmpDir), '{bad-json', 'utf8'); const restored = await createService({ logPaths: [logPath] }).service.getTaskChanges( TEAM_NAME, TASK_ID, SUMMARY_OPTIONS ); expect(restored.files).toHaveLength(1); }); it('does not persist low-confidence fallback summaries', async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-')); setClaudeBasePathOverride(tmpDir); await writeTaskFile(tmpDir, { workIntervals: [], historyEvents: [] }); const logPath = path.join(tmpDir, 'alice-fallback.jsonl'); await writeJsonl(logPath, [ buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'), ]); const service = new ChangeExtractorService( { findLogFileRefsForTask: vi.fn(async () => [{ filePath: logPath, memberName: 'alice' }]), findMemberLogPaths: vi.fn(async () => []), } as any, { parseBoundaries: vi.fn(async () => ({ boundaries: [], scopes: [], isSingleTaskSession: false, detectedMechanism: 'none' as const, })), } as any, { getConfig: vi.fn(async () => ({ projectPath: PROJECT_PATH })) } as any, undefined, { isAvailable: vi.fn(() => false), computeTaskChanges: vi.fn(async () => { throw new Error('worker disabled in test'); }), } as any ); const result = await service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS); expect(result.confidence).toBe('fallback'); await expect(fs.stat(persistedEntryPath(tmpDir))).rejects.toMatchObject({ code: 'ENOENT' }); }); it('merges fallback changes for the same Windows file across slash variants', async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-')); setClaudeBasePathOverride(tmpDir); const firstLogPath = path.join(tmpDir, 'first.jsonl'); const secondLogPath = path.join(tmpDir, 'second.jsonl'); await writeJsonl(firstLogPath, [ buildAssistantWriteEntry('tool-1', 'C:\\repo\\src\\same.ts', 'first\n', '2026-03-01T10:00:00.000Z'), ]); await writeJsonl(secondLogPath, [ buildAssistantWriteEntry('tool-2', 'C:/repo/src/same.ts', 'second\n', '2026-03-01T10:01:00.000Z'), ]); const service = createService({ logPaths: [firstLogPath, secondLogPath], projectPath: 'C:\\repo', }).service; const result = await service.getTaskChanges(TEAM_NAME, TASK_ID, { owner: 'alice', status: 'completed', }); expect(result.files).toHaveLength(1); expect(result.files[0]?.relativePath).toBe('src/same.ts'); expect(result.totalLinesAdded).toBe(2); }); it('prefers worker task-change results when the worker is available', async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-')); setClaudeBasePathOverride(tmpDir); await writeTaskFile(tmpDir); const workerResult = makeTaskChangeResult(); const computeTaskChanges = vi.fn(async () => workerResult); const { service, findLogFileRefsForTask } = createService({ logPaths: [], taskChangeWorkerClient: { isAvailable: vi.fn(() => true), computeTaskChanges, }, }); const result = await service.getTaskChanges(TEAM_NAME, TASK_ID, { owner: 'alice', status: 'completed', }); expect(result).toEqual(workerResult); expect(computeTaskChanges).toHaveBeenCalledTimes(1); expect(findLogFileRefsForTask).not.toHaveBeenCalled(); }); it('falls back inline when task-change worker is unavailable', async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-')); setClaudeBasePathOverride(tmpDir); await writeTaskFile(tmpDir); const logPath = path.join(tmpDir, 'alice-inline-unavailable.jsonl'); await writeJsonl(logPath, [ buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'), ]); const computeTaskChanges = vi.fn(); const { service, findLogFileRefsForTask } = createService({ logPaths: [logPath], taskChangeWorkerClient: { isAvailable: vi.fn(() => false), computeTaskChanges, }, }); const result = await service.getTaskChanges(TEAM_NAME, TASK_ID, { owner: 'alice', status: 'completed', }); expect(result.files).toHaveLength(1); expect(findLogFileRefsForTask).toHaveBeenCalled(); expect(computeTaskChanges).not.toHaveBeenCalled(); }); it('falls back inline when task-change worker throws', async () => { vi.spyOn(console, 'warn').mockImplementation(() => {}); tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-')); setClaudeBasePathOverride(tmpDir); await writeTaskFile(tmpDir); const logPath = path.join(tmpDir, 'alice-inline-worker-error.jsonl'); await writeJsonl(logPath, [ buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'), ]); const computeTaskChanges = vi.fn(async () => { throw new Error('worker failed'); }); const { service, findLogFileRefsForTask } = createService({ logPaths: [logPath], taskChangeWorkerClient: { isAvailable: vi.fn(() => true), computeTaskChanges, }, }); const result = await service.getTaskChanges(TEAM_NAME, TASK_ID, { owner: 'alice', status: 'completed', }); expect(result.files).toHaveLength(1); expect(computeTaskChanges).toHaveBeenCalledTimes(1); expect(findLogFileRefsForTask).toHaveBeenCalled(); }); it('keeps summary cache in main and skips worker on repeat terminal summary requests', async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-')); setClaudeBasePathOverride(tmpDir); await writeTaskFile(tmpDir); const logPath = path.join(tmpDir, 'alice-worker-summary-cache.jsonl'); await writeJsonl(logPath, [ buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'), ]); const computeTaskChanges = vi.fn(async () => makeTaskChangeResult()); const { service } = createService({ logPaths: [logPath], taskChangeWorkerClient: { isAvailable: vi.fn(() => true), computeTaskChanges, }, }); await service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS); await service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS); expect(computeTaskChanges).toHaveBeenCalledTimes(1); }); it('restores persisted summaries without invoking worker compute', async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-')); setClaudeBasePathOverride(tmpDir); await writeTaskFile(tmpDir); const logPath = path.join(tmpDir, 'alice-worker-persisted.jsonl'); await writeJsonl(logPath, [ buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'), ]); const firstWorker = { isAvailable: vi.fn(() => true), computeTaskChanges: vi.fn(async () => makeTaskChangeResult()), }; await createService({ logPaths: [logPath], taskChangeWorkerClient: firstWorker, }).service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS); const secondWorker = { isAvailable: vi.fn(() => true), computeTaskChanges: vi.fn(async () => makeTaskChangeResult(TASK_ID, { content: 'stale\n' })), }; const restored = await createService({ logPaths: [logPath], taskChangeWorkerClient: secondWorker, }).service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS); expect(restored.files).toHaveLength(1); expect(secondWorker.computeTaskChanges).not.toHaveBeenCalled(); }); it('does not let stale worker results populate summary cache after invalidation', async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-')); setClaudeBasePathOverride(tmpDir); await writeTaskFile(tmpDir); const first = deferred>(); const firstStarted = deferred(); const worker = { isAvailable: vi.fn(() => true), computeTaskChanges: vi .fn() .mockImplementationOnce(() => { firstStarted.resolve(); return first.promise; }) .mockImplementationOnce(async () => makeTaskChangeResult(TASK_ID, { filePath: '/repo/src/newer.ts' }) ), }; const { service } = createService({ logPaths: [], taskChangeWorkerClient: worker, }); const stalePromise = service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS); await firstStarted.promise; await service.invalidateTaskChangeSummaries(TEAM_NAME, [TASK_ID], { deletePersisted: true }); const freshPromise = service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS); // Flush microtasks so freshPromise advances past its internal awaits // and reaches the worker mock before we resolve the stale deferred. // Without this, CI timing can cause the stale resolution to race with // the fresh worker call, making the test flaky. await vi.advanceTimersByTimeAsync?.(0).catch(() => undefined); await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); first.resolve(makeTaskChangeResult()); const stale = await stalePromise; const fresh = await freshPromise; expect(stale.files[0]?.filePath).toBe('/repo/src/file.ts'); expect(fresh.files[0]?.filePath).toBe('/repo/src/newer.ts'); expect(worker.computeTaskChanges).toHaveBeenCalledTimes(2); }); it('writes has_changes presence entries after successful task diff computation', async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-')); setClaudeBasePathOverride(tmpDir); await writeTaskFile(tmpDir); const logPath = path.join(tmpDir, 'alice-presence.jsonl'); await writeJsonl(logPath, [ buildAssistantWriteEntry( 'tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z' ), ]); const upsertEntry = vi.fn(async () => undefined); const ensureTracking = vi.fn(async () => ({ projectFingerprint: 'project-fingerprint', logSourceGeneration: 'log-generation', })); const workerClient = { isAvailable: vi.fn(() => true), computeTaskChanges: vi.fn(async () => makeTaskChangeResult()), }; const { service } = createService({ logPaths: [logPath], taskChangePresenceRepository: { upsertEntry }, teamLogSourceTracker: { ensureTracking }, taskChangeWorkerClient: workerClient, }); await service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS); expect(upsertEntry).toHaveBeenCalledWith( TEAM_NAME, expect.objectContaining({ projectFingerprint: 'project-fingerprint', logSourceGeneration: 'log-generation', }), expect.objectContaining({ taskId: TASK_ID, presence: 'has_changes', taskSignature: buildTaskChangePresenceDescriptor({ createdAt: '2026-03-01T09:55:00.000Z', owner: 'alice', status: 'completed', intervals: [ { startedAt: '2026-03-01T10:00:00.000Z', completedAt: '2026-03-01T10:10:00.000Z', }, ], reviewState: 'none', historyEvents: [], }).taskSignature, }) ); }); it('writes needs_attention presence entries for warning-only task diff results', async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-')); setClaudeBasePathOverride(tmpDir); await writeTaskFile(tmpDir); const upsertEntry = vi.fn(async () => undefined); const ensureTracking = vi.fn(async () => ({ projectFingerprint: 'project-fingerprint', logSourceGeneration: 'log-generation', })); const workerClient = { isAvailable: vi.fn(() => true), computeTaskChanges: vi.fn(async () => makeTaskChangeResult(TASK_ID, { content: '', confidence: 'fallback', warning: 'Ledger skipped attribution because multiple task scopes were active.', }) ), }; const { service } = createService({ logPaths: [], taskChangePresenceRepository: { upsertEntry }, teamLogSourceTracker: { ensureTracking }, taskChangeWorkerClient: workerClient, }); const result = await service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS); expect(result.files).toHaveLength(0); expect(result.warnings).toEqual([ 'Ledger skipped attribution because multiple task scopes were active.', ]); expect(upsertEntry).toHaveBeenCalledWith( TEAM_NAME, expect.objectContaining({ projectFingerprint: 'project-fingerprint', logSourceGeneration: 'log-generation', }), expect.objectContaining({ taskId: TASK_ID, presence: 'needs_attention', }) ); }); it('does not write warning-only presence for active interval summaries with no observed file edits yet', async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-')); setClaudeBasePathOverride(tmpDir); await writeTaskFile(tmpDir); const upsertEntry = vi.fn(async () => undefined); const ensureTracking = vi.fn(async () => ({ projectFingerprint: 'project-fingerprint', logSourceGeneration: 'log-generation', })); const workerClient = { isAvailable: vi.fn(() => true), computeTaskChanges: vi.fn(async () => makeTaskChangeResult(TASK_ID, { content: '', confidence: 'medium', warning: 'No file edits found within persisted workIntervals.', scope: { memberName: 'echo', startTimestamp: '2026-03-01T12:00:00.000Z', endTimestamp: '', toolUseIds: [], filePaths: [], confidence: { tier: 2, label: 'medium', reason: 'Scoped by persisted task workIntervals (timestamp-based)', }, }, }) ), }; const { service } = createService({ logPaths: [], taskChangePresenceRepository: { upsertEntry }, teamLogSourceTracker: { ensureTracking }, taskChangeWorkerClient: workerClient, }); const result = await service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS); expect(result.files).toHaveLength(0); expect(result.warnings).toEqual(['No file edits found within persisted workIntervals.']); expect(upsertEntry).not.toHaveBeenCalled(); }); it('does not write no_changes presence entries for uncertain empty task diff results', async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-')); setClaudeBasePathOverride(tmpDir); await writeTaskFile(tmpDir); const upsertEntry = vi.fn(async () => undefined); const ensureTracking = vi.fn(async () => ({ projectFingerprint: 'project-fingerprint', logSourceGeneration: 'log-generation', })); const workerClient = { isAvailable: vi.fn(() => true), computeTaskChanges: vi.fn(async () => makeTaskChangeResult(TASK_ID, { content: '', confidence: 'fallback' })), }; const { service } = createService({ logPaths: [], taskChangePresenceRepository: { upsertEntry }, teamLogSourceTracker: { ensureTracking }, taskChangeWorkerClient: workerClient, }); const result = await service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS); expect(result.files).toHaveLength(0); expect(result.confidence === 'high' || result.confidence === 'medium').toBe(false); expect(upsertEntry).not.toHaveBeenCalled(); }); it('backfills OpenCode ledger artifacts once before falling back to legacy extraction', async () => { 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); let deliveryContextHashVerified = false; const backfillOpenCodeTaskLedger = vi.fn(async (input: any) => { deliveryContextHashVerified = createHash('sha256') .update(await fs.readFile(input.deliveryContextPath, 'utf8')) .digest('hex') === input.deliveryContextHash; await writeOpenCodeLedgerBundle(input.projectDir, projectPath); return { schemaVersion: 1, providerId: 'opencode', teamName: input.teamName, taskId: input.taskId, projectDir: input.projectDir, workspaceRoot: input.workspaceRoot, dryRun: false, scannedSessions: 1, scannedToolparts: 1, candidateEvents: 1, importedEvents: 1, skippedEvents: 0, outcome: 'imported', 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 ); 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).toHaveBeenCalledWith( expect.objectContaining({ teamName: TEAM_NAME, taskId: TASK_ID, taskDisplayId: 'abc12345', memberName: 'bob', projectDir, workspaceRoot: projectPath, attributionMode: 'strict-delivery', }) ); const backfillInput = backfillOpenCodeTaskLedger.mock.calls[0]?.[0]; expect(backfillInput.deliveryContextPath).toEqual( expect.stringContaining('delivery-context.json') ); expect(backfillInput.deliveryContextHash).toMatch(/^[a-f0-9]{64}$/); expect(deliveryContextHashVerified).toBe(true); 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); 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('ignores OpenCode delivery records that match only a recreated task display id', async () => { 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, { taskId: 'old-task', displayId: 'abc12345', memberName: 'bob', }); const backfillOpenCodeTaskLedger = vi.fn(async () => { throw new Error('display-id-only delivery record must not backfill'); }); 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(0); expect(backfillOpenCodeTaskLedger).not.toHaveBeenCalled(); expect(workerClient.computeTaskChanges).toHaveBeenCalledTimes(1); }); it('ignores OpenCode delivery records that only mention related tasks', async () => { 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, { taskId: 'related-task', displayId: 'def67890', memberName: 'bob', }); const backfillOpenCodeTaskLedger = vi.fn(async () => { throw new Error('related-only delivery record must not backfill'); }); 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(0); expect(backfillOpenCodeTaskLedger).not.toHaveBeenCalled(); expect(workerClient.computeTaskChanges).toHaveBeenCalledTimes(1); }); 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); 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 fs.mkdir(path.join(tmpDir, 'teams', TEAM_NAME, '.opencode-runtime'), { recursive: true, }); const backfillOpenCodeTaskLedger = vi.fn(async () => { throw new Error('OpenCode backfill should not run for non-OpenCode teams'); }); const workerClient = { isAvailable: vi.fn(() => true), computeTaskChanges: vi.fn(async () => makeTaskChangeResult(TASK_ID)), }; 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, members: [{ name: 'alice', providerId: 'codex' }], })), } as any, undefined, workerClient as any, { backfillOpenCodeTaskLedger } as any, { getMeta: vi.fn(async () => ({ providerId: 'codex' })) } as any ); const result = await service.getTaskChanges(TEAM_NAME, TASK_ID, { owner: 'alice', status: 'completed', }); expect(result.files).toHaveLength(1); expect(workerClient.computeTaskChanges).toHaveBeenCalledTimes(1); expect(backfillOpenCodeTaskLedger).not.toHaveBeenCalled(); }); it('awaits OpenCode backfill for summary-only requests with delivery context before falling back', async () => { 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 pendingBackfill = deferred(); const backfillOpenCodeTaskLedger = vi.fn(() => pendingBackfill.promise); 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 ); let settled = false; const request = service .getTaskChanges(TEAM_NAME, TASK_ID, { ...SUMMARY_OPTIONS, owner: 'bob' }) .finally(() => { settled = true; }); await vi.waitFor(() => { expect(backfillOpenCodeTaskLedger).toHaveBeenCalledWith( expect.objectContaining({ teamName: TEAM_NAME, taskId: TASK_ID, projectDir, workspaceRoot: projectPath, deliveryContextPath: expect.stringContaining('delivery-context.json'), deliveryContextHash: expect.stringMatching(/^[a-f0-9]{64}$/), attributionMode: 'strict-delivery', }) ); }); expect(settled).toBe(false); expect(workerClient.computeTaskChanges).not.toHaveBeenCalled(); pendingBackfill.resolve({ schemaVersion: 1, providerId: 'opencode', teamName: TEAM_NAME, taskId: TASK_ID, projectDir, workspaceRoot: projectPath, dryRun: false, scannedSessions: 0, scannedToolparts: 0, candidateEvents: 0, importedEvents: 0, skippedEvents: 0, outcome: 'no-history', notices: [], diagnostics: [], }); const result = await request; expect(result.files).toHaveLength(0); expect(workerClient.computeTaskChanges).toHaveBeenCalledTimes(1); }); it('does not reuse a negative OpenCode backfill cache entry after delivery context appears', async () => { 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 }); 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-attribution', 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: 'bob', status: 'completed', }); expect(backfillOpenCodeTaskLedger).not.toHaveBeenCalled(); await writeOpenCodeDeliveryLedger(tmpDir); await service.getTaskChanges(TEAM_NAME, TASK_ID, { owner: 'bob', status: 'completed', }); expect(backfillOpenCodeTaskLedger).toHaveBeenCalledTimes(1); expect(backfillOpenCodeTaskLedger.mock.calls[0]?.[0]?.deliveryContextPath).toEqual( expect.stringContaining('delivery-context.json') ); expect(backfillOpenCodeTaskLedger.mock.calls[0]?.[0]?.deliveryContextHash).toMatch(/^[a-f0-9]{64}$/); }); it('does not cache negative OpenCode backfill while delivery context already exists', async () => { 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 }); const deliveryLedgerPath = path.join( tmpDir, 'teams', TEAM_NAME, '.opencode-runtime', 'lanes', encodeURIComponent('secondary:opencode:bob'), 'opencode-prompt-delivery-ledger.json' ); await fs.mkdir(path.dirname(deliveryLedgerPath), { recursive: true }); await fs.writeFile( deliveryLedgerPath, JSON.stringify({ data: [ { teamName: TEAM_NAME, memberName: 'bob', laneId: 'secondary:opencode:bob', runtimeSessionId: 'session-1', inboxMessageId: 'user-1', deliveredUserMessageId: 'user-1', observedAssistantMessageId: null, prePromptCursor: null, postPromptCursor: null, taskRefs: [{ taskId: TASK_ID, displayId: 'abc12345', teamName: TEAM_NAME }], }, ], }), 'utf8' ); let backfillAttempt = 0; const backfillOpenCodeTaskLedger = vi.fn(async (input: any) => { const outcome = backfillAttempt++ === 0 ? 'transient-error' : 'no-attribution'; return { schemaVersion: 1, providerId: 'opencode', teamName: input.teamName, taskId: input.taskId, projectDir: input.projectDir, workspaceRoot: input.workspaceRoot, dryRun: false, attributionMode: input.attributionMode, scannedSessions: 1, scannedToolparts: 0, candidateEvents: 0, importedEvents: 0, skippedEvents: 0, outcome, notices: [], diagnostics: outcome === 'transient-error' ? ['OpenCode SQLite file changed while snapshot was read; using transaction snapshot.'] : [], }; }); 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: 'bob', status: 'completed', }); await service.getTaskChanges(TEAM_NAME, TASK_ID, { owner: 'bob', status: 'completed', }); expect(backfillOpenCodeTaskLedger).toHaveBeenCalledTimes(2); expect(backfillOpenCodeTaskLedger.mock.calls[0]?.[0]?.deliveryContextPath).toEqual( expect.stringContaining('delivery-context.json') ); expect(backfillOpenCodeTaskLedger.mock.calls[0]?.[0]?.deliveryContextHash).toMatch(/^[a-f0-9]{64}$/); expect(backfillOpenCodeTaskLedger.mock.calls[1]?.[0]?.deliveryContextPath).toEqual( expect.stringContaining('delivery-context.json') ); expect(backfillOpenCodeTaskLedger.mock.calls[1]?.[0]?.deliveryContextHash).toMatch(/^[a-f0-9]{64}$/); }); it('does not cache duplicates-only OpenCode backfill from an old evidence contract', async () => { 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) => ({ schemaVersion: 1, providerId: 'opencode', teamName: input.teamName, taskId: input.taskId, projectDir: input.projectDir, workspaceRoot: input.workspaceRoot, dryRun: false, attributionMode: input.attributionMode, scannedSessions: 1, scannedToolparts: 1, candidateEvents: 1, importedEvents: 0, skippedEvents: 1, outcome: 'duplicates-only', 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: 'bob', status: 'completed', }); await service.getTaskChanges(TEAM_NAME, TASK_ID, { owner: 'bob', status: 'completed', }); expect(backfillOpenCodeTaskLedger).toHaveBeenCalledTimes(2); expect(workerClient.computeTaskChanges).toHaveBeenCalledTimes(2); }); it('caches duplicates-only OpenCode backfill from the current evidence contract', async () => { 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) => ({ schemaVersion: 1, providerId: 'opencode', opencodeTaskLedgerEvidenceContractVersion: OPEN_CODE_TASK_LEDGER_EVIDENCE_CONTRACT_VERSION, teamName: input.teamName, taskId: input.taskId, projectDir: input.projectDir, workspaceRoot: input.workspaceRoot, dryRun: false, attributionMode: input.attributionMode, scannedSessions: 1, scannedToolparts: 1, candidateEvents: 1, importedEvents: 0, skippedEvents: 1, outcome: 'duplicates-only', 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: 'bob', status: 'completed', }); await service.getTaskChanges(TEAM_NAME, TASK_ID, { owner: 'bob', status: 'completed', }); expect(backfillOpenCodeTaskLedger).toHaveBeenCalledTimes(1); expect(workerClient.computeTaskChanges).toHaveBeenCalledTimes(2); }); });