diff --git a/src/main/services/team/ChangeExtractorService.ts b/src/main/services/team/ChangeExtractorService.ts index 26e53f0d..a28b758c 100644 --- a/src/main/services/team/ChangeExtractorService.ts +++ b/src/main/services/team/ChangeExtractorService.ts @@ -14,6 +14,7 @@ import * as os from 'os'; import * as path from 'path'; import { JsonTaskChangeSummaryCacheRepository } from './cache/JsonTaskChangeSummaryCacheRepository'; +import { OPEN_CODE_TASK_LEDGER_EVIDENCE_CONTRACT_VERSION } from './opencode/bridge/OpenCodeBridgeCommandContract'; import { getOpenCodeLaneScopedRuntimeFilePath, getOpenCodeTeamRuntimeDirectory, @@ -533,9 +534,25 @@ export class ChangeExtractorService { } : {}), }); + const evidenceContractVersion = + typeof result.opencodeTaskLedgerEvidenceContractVersion === 'number' && + Number.isInteger(result.opencodeTaskLedgerEvidenceContractVersion) + ? result.opencodeTaskLedgerEvidenceContractVersion + : 0; + const hasExpectedEvidenceContract = + evidenceContractVersion >= OPEN_CODE_TASK_LEDGER_EVIDENCE_CONTRACT_VERSION; + const diagnostics = hasExpectedEvidenceContract + ? (result.diagnostics ?? []) + : [ + `OpenCode task ledger evidence contract is unsupported or missing: ${evidenceContractVersion}.`, + ...(result.diagnostics ?? []), + ]; void appendOpenCodeTaskChangeDiag({ event: 'backfill_result', - reason: this.classifyOpenCodeBackfillResult(result), + reason: + !hasExpectedEvidenceContract && result.importedEvents <= 0 + ? 'unsupported-evidence-contract' + : this.classifyOpenCodeBackfillResult(result), teamName: input.teamName, taskId: input.taskId, displayId: input.taskMeta?.displayId ?? null, @@ -546,6 +563,7 @@ export class ChangeExtractorService { deliveryRecordCount: deliveryContextRecords.length, deliveryContextFingerprint: deliveryContextPayload.hash, result: { + opencodeTaskLedgerEvidenceContractVersion: evidenceContractVersion, attributionMode: result.attributionMode ?? OPEN_CODE_AUTO_BACKFILL_ATTRIBUTION_MODE, outcome: result.outcome, dryRun: result.dryRun, @@ -555,13 +573,14 @@ export class ChangeExtractorService { importedEvents: result.importedEvents, skippedEvents: result.skippedEvents, }, - diagnostics: (result.diagnostics ?? []).slice(0, 25), + diagnostics: diagnostics.slice(0, 25), notices: (result.notices ?? []).slice(0, 25), }).catch(() => undefined); const backfilled = result.importedEvents > 0 || - result.outcome === 'imported' || - (result.outcome === 'duplicates-only' && result.candidateEvents > 0); + (hasExpectedEvidenceContract && + (result.outcome === 'imported' || + (result.outcome === 'duplicates-only' && result.candidateEvents > 0))); if (result.importedEvents > 0) { await this.invalidateTaskChangeSummaries(input.teamName, [input.taskId], { @@ -569,7 +588,7 @@ export class ChangeExtractorService { }); } - if (backfilled || deliveryContextRecords.length === 0) { + if ((hasExpectedEvidenceContract && backfilled) || deliveryContextRecords.length === 0) { this.openCodeBackfillCache.set(cacheKey, { backfilledAt: backfilled ? Date.now() : 0, expiresAt: Date.now() + this.openCodeBackfillCacheTtl, @@ -578,9 +597,9 @@ export class ChangeExtractorService { this.openCodeBackfillCache.delete(cacheKey); } - if (result.diagnostics.length > 0 && result.outcome !== 'no-history') { + if (diagnostics.length > 0 && result.outcome !== 'no-history') { logger.debug( - `OpenCode ledger backfill for ${input.teamName}/${input.taskId}: ${result.outcome}; ${result.diagnostics.join('; ')}` + `OpenCode ledger backfill for ${input.teamName}/${input.taskId}: ${result.outcome}; ${diagnostics.join('; ')}` ); } return { attempted: true, backfilled }; diff --git a/test/main/services/team/ChangeExtractorService.test.ts b/test/main/services/team/ChangeExtractorService.test.ts index daaa5428..eb0535a3 100644 --- a/test/main/services/team/ChangeExtractorService.test.ts +++ b/test/main/services/team/ChangeExtractorService.test.ts @@ -1752,4 +1752,77 @@ describe('ChangeExtractorService', () => { ); 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); + }); });