From 50b2c715e7a8fb10e38c05a2c04cecbe8014efbf Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 28 Apr 2026 20:51:05 +0300 Subject: [PATCH 01/18] feat(changes): use default opencode evidence path --- src/main/services/team/ChangeExtractorService.ts | 5 ----- test/main/services/team/ChangeExtractorService.test.ts | 2 -- 2 files changed, 7 deletions(-) diff --git a/src/main/services/team/ChangeExtractorService.ts b/src/main/services/team/ChangeExtractorService.ts index 6a75053d..fe46e6b0 100644 --- a/src/main/services/team/ChangeExtractorService.ts +++ b/src/main/services/team/ChangeExtractorService.ts @@ -46,7 +46,6 @@ import type { AgentChangeSet, ChangeStats, TaskChangeSetV2 } from '@shared/types const logger = createLogger('Service:ChangeExtractorService'); const OPEN_CODE_AUTO_BACKFILL_ATTRIBUTION_MODE = 'strict-delivery' as const; -const OPEN_CODE_AUTO_BACKFILL_EVIDENCE_MODE = 'chain-only' as const; const OPEN_CODE_MAX_DISCOVERED_LANES = 500; /** Кеш-запись: данные + mtime файла + время протухания */ @@ -426,7 +425,6 @@ export class ChangeExtractorService { sourceGeneration, deliveryContextFingerprint, attributionMode: OPEN_CODE_AUTO_BACKFILL_ATTRIBUTION_MODE, - evidenceMode: OPEN_CODE_AUTO_BACKFILL_EVIDENCE_MODE, }); const now = Date.now(); const cached = this.openCodeBackfillCache.get(cacheKey); @@ -501,7 +499,6 @@ export class ChangeExtractorService { projectDir, workspaceRoot, attributionMode: OPEN_CODE_AUTO_BACKFILL_ATTRIBUTION_MODE, - evidenceMode: OPEN_CODE_AUTO_BACKFILL_EVIDENCE_MODE, ...(deliveryContext.filePath ? { deliveryContextPath: deliveryContext.filePath } : {}), }); void appendOpenCodeTaskChangeDiag({ @@ -841,7 +838,6 @@ export class ChangeExtractorService { sourceGeneration?: string | null; deliveryContextFingerprint: string; attributionMode: typeof OPEN_CODE_AUTO_BACKFILL_ATTRIBUTION_MODE; - evidenceMode: typeof OPEN_CODE_AUTO_BACKFILL_EVIDENCE_MODE; }): string { return JSON.stringify({ teamName: input.teamName, @@ -852,7 +848,6 @@ export class ChangeExtractorService { sourceGeneration: input.sourceGeneration ?? '', deliveryContextFingerprint: input.deliveryContextFingerprint, attributionMode: input.attributionMode, - evidenceMode: input.evidenceMode, }); } diff --git a/test/main/services/team/ChangeExtractorService.test.ts b/test/main/services/team/ChangeExtractorService.test.ts index 302c35b1..fdd32b5b 100644 --- a/test/main/services/team/ChangeExtractorService.test.ts +++ b/test/main/services/team/ChangeExtractorService.test.ts @@ -1058,7 +1058,6 @@ describe('ChangeExtractorService', () => { projectDir, workspaceRoot: projectPath, attributionMode: 'strict-delivery', - evidenceMode: 'chain-only', }) ); expect(workerClient.computeTaskChanges).not.toHaveBeenCalled(); @@ -1183,7 +1182,6 @@ describe('ChangeExtractorService', () => { workspaceRoot: projectPath, deliveryContextPath: expect.stringContaining('delivery-context.json'), attributionMode: 'strict-delivery', - evidenceMode: 'chain-only', }) ); }); From ff506d0d96d3bedd6e28f1edaa7263d1443e5d46 Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 28 Apr 2026 21:08:10 +0300 Subject: [PATCH 02/18] fix(changes): fingerprint projected ledger summaries --- .../services/team/TaskChangeLedgerReader.ts | 92 +++++++++-- .../team/TaskChangeLedgerReader.test.ts | 153 ++++++++++++++++++ test/renderer/store/changeReviewSlice.test.ts | 80 +++++++++ 3 files changed, 315 insertions(+), 10 deletions(-) diff --git a/src/main/services/team/TaskChangeLedgerReader.ts b/src/main/services/team/TaskChangeLedgerReader.ts index f36c7595..f07e523e 100644 --- a/src/main/services/team/TaskChangeLedgerReader.ts +++ b/src/main/services/team/TaskChangeLedgerReader.ts @@ -428,11 +428,7 @@ export class TaskChangeLedgerReader { return null; } - const provenance = this.buildLedgerProvenance( - bundle.journalStamp, - bundle.integrity, - bundle.schemaVersion - ); + const provenance = this.buildLedgerProvenanceFromSummaryBundle(bundle); if ( freshness && @@ -450,11 +446,7 @@ export class TaskChangeLedgerReader { ) { return { bundle, - provenance: this.buildLedgerProvenance( - journalStamp, - bundle.integrity, - bundle.schemaVersion - ), + provenance: this.buildLedgerProvenanceFromSummaryBundle(bundle, journalStamp), mode: 'validated', }; } @@ -694,6 +686,86 @@ export class TaskChangeLedgerReader { return this.buildLedgerProvenance(journalStamp, integrity, bundleSchemaVersion); } + private buildLedgerProvenanceFromSummaryBundle( + bundle: LedgerSummaryBundleV2, + journalStamp: TaskChangeJournalStamp = bundle.journalStamp + ): TaskChangeProvenance { + return { + sourceKind: 'ledger', + sourceFingerprint: this.hashFingerprintPayload(this.buildProjectedSummaryIdentity(bundle)), + journalStamp, + bundleSchemaVersion: bundle.schemaVersion, + integrity: bundle.integrity, + }; + } + + private buildProjectedSummaryIdentity(bundle: LedgerSummaryBundleV2): unknown { + return { + kind: 'ledger-summary-v2-projected-identity', + schemaVersion: bundle.schemaVersion, + bundleKind: bundle.bundleKind, + taskId: bundle.taskId, + integrity: bundle.integrity, + totalFiles: bundle.totalFiles, + totalLinesAdded: bundle.totalLinesAdded, + totalLinesRemoved: bundle.totalLinesRemoved, + diffStatCompleteness: bundle.diffStatCompleteness, + confidence: bundle.confidence, + files: [...bundle.files] + .map((file) => ({ + changeKey: this.normalizeSummaryChangeKey(file), + filePath: normalizePathForComparison(file.filePath), + relativePath: normalizePathForComparison(file.relativePath), + displayPath: file.displayPath ? normalizePathForComparison(file.displayPath) : undefined, + linesAdded: file.linesAdded, + linesRemoved: file.linesRemoved, + diffStatKnown: file.diffStatKnown, + latestOperation: file.latestOperation, + createdInTask: file.createdInTask, + deletedInTask: file.deletedInTask, + baselineExists: file.baselineExists, + finalExists: file.finalExists, + latestBeforeHash: file.latestBeforeHash, + latestAfterHash: file.latestAfterHash, + latestBeforeState: this.contentStateFingerprint(file.latestBeforeState), + latestAfterState: this.contentStateFingerprint(file.latestAfterState), + contentAvailability: file.contentAvailability, + reviewability: file.reviewability, + relation: file.relation + ? { + kind: file.relation.kind, + oldPath: normalizePathForComparison(file.relation.oldPath), + newPath: normalizePathForComparison(file.relation.newPath), + } + : undefined, + worktreePath: file.worktreePath + ? normalizePathForComparison(file.worktreePath) + : undefined, + worktreeBranch: file.worktreeBranch, + baseWorkspaceRoot: file.baseWorkspaceRoot + ? normalizePathForComparison(file.baseWorkspaceRoot) + : undefined, + })) + .sort( + (left, right) => + left.changeKey.localeCompare(right.changeKey) || + left.filePath.localeCompare(right.filePath) + ), + }; + } + + private contentStateFingerprint(state: LedgerContentState | undefined): unknown { + if (!state) { + return undefined; + } + return { + exists: state.exists, + sha256: state.sha256, + sizeBytes: state.sizeBytes, + unavailableReason: state.unavailableReason, + }; + } + private hashFingerprintPayload(payload: unknown): string { return createHash('sha256').update(JSON.stringify(payload)).digest('hex'); } diff --git a/test/main/services/team/TaskChangeLedgerReader.test.ts b/test/main/services/team/TaskChangeLedgerReader.test.ts index a60807d0..969fac6c 100644 --- a/test/main/services/team/TaskChangeLedgerReader.test.ts +++ b/test/main/services/team/TaskChangeLedgerReader.test.ts @@ -614,6 +614,91 @@ describe('TaskChangeLedgerReader', () => { ); }); + it('keeps v2 provenance fingerprint stable when only raw journal metadata changes', async () => { + tmpDir = await makeSummaryLedgerBundleV2({ + bundle: { + journalStamp: { events: { bytes: 10, mtimeMs: 1, tailSha256: 'raw-a' } }, + eventCount: 1, + noticeCount: 0, + warningCount: 0, + warnings: [], + }, + file: { + eventCount: 1, + firstTimestamp: '2026-03-01T10:00:00.000Z', + lastTimestamp: '2026-03-01T10:00:00.000Z', + agentIds: ['alice@team'], + }, + }); + const reader = new TaskChangeLedgerReader(); + const first = await reader.readTaskChanges({ + teamName: 'team', + taskId: TASK_ID, + projectDir: tmpDir, + projectPath: '/repo', + includeDetails: false, + }); + + tmpDir = await makeSummaryLedgerBundleV2({ + bundle: { + generatedAt: '2026-03-01T11:00:00.000Z', + journalStamp: { events: { bytes: 999, mtimeMs: 99, tailSha256: 'raw-b' } }, + eventCount: 7, + noticeCount: 3, + warningCount: 1, + warnings: ['raw journal had a recovered warning'], + }, + file: { + eventCount: 7, + firstTimestamp: '2026-03-01T09:00:00.000Z', + lastTimestamp: '2026-03-01T11:00:00.000Z', + agentIds: ['alice@team', 'bob@team'], + }, + }); + const second = await reader.readTaskChanges({ + teamName: 'team', + taskId: TASK_ID, + projectDir: tmpDir, + projectPath: '/repo', + includeDetails: false, + }); + + expect(first?.provenance?.sourceFingerprint).toBe(second?.provenance?.sourceFingerprint); + }); + + it('changes v2 provenance fingerprint when projected file evidence changes', async () => { + tmpDir = await makeSummaryLedgerBundleV2({ + file: { + latestAfterHash: sha('after-v1'), + latestAfterState: { exists: true, sha256: sha('after-v1'), sizeBytes: 8 }, + }, + }); + const reader = new TaskChangeLedgerReader(); + const first = await reader.readTaskChanges({ + teamName: 'team', + taskId: TASK_ID, + projectDir: tmpDir, + projectPath: '/repo', + includeDetails: false, + }); + + tmpDir = await makeSummaryLedgerBundleV2({ + file: { + latestAfterHash: sha('after-v2'), + latestAfterState: { exists: true, sha256: sha('after-v2'), sizeBytes: 8 }, + }, + }); + const second = await reader.readTaskChanges({ + teamName: 'team', + taskId: TASK_ID, + projectDir: tmpDir, + projectPath: '/repo', + includeDetails: false, + }); + + expect(first?.provenance?.sourceFingerprint).not.toBe(second?.provenance?.sourceFingerprint); + }); + it('keeps identical relative rename relations isolated by worktree path', async () => { tmpDir = await fsTempDir(); const bundleDir = path.join(tmpDir, '.board-task-changes', 'bundles'); @@ -969,6 +1054,74 @@ async function makeLedgerBundle(params: { return dir; } +async function makeSummaryLedgerBundleV2(params: { + bundle?: Record; + file?: Record; +} = {}): Promise { + const dir = await fsTempDir(); + const bundleDir = path.join(dir, '.board-task-changes', 'bundles'); + await mkdir(bundleDir, { recursive: true }); + const file = { + changeKey: 'path:/repo/src/file.ts', + filePath: '/repo/src/file.ts', + relativePath: 'src/file.ts', + linesAdded: 1, + linesRemoved: 1, + diffStatKnown: true, + eventCount: 1, + firstTimestamp: '2026-03-01T10:00:00.000Z', + lastTimestamp: '2026-03-01T10:00:00.000Z', + latestOperation: 'modify', + createdInTask: false, + deletedInTask: false, + latestBeforeHash: sha('before'), + latestAfterHash: sha('after'), + latestBeforeState: { exists: true, sha256: sha('before'), sizeBytes: 6 }, + latestAfterState: { exists: true, sha256: sha('after'), sizeBytes: 5 }, + contentAvailability: 'full-text', + reviewability: 'full-text', + agentIds: ['alice@team'], + ...params.file, + }; + await writeFile( + path.join(bundleDir, `${encodeURIComponent(TASK_ID)}.json`), + JSON.stringify({ + schemaVersion: 2, + source: 'task-change-ledger', + bundleKind: 'summary', + taskId: TASK_ID, + generatedAt: '2026-03-01T10:00:00.000Z', + journalStamp: { events: { bytes: 10, mtimeMs: 1, tailSha256: 'raw' } }, + integrity: 'ok', + eventCount: 1, + noticeCount: 0, + scope: { + confidence: { tier: 1, label: 'high', reason: 'bundle' }, + memberName: 'alice', + agentIds: ['alice@team'], + startTimestamp: '2026-03-01T10:00:00.000Z', + endTimestamp: '2026-03-01T10:00:00.000Z', + toolUseIds: ['tool-1'], + toolUseCount: 1, + phaseSet: ['work'], + visibleFileCount: 1, + contributors: [], + }, + files: [file], + totalLinesAdded: 1, + totalLinesRemoved: 1, + diffStatCompleteness: 'complete', + totalFiles: 1, + confidence: 'high', + warningCount: 0, + warnings: [], + ...params.bundle, + }), + 'utf8' + ); + return dir; +} + async function fsTempDir(): Promise { return mkdtemp(path.join(os.tmpdir(), 'ledger-reader-')); } diff --git a/test/renderer/store/changeReviewSlice.test.ts b/test/renderer/store/changeReviewSlice.test.ts index da924758..19b8f388 100644 --- a/test/renderer/store/changeReviewSlice.test.ts +++ b/test/renderer/store/changeReviewSlice.test.ts @@ -1651,4 +1651,84 @@ describe('changeReviewSlice task changes', () => { }); expect(store.getState().activeChangeSet).toEqual(current); }); + + it('does not force re-review when ledger provenance stays stable despite warning changes', async () => { + const store = createSliceStore(); + const current = { + ...makeTaskChangeSet('task-ledger', '/repo/file.ts'), + provenance: { + sourceKind: 'ledger', + sourceFingerprint: 'projected-fp-stable', + }, + warnings: [], + }; + const fresh = { + ...current, + computedAt: '2026-03-01T13:00:00.000Z', + warnings: ['raw journal warning changed'], + }; + hoisted.getTaskChanges.mockResolvedValueOnce(fresh); + hoisted.applyDecisions.mockResolvedValueOnce({ + applied: 1, + skipped: 0, + conflicts: 0, + errors: [], + }); + + store.setState({ + activeChangeSet: current, + hunkDecisions: { '/repo/file.ts:0': 'rejected' }, + fileDecisions: { '/repo/file.ts': 'rejected' }, + fileChunkCounts: { '/repo/file.ts': 1 }, + changeSetEpoch: 0, + fileContentVersionByPath: {}, + }); + + await store.getState().applyReview('team-a', 'task-ledger'); + + expect(store.getState().applyError).toBeNull(); + expect(hoisted.applyDecisions).toHaveBeenCalledTimes(1); + expect(store.getState().activeChangeSet).toEqual(current); + }); + + it('forces re-review when ledger projected provenance changes with the same file paths', async () => { + const store = createSliceStore(); + const current = { + ...makeTaskChangeSet('task-ledger', '/repo/file.ts'), + provenance: { + sourceKind: 'ledger', + sourceFingerprint: 'projected-fp-v1', + }, + }; + const fresh = { + ...current, + provenance: { + sourceKind: 'ledger', + sourceFingerprint: 'projected-fp-v2', + }, + }; + hoisted.getTaskChanges.mockResolvedValueOnce(fresh); + + store.setState({ + activeChangeSet: current, + hunkDecisions: { '/repo/file.ts:0': 'rejected' }, + fileDecisions: { '/repo/file.ts': 'rejected' }, + fileChunkCounts: { '/repo/file.ts': 1 }, + reviewUndoStack: [{ hunkDecisions: { '/repo/file.ts:0': 'rejected' }, fileDecisions: { '/repo/file.ts': 'rejected' } }], + changeSetEpoch: 2, + fileContentVersionByPath: { '/repo/file.ts': 3 }, + }); + + await store.getState().applyReview('team-a', 'task-ledger'); + + expect(hoisted.applyDecisions).not.toHaveBeenCalled(); + expect(store.getState().activeChangeSet).toEqual(fresh); + expect(store.getState().applyError).toBe( + 'Changes have been updated since you started reviewing. Please re-review.' + ); + expect(store.getState().hunkDecisions).toEqual({}); + expect(store.getState().fileDecisions).toEqual({}); + expect(store.getState().reviewUndoStack).toEqual([]); + expect(store.getState().fileContentVersionByPath).toEqual({}); + }); }); From c065dc703da8344fa77e6781a2fa3257efedd646 Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 28 Apr 2026 21:38:22 +0300 Subject: [PATCH 03/18] fix(changes): remove opencode evidence mode from ui bridge --- .../team/opencode/bridge/OpenCodeBridgeCommandContract.ts | 8 -------- .../team/opencode/bridge/OpenCodeReadinessBridge.ts | 1 - test/main/services/team/ChangeExtractorService.test.ts | 3 +++ 3 files changed, 3 insertions(+), 9 deletions(-) diff --git a/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts index eca989d8..eca8e93a 100644 --- a/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts +++ b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts @@ -240,17 +240,10 @@ export interface OpenCodeBackfillTaskLedgerCommandBody { workspaceRoot?: string; deliveryContextPath?: string; attributionMode?: OpenCodeBackfillTaskLedgerAttributionMode; - evidenceMode?: OpenCodeBackfillTaskLedgerEvidenceMode; dryRun?: boolean; } export type OpenCodeBackfillTaskLedgerAttributionMode = 'strict-delivery' | 'compatible'; -export type OpenCodeBackfillTaskLedgerEvidenceMode = - | 'off' - | 'metadata-only' - | 'chain-only' - | 'snapshot-probe' - | 'snapshot-auto'; export type OpenCodeBackfillTaskLedgerOutcome = | 'imported' @@ -271,7 +264,6 @@ export interface OpenCodeBackfillTaskLedgerCommandData { workspaceRoot?: string; dryRun: boolean; attributionMode?: OpenCodeBackfillTaskLedgerAttributionMode; - evidenceMode?: OpenCodeBackfillTaskLedgerEvidenceMode; strictWindowCandidateCount?: number; openCodeDbFingerprint?: string; deliveryLedgerFingerprint?: string; diff --git a/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts b/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts index cba24f84..ad4dff69 100644 --- a/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts +++ b/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts @@ -308,7 +308,6 @@ export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort { ...(input.workspaceRoot ? { workspaceRoot: input.workspaceRoot } : {}), dryRun: input.dryRun === true, ...(input.attributionMode ? { attributionMode: input.attributionMode } : {}), - ...(input.evidenceMode ? { evidenceMode: input.evidenceMode } : {}), scannedSessions: 0, scannedToolparts: 0, candidateEvents: 0, diff --git a/test/main/services/team/ChangeExtractorService.test.ts b/test/main/services/team/ChangeExtractorService.test.ts index fdd32b5b..5076fd01 100644 --- a/test/main/services/team/ChangeExtractorService.test.ts +++ b/test/main/services/team/ChangeExtractorService.test.ts @@ -1060,6 +1060,7 @@ describe('ChangeExtractorService', () => { attributionMode: 'strict-delivery', }) ); + expect(backfillOpenCodeTaskLedger.mock.calls[0]?.[0]).not.toHaveProperty('evidenceMode'); expect(workerClient.computeTaskChanges).not.toHaveBeenCalled(); }); @@ -1185,6 +1186,8 @@ describe('ChangeExtractorService', () => { }) ); }); + const backfillCalls = backfillOpenCodeTaskLedger.mock.calls as unknown as Array<[Record]>; + expect(backfillCalls[0]?.[0]).not.toHaveProperty('evidenceMode'); expect(settled).toBe(false); expect(workerClient.computeTaskChanges).not.toHaveBeenCalled(); From 9f785ee3b20db7b964d0dc0d7908dd81c0ce5b1e Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 28 Apr 2026 21:47:52 +0300 Subject: [PATCH 04/18] test(changes): keep opencode transient backfill retryable --- .../team/ChangeExtractorService.test.ts | 42 +++++++++++-------- 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/test/main/services/team/ChangeExtractorService.test.ts b/test/main/services/team/ChangeExtractorService.test.ts index 5076fd01..90238931 100644 --- a/test/main/services/team/ChangeExtractorService.test.ts +++ b/test/main/services/team/ChangeExtractorService.test.ts @@ -1332,24 +1332,30 @@ describe('ChangeExtractorService', () => { 'utf8' ); - 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: 0, - candidateEvents: 0, - importedEvents: 0, - skippedEvents: 0, - outcome: 'no-attribution', - notices: [], - diagnostics: [], - })); + 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 () => From 819a1f6e8fb6a5d2acfd74c87f6a514a7f7b45a1 Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 28 Apr 2026 21:58:24 +0300 Subject: [PATCH 05/18] test(changes): reset decisions on ledger evidence upgrade --- test/renderer/store/changeReviewSlice.test.ts | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/test/renderer/store/changeReviewSlice.test.ts b/test/renderer/store/changeReviewSlice.test.ts index 19b8f388..0bc2d397 100644 --- a/test/renderer/store/changeReviewSlice.test.ts +++ b/test/renderer/store/changeReviewSlice.test.ts @@ -1731,4 +1731,73 @@ describe('changeReviewSlice task changes', () => { expect(store.getState().reviewUndoStack).toEqual([]); expect(store.getState().fileContentVersionByPath).toEqual({}); }); + + it('clears metadata-only decisions when ledger evidence upgrades to full text for the same changeKey', async () => { + const store = createSliceStore(); + const changeKey = 'path:/repo/file.ts'; + const currentFile = { + ...makeFile('/repo/file.ts'), + changeKey, + snippets: [], + ledgerSummary: { + latestOperation: 'modify', + contentAvailability: 'metadata-only', + reviewability: 'metadata-only', + }, + }; + const freshFile = { + ...makeFile('/repo/file.ts'), + changeKey, + ledgerSummary: { + latestOperation: 'modify', + contentAvailability: 'full-text', + reviewability: 'full-text', + beforeState: { exists: true, sha256: 'before-hash', sizeBytes: 6 }, + afterState: { exists: true, sha256: 'after-hash', sizeBytes: 5 }, + }, + }; + const current = { + ...makeTaskChangeSet('task-ledger', '/repo/file.ts'), + files: [currentFile], + provenance: { + sourceKind: 'ledger', + sourceFingerprint: 'metadata-only-projection', + }, + }; + const fresh = { + ...current, + files: [freshFile], + provenance: { + sourceKind: 'ledger', + sourceFingerprint: 'snapshot-full-text-projection', + }, + }; + hoisted.getTaskChanges.mockResolvedValueOnce(fresh); + + store.setState({ + activeChangeSet: current, + hunkDecisions: { [`${changeKey}:0`]: 'rejected' }, + fileDecisions: { [changeKey]: 'rejected' }, + hunkContextHashesByFile: { [changeKey]: { 0: 'metadata-only-context' } }, + fileChunkCounts: { [changeKey]: 1 }, + reviewUndoStack: [ + { + hunkDecisions: { [`${changeKey}:0`]: 'rejected' }, + fileDecisions: { [changeKey]: 'rejected' }, + }, + ], + changeSetEpoch: 4, + fileContentVersionByPath: { '/repo/file.ts': 2 }, + }); + + await store.getState().applyReview('team-a', 'task-ledger'); + + expect(hoisted.applyDecisions).not.toHaveBeenCalled(); + expect(store.getState().activeChangeSet).toEqual(fresh); + expect(store.getState().fileDecisions).toEqual({}); + expect(store.getState().hunkDecisions).toEqual({}); + expect(store.getState().hunkContextHashesByFile).toEqual({}); + expect(store.getState().reviewUndoStack).toEqual([]); + expect(store.getState().fileContentVersionByPath).toEqual({}); + }); }); From ba09010fcba287a643de30108800a58364e17515 Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 28 Apr 2026 22:28:58 +0300 Subject: [PATCH 06/18] fix(changes): derive opencode backfill member from delivery --- .../services/team/ChangeExtractorService.ts | 30 +++- .../team/ChangeExtractorService.test.ts | 146 ++++++++++++++++++ 2 files changed, 170 insertions(+), 6 deletions(-) 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); From 9d9c7fbd38ce1b69c80a0c544c296aa25729d598 Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 28 Apr 2026 22:33:11 +0300 Subject: [PATCH 07/18] fix(changes): reread ledger after opencode backfill failure --- .../services/team/ChangeExtractorService.ts | 32 ++-- .../team/ChangeExtractorService.test.ts | 180 ++++++++++++------ 2 files changed, 143 insertions(+), 69 deletions(-) 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); From 6bc9ddbc3ea85897fe691566d07448ac58228758 Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 28 Apr 2026 22:34:27 +0300 Subject: [PATCH 08/18] test(changes): ignore display-only opencode delivery --- .../team/ChangeExtractorService.test.ts | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/test/main/services/team/ChangeExtractorService.test.ts b/test/main/services/team/ChangeExtractorService.test.ts index 384dcf03..5076c321 100644 --- a/test/main/services/team/ChangeExtractorService.test.ts +++ b/test/main/services/team/ChangeExtractorService.test.ts @@ -1276,6 +1276,65 @@ describe('ChangeExtractorService', () => { 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('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); From ee590d0a62a6cf3f530a07060aa1bbf3931d1f10 Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 28 Apr 2026 22:39:47 +0300 Subject: [PATCH 09/18] test(changes): ignore related opencode deliveries --- .../team/ChangeExtractorService.test.ts | 62 ++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) diff --git a/test/main/services/team/ChangeExtractorService.test.ts b/test/main/services/team/ChangeExtractorService.test.ts index 5076c321..19ca95c6 100644 --- a/test/main/services/team/ChangeExtractorService.test.ts +++ b/test/main/services/team/ChangeExtractorService.test.ts @@ -79,6 +79,7 @@ async function writeOpenCodeDeliveryLedger( taskId: string; displayId: string; teamName: string; + taskRefs: { taskId: string; displayId: string; teamName: string }[]; }> ): Promise { const memberName = overrides?.memberName ?? 'bob'; @@ -108,7 +109,7 @@ async function writeOpenCodeDeliveryLedger( observedAssistantMessageId: overrides?.observedAssistantMessageId ?? null, prePromptCursor: null, postPromptCursor: null, - taskRefs: [ + taskRefs: overrides?.taskRefs ?? [ { taskId: overrides?.taskId ?? TASK_ID, displayId: overrides?.displayId ?? 'abc12345', @@ -1335,6 +1336,65 @@ describe('ChangeExtractorService', () => { 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); From 53012ed623c08cb7974ab82491ac168278430d36 Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 28 Apr 2026 22:52:05 +0300 Subject: [PATCH 10/18] fix(changes): pass verified opencode delivery context --- .../services/team/ChangeExtractorService.ts | 39 +++++++++++-------- .../bridge/OpenCodeBridgeCommandContract.ts | 1 + .../team/ChangeExtractorService.test.ts | 16 ++++++++ .../team/OpenCodeReadinessBridge.test.ts | 4 ++ 4 files changed, 43 insertions(+), 17 deletions(-) diff --git a/src/main/services/team/ChangeExtractorService.ts b/src/main/services/team/ChangeExtractorService.ts index a84d570d..ac1c68d2 100644 --- a/src/main/services/team/ChangeExtractorService.ts +++ b/src/main/services/team/ChangeExtractorService.ts @@ -9,7 +9,7 @@ import { } from '@shared/utils/taskChangeState'; import { createHash } from 'crypto'; import { existsSync } from 'fs'; -import { mkdtemp, readdir, readFile, rm, stat, writeFile } from 'fs/promises'; +import { chmod, mkdtemp, readdir, readFile, rm, stat, writeFile } from 'fs/promises'; import * as os from 'os'; import * as path from 'path'; @@ -77,6 +77,7 @@ interface OpenCodeBackfillAttempt { interface OpenCodeDeliveryContextTempFile { filePath: string | null; + hash: string | null; cleanup: () => Promise; } @@ -513,7 +514,12 @@ export class ChangeExtractorService { workspaceRoot, attributionMode: OPEN_CODE_AUTO_BACKFILL_ATTRIBUTION_MODE, ...(backfillMemberName ? { memberName: backfillMemberName } : {}), - ...(deliveryContext.filePath ? { deliveryContextPath: deliveryContext.filePath } : {}), + ...(deliveryContext.filePath + ? { + deliveryContextPath: deliveryContext.filePath, + deliveryContextHash: deliveryContext.hash ?? undefined, + } + : {}), }); void appendOpenCodeTaskChangeDiag({ event: 'backfill_result', @@ -661,27 +667,26 @@ export class ChangeExtractorService { records: Awaited> ): Promise { if (records.length === 0) { - return { filePath: null, cleanup: async () => undefined }; + return { filePath: null, hash: null, cleanup: async () => undefined }; } const dir = await mkdtemp(path.join(os.tmpdir(), 'claude-team-opencode-ledger-context-')); + await chmod(dir, 0o700).catch(() => undefined); const filePath = path.join(dir, 'delivery-context.json'); - await writeFile( - filePath, - `${JSON.stringify( - { - schemaVersion: 1, - teamName, - taskId, - records, - }, - null, - 2 - )}\n`, - { encoding: 'utf8', mode: 0o600 } - ); + const rawContext = `${JSON.stringify( + { + schemaVersion: 1, + teamName, + taskId, + records, + }, + null, + 2 + )}\n`; + await writeFile(filePath, rawContext, { encoding: 'utf8', mode: 0o600 }); return { filePath, + hash: createHash('sha256').update(rawContext).digest('hex'), cleanup: async () => { await rm(dir, { recursive: true, force: true }).catch(() => undefined); }, diff --git a/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts index eca8e93a..9a478e01 100644 --- a/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts +++ b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts @@ -239,6 +239,7 @@ export interface OpenCodeBackfillTaskLedgerCommandBody { projectDir?: string; workspaceRoot?: string; deliveryContextPath?: string; + deliveryContextHash?: string; attributionMode?: OpenCodeBackfillTaskLedgerAttributionMode; dryRun?: boolean; } diff --git a/test/main/services/team/ChangeExtractorService.test.ts b/test/main/services/team/ChangeExtractorService.test.ts index 19ca95c6..daaa5428 100644 --- a/test/main/services/team/ChangeExtractorService.test.ts +++ b/test/main/services/team/ChangeExtractorService.test.ts @@ -1,5 +1,6 @@ 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'; @@ -999,7 +1000,12 @@ describe('ChangeExtractorService', () => { 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, @@ -1069,6 +1075,12 @@ describe('ChangeExtractorService', () => { 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(backfillOpenCodeTaskLedger.mock.calls[0]?.[0]).not.toHaveProperty('evidenceMode'); expect(workerClient.computeTaskChanges).not.toHaveBeenCalled(); }); @@ -1513,6 +1525,7 @@ describe('ChangeExtractorService', () => { projectDir, workspaceRoot: projectPath, deliveryContextPath: expect.stringContaining('delivery-context.json'), + deliveryContextHash: expect.stringMatching(/^[a-f0-9]{64}$/), attributionMode: 'strict-delivery', }) ); @@ -1621,6 +1634,7 @@ describe('ChangeExtractorService', () => { 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 () => { @@ -1732,8 +1746,10 @@ describe('ChangeExtractorService', () => { 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}$/); }); }); diff --git a/test/main/services/team/OpenCodeReadinessBridge.test.ts b/test/main/services/team/OpenCodeReadinessBridge.test.ts index 26079377..090a4bd7 100644 --- a/test/main/services/team/OpenCodeReadinessBridge.test.ts +++ b/test/main/services/team/OpenCodeReadinessBridge.test.ts @@ -170,6 +170,8 @@ describe('OpenCodeReadinessBridge', () => { taskDisplayId: 'abc12345', projectDir: '/claude/project', workspaceRoot: '/repo', + deliveryContextPath: '/tmp/claude-team-opencode-ledger-context-test/delivery-context.json', + deliveryContextHash: 'a'.repeat(64), }) ).resolves.toMatchObject({ outcome: 'imported', @@ -184,6 +186,8 @@ describe('OpenCodeReadinessBridge', () => { taskDisplayId: 'abc12345', projectDir: '/claude/project', workspaceRoot: '/repo', + deliveryContextPath: '/tmp/claude-team-opencode-ledger-context-test/delivery-context.json', + deliveryContextHash: 'a'.repeat(64), }, { cwd: '/repo', From 33a8a5fabc70488316ff7f5c6270498e306515ce Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 28 Apr 2026 23:01:42 +0300 Subject: [PATCH 11/18] fix(changes): key opencode backfill by context hash --- .../services/team/ChangeExtractorService.ts | 90 ++++++++----------- 1 file changed, 35 insertions(+), 55 deletions(-) diff --git a/src/main/services/team/ChangeExtractorService.ts b/src/main/services/team/ChangeExtractorService.ts index ac1c68d2..26e53f0d 100644 --- a/src/main/services/team/ChangeExtractorService.ts +++ b/src/main/services/team/ChangeExtractorService.ts @@ -81,6 +81,11 @@ interface OpenCodeDeliveryContextTempFile { cleanup: () => Promise; } +interface OpenCodeDeliveryContextPayload { + rawContext: string; + hash: string; +} + export class ChangeExtractorService { private cache = new Map(); private taskChangeSummaryCache = new Map(); @@ -422,12 +427,16 @@ export class ChangeExtractorService { input.teamName, input.taskId ); + const deliveryContextPayload = this.buildOpenCodeDeliveryContextPayload( + input.teamName, + input.taskId, + deliveryContextRecords + ); const backfillMemberName = this.resolveOpenCodeBackfillMemberName( input.effectiveOptions.owner, deliveryContextRecords ); - const deliveryContextFingerprint = - this.hashOpenCodeDeliveryContextRecords(deliveryContextRecords); + const deliveryContextFingerprint = deliveryContextPayload.hash; const cacheKey = this.buildOpenCodeBackfillCacheKey({ teamName: input.teamName, @@ -479,6 +488,7 @@ export class ChangeExtractorService { workspaceRoot, cacheKey, deliveryContextRecords, + deliveryContextPayload, sourceGeneration, backfillMemberName ).finally(() => { @@ -496,13 +506,15 @@ export class ChangeExtractorService { deliveryContextRecords: Awaited< ReturnType >, + deliveryContextPayload: OpenCodeDeliveryContextPayload, sourceGeneration: string | null, backfillMemberName?: string ): Promise { const deliveryContext = await this.createOpenCodeDeliveryContextTempFile( input.teamName, input.taskId, - deliveryContextRecords + deliveryContextRecords, + deliveryContextPayload ); try { const result = await this.openCodeLedgerBackfillPort!.backfillOpenCodeTaskLedger({ @@ -532,7 +544,7 @@ export class ChangeExtractorService { workspaceRoot, sourceGeneration, deliveryRecordCount: deliveryContextRecords.length, - deliveryContextFingerprint: this.hashOpenCodeDeliveryContextRecords(deliveryContextRecords), + deliveryContextFingerprint: deliveryContextPayload.hash, result: { attributionMode: result.attributionMode ?? OPEN_CODE_AUTO_BACKFILL_ATTRIBUTION_MODE, outcome: result.outcome, @@ -586,7 +598,7 @@ export class ChangeExtractorService { projectDir, workspaceRoot, deliveryRecordCount: deliveryContextRecords.length, - deliveryContextFingerprint: this.hashOpenCodeDeliveryContextRecords(deliveryContextRecords), + deliveryContextFingerprint: deliveryContextPayload.hash, error: error instanceof Error ? error.message : String(error), }).catch(() => undefined); if (deliveryContextRecords.length === 0) { @@ -664,7 +676,8 @@ export class ChangeExtractorService { private async createOpenCodeDeliveryContextTempFile( teamName: string, taskId: string, - records: Awaited> + records: Awaited>, + payload = this.buildOpenCodeDeliveryContextPayload(teamName, taskId, records) ): Promise { if (records.length === 0) { return { filePath: null, hash: null, cleanup: async () => undefined }; @@ -673,6 +686,21 @@ export class ChangeExtractorService { const dir = await mkdtemp(path.join(os.tmpdir(), 'claude-team-opencode-ledger-context-')); await chmod(dir, 0o700).catch(() => undefined); const filePath = path.join(dir, 'delivery-context.json'); + await writeFile(filePath, payload.rawContext, { encoding: 'utf8', mode: 0o600 }); + return { + filePath, + hash: payload.hash, + cleanup: async () => { + await rm(dir, { recursive: true, force: true }).catch(() => undefined); + }, + }; + } + + private buildOpenCodeDeliveryContextPayload( + teamName: string, + taskId: string, + records: Awaited> + ): OpenCodeDeliveryContextPayload { const rawContext = `${JSON.stringify( { schemaVersion: 1, @@ -683,13 +711,9 @@ export class ChangeExtractorService { null, 2 )}\n`; - await writeFile(filePath, rawContext, { encoding: 'utf8', mode: 0o600 }); return { - filePath, hash: createHash('sha256').update(rawContext).digest('hex'), - cleanup: async () => { - await rm(dir, { recursive: true, force: true }).catch(() => undefined); - }, + rawContext, }; } @@ -798,50 +822,6 @@ export class ChangeExtractorService { return laneIds.sort((left, right) => left.localeCompare(right)); } - private hashOpenCodeDeliveryContextRecords( - records: Awaited> - ): string { - const stableRecords = records - .map((record) => ({ - memberName: record.memberName, - laneId: record.laneId ?? '', - runtimeSessionId: record.runtimeSessionId ?? '', - inboxMessageId: record.inboxMessageId ?? '', - deliveredUserMessageId: record.deliveredUserMessageId ?? '', - taskRefs: record.taskRefs - .map((taskRef) => ({ - taskId: taskRef.taskId, - displayId: taskRef.displayId, - teamName: taskRef.teamName, - })) - .sort((left, right) => - `${left.teamName}\0${left.taskId}\0${left.displayId}`.localeCompare( - `${right.teamName}\0${right.taskId}\0${right.displayId}` - ) - ), - })) - .sort((left, right) => - [ - left.laneId, - left.memberName, - left.runtimeSessionId, - left.inboxMessageId, - left.deliveredUserMessageId, - ] - .join('\0') - .localeCompare( - [ - right.laneId, - right.memberName, - right.runtimeSessionId, - right.inboxMessageId, - right.deliveredUserMessageId, - ].join('\0') - ) - ); - return createHash('sha256').update(JSON.stringify(stableRecords)).digest('hex'); - } - private async readOpenCodePromptDeliveryLedgerRecords( filePath: string ): Promise { From 7b5924c8bd4a331614396810712c75d5e2edcf89 Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 28 Apr 2026 23:05:44 +0300 Subject: [PATCH 12/18] fix(changes): accept opencode evidence contract version --- .../bridge/OpenCodeBridgeCommandContract.ts | 8 +++- .../OpenCodeBridgeCommandContract.test.ts | 37 +++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts index 9a478e01..f941cc6d 100644 --- a/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts +++ b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts @@ -1,6 +1,7 @@ import { createHash } from 'crypto'; export const OPEN_CODE_BRIDGE_SCHEMA_VERSION = 1 as const; +export const OPEN_CODE_TASK_LEDGER_EVIDENCE_CONTRACT_VERSION = 1 as const; export type OpenCodeBridgeCommandName = | 'opencode.handshake' @@ -259,6 +260,7 @@ export type OpenCodeBackfillTaskLedgerOutcome = export interface OpenCodeBackfillTaskLedgerCommandData { schemaVersion: 1; providerId: 'opencode'; + opencodeTaskLedgerEvidenceContractVersion?: number; teamName: string; taskId?: string; projectDir?: string; @@ -362,6 +364,7 @@ export interface OpenCodeBridgePeerIdentity { minVersion: number; currentVersion: number; supportedCommands: OpenCodeBridgeCommandName[]; + opencodeTaskLedgerEvidenceContractVersion?: number; }; runtime: { providerId: 'opencode'; @@ -846,7 +849,10 @@ function isPeerIdentity(value: unknown): value is OpenCodeBridgePeerIdentity { (bridgeProtocol.minVersion as number) < 1 || (bridgeProtocol.currentVersion as number) < (bridgeProtocol.minVersion as number) || !Array.isArray(bridgeProtocol.supportedCommands) || - !bridgeProtocol.supportedCommands.every(isOpenCodeBridgeCommandName) + !bridgeProtocol.supportedCommands.every(isOpenCodeBridgeCommandName) || + (bridgeProtocol.opencodeTaskLedgerEvidenceContractVersion !== undefined && + (!Number.isInteger(bridgeProtocol.opencodeTaskLedgerEvidenceContractVersion) || + (bridgeProtocol.opencodeTaskLedgerEvidenceContractVersion as number) < 1)) ) { return false; } diff --git a/test/main/services/team/OpenCodeBridgeCommandContract.test.ts b/test/main/services/team/OpenCodeBridgeCommandContract.test.ts index 48c5051c..7be4e603 100644 --- a/test/main/services/team/OpenCodeBridgeCommandContract.test.ts +++ b/test/main/services/team/OpenCodeBridgeCommandContract.test.ts @@ -6,6 +6,7 @@ import { createOpenCodeBridgeHandshakeIdentityHash, createOpenCodeBridgeIdempotencyKey, isOpenCodeBridgeCommandName, + OPEN_CODE_TASK_LEDGER_EVIDENCE_CONTRACT_VERSION, parseSingleBridgeJsonResult, stableHash, validateBridgeResultEnvelope, @@ -202,6 +203,42 @@ describe('OpenCodeBridgeCommandContract', () => { }); }); + it('accepts handshake evidence contract version and rejects invalid values', () => { + const client = peerIdentity('claude_team'); + const server = peerIdentity('agent_teams_orchestrator'); + server.bridgeProtocol.opencodeTaskLedgerEvidenceContractVersion = + OPEN_CODE_TASK_LEDGER_EVIDENCE_CONTRACT_VERSION; + const validHandshake = buildHandshake({ client, server }); + + expect( + validateOpenCodeBridgeHandshake({ + handshake: validHandshake, + expectedClient: client, + requiredCommand: 'opencode.launchTeam', + expectedCapabilitySnapshotId: 'cap-1', + expectedManifestHighWatermark: 10, + expectedRunId: 'run-1', + }) + ).toEqual({ ok: true }); + + server.bridgeProtocol.opencodeTaskLedgerEvidenceContractVersion = 0; + const invalidHandshake = buildHandshake({ client, server }); + + expect( + validateOpenCodeBridgeHandshake({ + handshake: invalidHandshake, + expectedClient: client, + requiredCommand: 'opencode.launchTeam', + expectedCapabilitySnapshotId: 'cap-1', + expectedManifestHighWatermark: 10, + expectedRunId: 'run-1', + }) + ).toEqual({ + ok: false, + reason: 'Bridge handshake peer identity is invalid', + }); + }); + it('creates deterministic idempotency keys for equivalent JSON bodies', () => { const first = createOpenCodeBridgeIdempotencyKey({ command: 'opencode.launchTeam', From ef82755b1711232e61a926e5053211ceac9868e7 Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 28 Apr 2026 23:10:41 +0300 Subject: [PATCH 13/18] fix(changes): avoid caching stale opencode contracts --- .../services/team/ChangeExtractorService.ts | 33 +++++++-- .../team/ChangeExtractorService.test.ts | 73 +++++++++++++++++++ 2 files changed, 99 insertions(+), 7 deletions(-) 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); + }); }); From fe722cd8bca60be219f07c97ba85cc226480a05d Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 28 Apr 2026 23:11:45 +0300 Subject: [PATCH 14/18] fix(changes): version opencode backfill cache --- src/main/services/team/ChangeExtractorService.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/main/services/team/ChangeExtractorService.ts b/src/main/services/team/ChangeExtractorService.ts index a28b758c..6b84d7c6 100644 --- a/src/main/services/team/ChangeExtractorService.ts +++ b/src/main/services/team/ChangeExtractorService.ts @@ -47,6 +47,7 @@ import type { AgentChangeSet, ChangeStats, TaskChangeSetV2 } from '@shared/types const logger = createLogger('Service:ChangeExtractorService'); const OPEN_CODE_AUTO_BACKFILL_ATTRIBUTION_MODE = 'strict-delivery' as const; +const OPEN_CODE_AUTO_BACKFILL_EVIDENCE_PIPELINE = 'opencode-session-snapshot-v1' as const; const OPEN_CODE_MAX_DISCOVERED_LANES = 500; /** Кеш-запись: данные + mtime файла + время протухания */ @@ -448,6 +449,7 @@ export class ChangeExtractorService { sourceGeneration, deliveryContextFingerprint, attributionMode: OPEN_CODE_AUTO_BACKFILL_ATTRIBUTION_MODE, + evidencePipeline: OPEN_CODE_AUTO_BACKFILL_EVIDENCE_PIPELINE, }); const now = Date.now(); const cached = this.openCodeBackfillCache.get(cacheKey); @@ -474,6 +476,7 @@ export class ChangeExtractorService { deliveryRecordCount: 0, deliveryContextFingerprint, attributionMode: OPEN_CODE_AUTO_BACKFILL_ATTRIBUTION_MODE, + evidencePipeline: OPEN_CODE_AUTO_BACKFILL_EVIDENCE_PIPELINE, }).catch(() => undefined); return { attempted: false, backfilled: false }; } @@ -562,6 +565,7 @@ export class ChangeExtractorService { sourceGeneration, deliveryRecordCount: deliveryContextRecords.length, deliveryContextFingerprint: deliveryContextPayload.hash, + evidencePipeline: OPEN_CODE_AUTO_BACKFILL_EVIDENCE_PIPELINE, result: { opencodeTaskLedgerEvidenceContractVersion: evidenceContractVersion, attributionMode: result.attributionMode ?? OPEN_CODE_AUTO_BACKFILL_ATTRIBUTION_MODE, @@ -618,6 +622,7 @@ export class ChangeExtractorService { workspaceRoot, deliveryRecordCount: deliveryContextRecords.length, deliveryContextFingerprint: deliveryContextPayload.hash, + evidencePipeline: OPEN_CODE_AUTO_BACKFILL_EVIDENCE_PIPELINE, error: error instanceof Error ? error.message : String(error), }).catch(() => undefined); if (deliveryContextRecords.length === 0) { @@ -868,6 +873,7 @@ export class ChangeExtractorService { sourceGeneration?: string | null; deliveryContextFingerprint: string; attributionMode: typeof OPEN_CODE_AUTO_BACKFILL_ATTRIBUTION_MODE; + evidencePipeline: typeof OPEN_CODE_AUTO_BACKFILL_EVIDENCE_PIPELINE; }): string { return JSON.stringify({ teamName: input.teamName, @@ -878,6 +884,7 @@ export class ChangeExtractorService { sourceGeneration: input.sourceGeneration ?? '', deliveryContextFingerprint: input.deliveryContextFingerprint, attributionMode: input.attributionMode, + evidencePipeline: input.evidencePipeline, }); } From 143f905b8694a3af347f5079f41e6450c94d14e5 Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 28 Apr 2026 23:14:22 +0300 Subject: [PATCH 15/18] test(changes): cover supported opencode backfill cache --- .../team/ChangeExtractorService.test.ts | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/test/main/services/team/ChangeExtractorService.test.ts b/test/main/services/team/ChangeExtractorService.test.ts index eb0535a3..b7b3898a 100644 --- a/test/main/services/team/ChangeExtractorService.test.ts +++ b/test/main/services/team/ChangeExtractorService.test.ts @@ -6,6 +6,7 @@ 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'; @@ -1825,4 +1826,79 @@ describe('ChangeExtractorService', () => { 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); + }); }); From fdf5ddeb61753b5f9830ef823a0806fe2ccbce77 Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 28 Apr 2026 23:34:17 +0300 Subject: [PATCH 16/18] fix(changes): project opencode upgrades in detail view --- .../services/team/TaskChangeLedgerReader.ts | 104 +++++++++++++++--- .../opencode-snapshot-upgrade/manifest.json | 15 +++ .../fixture-opencode-snapshot-upgrade.json | 1 + ...bb741815adaa06f6d396f46739c279eec0fc25cfb6 | 1 + ...fe77a71f364d012bad8e892b1ba9adaa30909fb887 | 1 + .../fixture-opencode-snapshot-upgrade.json | 1 + .../fixture-opencode-snapshot-upgrade.jsonl | 2 + .../project/src/snapshot-only.js | 1 + ...skChangeLedgerFixtures.integration.test.ts | 44 ++++++++ 9 files changed, 157 insertions(+), 13 deletions(-) create mode 100644 test/fixtures/team/task-change-ledger/opencode-snapshot-upgrade/manifest.json create mode 100644 test/fixtures/team/task-change-ledger/opencode-snapshot-upgrade/project/.board-task-change-freshness/fixture-opencode-snapshot-upgrade.json create mode 100644 test/fixtures/team/task-change-ledger/opencode-snapshot-upgrade/project/.board-task-changes/blobs/sha256/402c3103f57599660a8b57bb741815adaa06f6d396f46739c279eec0fc25cfb6 create mode 100644 test/fixtures/team/task-change-ledger/opencode-snapshot-upgrade/project/.board-task-changes/blobs/sha256/892dd6554b064c9dec7454fe77a71f364d012bad8e892b1ba9adaa30909fb887 create mode 100644 test/fixtures/team/task-change-ledger/opencode-snapshot-upgrade/project/.board-task-changes/bundles/fixture-opencode-snapshot-upgrade.json create mode 100644 test/fixtures/team/task-change-ledger/opencode-snapshot-upgrade/project/.board-task-changes/events/fixture-opencode-snapshot-upgrade.jsonl create mode 100644 test/fixtures/team/task-change-ledger/opencode-snapshot-upgrade/project/src/snapshot-only.js diff --git a/src/main/services/team/TaskChangeLedgerReader.ts b/src/main/services/team/TaskChangeLedgerReader.ts index f07e523e..3f5d3cff 100644 --- a/src/main/services/team/TaskChangeLedgerReader.ts +++ b/src/main/services/team/TaskChangeLedgerReader.ts @@ -129,6 +129,13 @@ interface LedgerEvent { linesRemoved?: number; replaceAll?: boolean; warnings?: string[]; + sourceRuntime?: 'opencode'; + sourceProvider?: 'opencode'; + sourceImportKey?: string; + evidenceProof?: string; + supersedesEventId?: string; + snapshotId?: string; + snapshotSource?: string; } interface LedgerNotice { @@ -196,7 +203,7 @@ interface LedgerSummaryScopeV2 { primaryAgentId?: string; primaryMemberName?: string; memberName: string; - agentIds: string[]; + agentIds?: string[]; memberNames?: string[]; startTimestamp: string; endTimestamp: string; @@ -865,9 +872,10 @@ export class TaskChangeLedgerReader { bundle?: LedgerSummaryBundleV2; provenance: TaskChangeProvenance; }): Promise { - const snippets = await this.buildSnippets(params.projectDir, params.journal.events); + const projectedEvents = this.projectJournalEventsForUi(params.journal.events); + const snippets = await this.buildSnippets(params.projectDir, projectedEvents); const groupedSnippets = this.groupSnippets(snippets); - const warnings = this.collectWarnings(params.journal.events, params.journal.notices, { + const warnings = this.collectWarnings(projectedEvents, params.journal.notices, { recovered: params.journal.recovered, }); @@ -908,15 +916,15 @@ export class TaskChangeLedgerReader { totalLinesAdded = fallback.totalLinesAdded; totalLinesRemoved = fallback.totalLinesRemoved; totalFiles = fallback.files.length; - confidence = params.journal.events.some((event) => event.confidence === 'low') + confidence = projectedEvents.some((event) => event.confidence === 'low') ? 'low' - : params.journal.events.some((event) => event.confidence === 'medium') + : projectedEvents.some((event) => event.confidence === 'medium') ? 'medium' : 'high'; scope = this.buildFallbackScope( params.taskId, files, - params.journal.events, + projectedEvents, params.journal.notices ); diffStatCompleteness = fallback.files.every((file) => file.diffStatKnown !== false) @@ -955,7 +963,8 @@ export class TaskChangeLedgerReader { undefined, params.journal.recovered ? 'recovered' : 'ok' ); - const snippets = params.journal.events.map((event) => this.eventToSnippet(event, null, null)); + const projectedEvents = this.projectJournalEventsForUi(params.journal.events); + const snippets = projectedEvents.map((event) => this.eventToSnippet(event, null, null)); const grouped = this.groupSnippets(snippets); const fallback = this.buildFallbackFilesFromGroupedSnippets(grouped, params.projectPath); return { @@ -965,20 +974,20 @@ export class TaskChangeLedgerReader { totalLinesAdded: fallback.totalLinesAdded, totalLinesRemoved: fallback.totalLinesRemoved, totalFiles: fallback.files.length, - confidence: params.journal.events.some((event) => event.confidence === 'low') + confidence: projectedEvents.some((event) => event.confidence === 'low') ? 'low' - : params.journal.events.some((event) => event.confidence === 'medium') + : projectedEvents.some((event) => event.confidence === 'medium') ? 'medium' : 'high', computedAt: new Date().toISOString(), scope: this.buildFallbackScope( params.taskId, fallback.files, - params.journal.events, + projectedEvents, params.journal.notices ), warnings: [ - ...this.collectWarnings(params.journal.events, params.journal.notices, { + ...this.collectWarnings(projectedEvents, params.journal.notices, { recovered: params.journal.recovered, }), 'Task change summary fell back to journal reconstruction.', @@ -1044,6 +1053,7 @@ export class TaskChangeLedgerReader { private mapV2SummaryFile(file: LedgerSummaryFileV2, projectPath?: string): FileChangeSummary { const displayPath = file.displayPath ?? file.filePath; const filePath = this.normalizeLedgerFilePath(file.filePath); + const agentIds = Array.isArray(file.agentIds) ? file.agentIds : []; return { filePath, relativePath: this.relativePath(displayPath, projectPath, file.relativePath), @@ -1065,7 +1075,7 @@ export class TaskChangeLedgerReader { ...(file.latestBeforeState ? { beforeState: file.latestBeforeState } : {}), ...(file.latestAfterState ? { afterState: file.latestAfterState } : {}), ...(file.primaryActorKey ? { primaryActorKey: file.primaryActorKey } : {}), - ...(file.agentIds.length > 0 ? { agentIds: file.agentIds } : {}), + ...(agentIds.length > 0 ? { agentIds } : {}), ...(file.memberNames ? { memberNames: file.memberNames } : {}), ...(file.executionSeqRange ? { executionSeqRange: file.executionSeqRange } : {}), ...(file.worktreePath ? { worktreePath: file.worktreePath } : {}), @@ -1093,6 +1103,7 @@ export class TaskChangeLedgerReader { scope: LedgerSummaryScopeV2, files: LedgerSummaryFileV2[] ): TaskChangeScope { + const agentIds = Array.isArray(scope.agentIds) ? scope.agentIds : []; return { taskId, memberName: @@ -1111,7 +1122,7 @@ export class TaskChangeLedgerReader { ...(scope.primaryActorKey ? { primaryActorKey: scope.primaryActorKey } : {}), ...(scope.primaryAgentId ? { primaryAgentId: scope.primaryAgentId } : {}), ...(scope.primaryMemberName ? { primaryMemberName: scope.primaryMemberName } : {}), - ...(scope.agentIds.length > 0 ? { agentIds: scope.agentIds } : {}), + ...(agentIds.length > 0 ? { agentIds } : {}), ...(scope.memberNames ? { memberNames: scope.memberNames } : {}), ...(scope.toolUseCount !== undefined ? { toolUseCount: scope.toolUseCount } : {}), ...(scope.toolUseIdsTruncated ? { toolUseIdsTruncated: true } : {}), @@ -1136,6 +1147,73 @@ export class TaskChangeLedgerReader { ); } + private projectJournalEventsForUi(events: LedgerEvent[]): LedgerEvent[] { + const selectedBySourceImportKey = new Map< + string, + { event: LedgerEvent; index: number; rank: number } + >(); + const passthrough: Array<{ event: LedgerEvent; index: number }> = []; + + events.forEach((event, index) => { + const sourceImportKey = this.sourceImportKeyForEvent(event); + if (!sourceImportKey) { + passthrough.push({ event, index }); + return; + } + const rank = this.evidenceRankForEvent(event); + const existing = selectedBySourceImportKey.get(sourceImportKey); + if (!existing || rank >= existing.rank) { + selectedBySourceImportKey.set(sourceImportKey, { event, index, rank }); + } + }); + + return [ + ...passthrough, + ...[...selectedBySourceImportKey.values()].map(({ event, index }) => ({ event, index })), + ] + .sort((left, right) => left.index - right.index) + .map(({ event }) => event); + } + + private sourceImportKeyForEvent(event: LedgerEvent): string | null { + if ( + event.sourceImportKey && + (event.sourceRuntime === 'opencode' || + event.sourceProvider === 'opencode' || + event.source === 'opencode_toolpart_write' || + event.source === 'opencode_toolpart_edit' || + event.source === 'opencode_toolpart_apply_patch') + ) { + return event.sourceImportKey; + } + return null; + } + + private evidenceRankForEvent(event: LedgerEvent): number { + const hasFullText = + event.before !== null || + event.after !== null || + (event.operation === 'create' && + event.afterState?.exists === true && + !event.afterState.unavailableReason) || + (event.operation === 'delete' && + event.beforeState?.exists === true && + !event.beforeState.unavailableReason); + + switch (event.evidenceProof) { + case 'opencode-snapshot': + return hasFullText ? 50 : 35; + case 'inverse-apply-patch-chain': + case 'inverse-edit-chain': + case 'toolpart-chain': + return hasFullText ? 40 : 25; + case 'metadata-only-fallback': + return 10; + default: + return hasFullText ? 30 : 5; + } + } + private async readContentRef( projectDir: string, ref: LedgerContentRef | null diff --git a/test/fixtures/team/task-change-ledger/opencode-snapshot-upgrade/manifest.json b/test/fixtures/team/task-change-ledger/opencode-snapshot-upgrade/manifest.json new file mode 100644 index 00000000..1b0344f6 --- /dev/null +++ b/test/fixtures/team/task-change-ledger/opencode-snapshot-upgrade/manifest.json @@ -0,0 +1,15 @@ +{ + "schemaVersion": 1, + "name": "opencode-snapshot-upgrade", + "taskId": "fixture-opencode-snapshot-upgrade", + "description": "OpenCode metadata-only import upgraded by source-driven snapshot evidence into one visible full-text row.", + "projectRootToken": "__PROJECT_ROOT__", + "expected": { + "totalFiles": 1, + "warnings": [], + "relativePaths": [ + "src/snapshot-only.js" + ], + "relationKinds": [] + } +} diff --git a/test/fixtures/team/task-change-ledger/opencode-snapshot-upgrade/project/.board-task-change-freshness/fixture-opencode-snapshot-upgrade.json b/test/fixtures/team/task-change-ledger/opencode-snapshot-upgrade/project/.board-task-change-freshness/fixture-opencode-snapshot-upgrade.json new file mode 100644 index 00000000..2c97190e --- /dev/null +++ b/test/fixtures/team/task-change-ledger/opencode-snapshot-upgrade/project/.board-task-change-freshness/fixture-opencode-snapshot-upgrade.json @@ -0,0 +1 @@ +{"schemaVersion":2,"source":"task-change-ledger","taskId":"fixture-opencode-snapshot-upgrade","updatedAt":"2026-04-26T10:00:02.000Z","journalStamp":{"events":{"bytes":3265,"mtimeMs":1777197602000,"tailSha256":"fixture-opencode-snapshot-upgrade-tail"}},"eventCount":2,"noticeCount":0,"integrity":"ok","bundleSchemaVersion":2,"bundleKind":"summary"} diff --git a/test/fixtures/team/task-change-ledger/opencode-snapshot-upgrade/project/.board-task-changes/blobs/sha256/402c3103f57599660a8b57bb741815adaa06f6d396f46739c279eec0fc25cfb6 b/test/fixtures/team/task-change-ledger/opencode-snapshot-upgrade/project/.board-task-changes/blobs/sha256/402c3103f57599660a8b57bb741815adaa06f6d396f46739c279eec0fc25cfb6 new file mode 100644 index 00000000..d10a8442 --- /dev/null +++ b/test/fixtures/team/task-change-ledger/opencode-snapshot-upgrade/project/.board-task-changes/blobs/sha256/402c3103f57599660a8b57bb741815adaa06f6d396f46739c279eec0fc25cfb6 @@ -0,0 +1 @@ +export const snapshot = 1; diff --git a/test/fixtures/team/task-change-ledger/opencode-snapshot-upgrade/project/.board-task-changes/blobs/sha256/892dd6554b064c9dec7454fe77a71f364d012bad8e892b1ba9adaa30909fb887 b/test/fixtures/team/task-change-ledger/opencode-snapshot-upgrade/project/.board-task-changes/blobs/sha256/892dd6554b064c9dec7454fe77a71f364d012bad8e892b1ba9adaa30909fb887 new file mode 100644 index 00000000..99778790 --- /dev/null +++ b/test/fixtures/team/task-change-ledger/opencode-snapshot-upgrade/project/.board-task-changes/blobs/sha256/892dd6554b064c9dec7454fe77a71f364d012bad8e892b1ba9adaa30909fb887 @@ -0,0 +1 @@ +export const snapshot = 2; diff --git a/test/fixtures/team/task-change-ledger/opencode-snapshot-upgrade/project/.board-task-changes/bundles/fixture-opencode-snapshot-upgrade.json b/test/fixtures/team/task-change-ledger/opencode-snapshot-upgrade/project/.board-task-changes/bundles/fixture-opencode-snapshot-upgrade.json new file mode 100644 index 00000000..4ebb1b89 --- /dev/null +++ b/test/fixtures/team/task-change-ledger/opencode-snapshot-upgrade/project/.board-task-changes/bundles/fixture-opencode-snapshot-upgrade.json @@ -0,0 +1 @@ +{"schemaVersion":2,"source":"task-change-ledger","bundleKind":"summary","taskId":"fixture-opencode-snapshot-upgrade","generatedAt":"2026-04-26T10:00:02.000Z","journalStamp":{"events":{"bytes":3265,"mtimeMs":1777197602000,"tailSha256":"fixture-opencode-snapshot-upgrade-tail"}},"integrity":"ok","eventCount":2,"projectedEventCount":1,"noticeCount":0,"scope":{"confidence":{"tier":1,"label":"high","reason":"Derived from task-change ledger"},"primaryActorKey":"member:bob","primaryMemberName":"bob","memberName":"bob","memberNames":["bob"],"startTimestamp":"2026-04-26T10:00:01.000Z","endTimestamp":"2026-04-26T10:00:01.000Z","toolUseIds":["bob-edit-snapshot-only"],"toolUseCount":1,"phaseSet":["work"],"executionSeqRange":{"start":0,"end":0},"confidenceBreakdown":{"capture":"high","attribution":"high","reviewability":"full-text"},"visibleFileCount":1,"contributors":[{"actorKey":"member:bob","memberName":"bob","eventCount":1,"noticeCount":0,"touchedFileCount":1,"visibleFileCount":1,"toolUseCount":1,"cumulativeLinesAdded":1,"cumulativeLinesRemoved":1,"firstTimestamp":"2026-04-26T10:00:01.000Z","lastTimestamp":"2026-04-26T10:00:01.000Z"}]},"files":[{"changeKey":"modify:__PROJECT_ROOT__/src/snapshot-only.js","filePath":"__PROJECT_ROOT__/src/snapshot-only.js","relativePath":"src/snapshot-only.js","linesAdded":1,"linesRemoved":1,"diffStatKnown":true,"eventCount":1,"journalEventCount":2,"firstTimestamp":"2026-04-26T10:00:01.000Z","lastTimestamp":"2026-04-26T10:00:01.000Z","latestOperation":"modify","createdInTask":false,"deletedInTask":false,"baselineExists":true,"finalExists":true,"latestBeforeHash":"402c3103f57599660a8b57bb741815adaa06f6d396f46739c279eec0fc25cfb6","latestAfterHash":"892dd6554b064c9dec7454fe77a71f364d012bad8e892b1ba9adaa30909fb887","latestBeforeState":{"exists":true,"sha256":"402c3103f57599660a8b57bb741815adaa06f6d396f46739c279eec0fc25cfb6","sizeBytes":27},"latestAfterState":{"exists":true,"sha256":"892dd6554b064c9dec7454fe77a71f364d012bad8e892b1ba9adaa30909fb887","sizeBytes":27},"contentAvailability":"full-text","reviewability":"full-text","primaryActorKey":"member:bob","memberNames":["bob"],"executionSeqRange":{"start":0,"end":0}}],"totalLinesAdded":1,"totalLinesRemoved":1,"diffStatCompleteness":"complete","totalFiles":1,"confidence":"high","warningCount":0,"warnings":[]} diff --git a/test/fixtures/team/task-change-ledger/opencode-snapshot-upgrade/project/.board-task-changes/events/fixture-opencode-snapshot-upgrade.jsonl b/test/fixtures/team/task-change-ledger/opencode-snapshot-upgrade/project/.board-task-changes/events/fixture-opencode-snapshot-upgrade.jsonl new file mode 100644 index 00000000..23734d04 --- /dev/null +++ b/test/fixtures/team/task-change-ledger/opencode-snapshot-upgrade/project/.board-task-changes/events/fixture-opencode-snapshot-upgrade.jsonl @@ -0,0 +1,2 @@ +{"schemaVersion":1,"taskId":"fixture-opencode-snapshot-upgrade","taskRef":"fixture-opencode-snapshot-upgrade","taskRefKind":"canonical","phase":"work","executionSeq":0,"sessionId":"opencode-session-fixture","memberName":"bob","toolUseId":"bob-edit-snapshot-only","source":"opencode_toolpart_edit","operation":"modify","confidence":"medium","workspaceRoot":"__PROJECT_ROOT__","filePath":"__PROJECT_ROOT__/src/snapshot-only.js","relativePath":"src/snapshot-only.js","timestamp":"2026-04-26T10:00:00.000Z","toolStatus":"succeeded","before":null,"after":null,"beforeState":{"exists":true,"unavailableReason":"opencode-before-content-unavailable"},"afterState":{"exists":true,"unavailableReason":"opencode-edit-final-content-unavailable"},"oldString":"snapshot = 1","newString":"snapshot = 2","linesAdded":0,"linesRemoved":0,"sourceRuntime":"opencode","sourceProvider":"opencode","sourceSessionId":"opencode-session-fixture","sourcePartId":"bob-edit-snapshot-only","sourceMessageId":"assistant-1","parentUserMessageId":"user-1","attributionMethod":"delivery-ledger-taskrefs","sourceImportKey":"opencode\u0000opencode-session-fixture\u0000bob-edit-snapshot-only\u0000src/snapshot-only.js","evidenceProof":"metadata-only-fallback","warnings":["OpenCode edit was captured without a git/snapshot baseline; apply/reject is manual-only."],"eventId":"opencode-metadata-only-event"} +{"schemaVersion":1,"taskId":"fixture-opencode-snapshot-upgrade","taskRef":"fixture-opencode-snapshot-upgrade","taskRefKind":"canonical","phase":"work","executionSeq":0,"sessionId":"opencode-session-fixture","memberName":"bob","toolUseId":"bob-edit-snapshot-only","source":"opencode_toolpart_edit","operation":"modify","confidence":"high","workspaceRoot":"__PROJECT_ROOT__","filePath":"__PROJECT_ROOT__/src/snapshot-only.js","relativePath":"src/snapshot-only.js","timestamp":"2026-04-26T10:00:01.000Z","toolStatus":"succeeded","before":{"sha256":"402c3103f57599660a8b57bb741815adaa06f6d396f46739c279eec0fc25cfb6","sizeBytes":27,"blobRef":"sha256/402c3103f57599660a8b57bb741815adaa06f6d396f46739c279eec0fc25cfb6"},"after":{"sha256":"892dd6554b064c9dec7454fe77a71f364d012bad8e892b1ba9adaa30909fb887","sizeBytes":27,"blobRef":"sha256/892dd6554b064c9dec7454fe77a71f364d012bad8e892b1ba9adaa30909fb887"},"beforeState":{"exists":true,"sha256":"402c3103f57599660a8b57bb741815adaa06f6d396f46739c279eec0fc25cfb6","sizeBytes":27},"afterState":{"exists":true,"sha256":"892dd6554b064c9dec7454fe77a71f364d012bad8e892b1ba9adaa30909fb887","sizeBytes":27},"oldString":"snapshot = 1","newString":"snapshot = 2","linesAdded":1,"linesRemoved":1,"sourceRuntime":"opencode","sourceProvider":"opencode","sourceSessionId":"opencode-session-fixture","sourcePartId":"bob-edit-snapshot-only","sourceMessageId":"assistant-1","parentUserMessageId":"user-1","attributionMethod":"delivery-ledger-taskrefs","sourceImportKey":"opencode\u0000opencode-session-fixture\u0000bob-edit-snapshot-only\u0000src/snapshot-only.js","evidenceProof":"inverse-edit-chain","snapshotId":"opencode-snapshot-window-fixture","snapshotSource":"opencode","supersedesEventId":"opencode-metadata-only-event","eventId":"opencode-snapshot-upgrade-event"} diff --git a/test/fixtures/team/task-change-ledger/opencode-snapshot-upgrade/project/src/snapshot-only.js b/test/fixtures/team/task-change-ledger/opencode-snapshot-upgrade/project/src/snapshot-only.js new file mode 100644 index 00000000..99778790 --- /dev/null +++ b/test/fixtures/team/task-change-ledger/opencode-snapshot-upgrade/project/src/snapshot-only.js @@ -0,0 +1 @@ +export const snapshot = 2; diff --git a/test/main/services/team/taskChangeLedgerFixtures.integration.test.ts b/test/main/services/team/taskChangeLedgerFixtures.integration.test.ts index 1eb45550..212a4b35 100644 --- a/test/main/services/team/taskChangeLedgerFixtures.integration.test.ts +++ b/test/main/services/team/taskChangeLedgerFixtures.integration.test.ts @@ -315,6 +315,50 @@ describe('task change ledger golden fixtures', () => { expect(resolved.contentSource).toBe('ledger-snapshot'); }); + it('reads OpenCode snapshot upgrade fixtures as one full-text ledger row', async () => { + const fixture = await materializeTaskChangeLedgerFixture('opencode-snapshot-upgrade'); + cleanups.push(fixture.cleanup); + const reader = new TaskChangeLedgerReader(); + const changeSet = await reader.readTaskChanges({ + teamName: TEAM_NAME, + taskId: fixture.manifest.taskId, + projectDir: fixture.projectDir, + projectPath: fixture.projectDir, + includeDetails: true, + }); + + expect(changeSet?.files).toHaveLength(1); + const file = changeSet!.files[0]!; + expect(file.relativePath).toBe('src/snapshot-only.js'); + expect(file.ledgerSummary).toMatchObject({ + reviewability: 'full-text', + contentAvailability: 'full-text', + }); + expect(file.snippets).toHaveLength(1); + const snippet = file.snippets[0]!; + expect(snippet.toolName).toBe('Edit'); + expect(snippet.type).toBe('edit'); + expect(snippet.ledger).toMatchObject({ + source: 'ledger-snapshot', + confidence: 'high', + textAvailability: 'full-text', + operation: 'modify', + }); + expect(snippet.ledger?.originalFullContent).toBe('export const snapshot = 1;\n'); + expect(snippet.ledger?.modifiedFullContent).toBe('export const snapshot = 2;\n'); + + const resolver = new FileContentResolver({ findMemberLogPaths: vi.fn(async () => []) } as any); + const resolved = await resolver.getFileContent( + TEAM_NAME, + 'bob', + file.filePath, + file.snippets + ); + expect(resolved.originalFullContent).toBe('export const snapshot = 1;\n'); + expect(resolved.modifiedFullContent).toBe('export const snapshot = 2;\n'); + expect(resolved.contentSource).toBe('ledger-snapshot'); + }); + it('rejects grouped copy fixtures by deleting only the copied path', async () => { const fixture = await materializeTaskChangeLedgerFixture('copy'); cleanups.push(fixture.cleanup); From 642cea8857bc602db7824bed06ff2b5212f76206 Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 28 Apr 2026 23:42:35 +0300 Subject: [PATCH 17/18] fix(changes): rank full text evidence by blobs --- .../services/team/TaskChangeLedgerReader.ts | 20 ++--- .../team/TaskChangeLedgerReader.test.ts | 78 +++++++++++++++++++ 2 files changed, 89 insertions(+), 9 deletions(-) diff --git a/src/main/services/team/TaskChangeLedgerReader.ts b/src/main/services/team/TaskChangeLedgerReader.ts index 3f5d3cff..644f58ca 100644 --- a/src/main/services/team/TaskChangeLedgerReader.ts +++ b/src/main/services/team/TaskChangeLedgerReader.ts @@ -1190,15 +1190,7 @@ export class TaskChangeLedgerReader { } private evidenceRankForEvent(event: LedgerEvent): number { - const hasFullText = - event.before !== null || - event.after !== null || - (event.operation === 'create' && - event.afterState?.exists === true && - !event.afterState.unavailableReason) || - (event.operation === 'delete' && - event.beforeState?.exists === true && - !event.beforeState.unavailableReason); + const hasFullText = this.hasFullTextEvidence(event); switch (event.evidenceProof) { case 'opencode-snapshot': @@ -1214,6 +1206,16 @@ export class TaskChangeLedgerReader { } } + private hasFullTextEvidence(event: Pick): boolean { + if (event.operation === 'create') { + return event.after !== null; + } + if (event.operation === 'delete') { + return event.before !== null; + } + return event.before !== null && event.after !== null; + } + private async readContentRef( projectDir: string, ref: LedgerContentRef | null diff --git a/test/main/services/team/TaskChangeLedgerReader.test.ts b/test/main/services/team/TaskChangeLedgerReader.test.ts index 969fac6c..11b494cd 100644 --- a/test/main/services/team/TaskChangeLedgerReader.test.ts +++ b/test/main/services/team/TaskChangeLedgerReader.test.ts @@ -285,6 +285,84 @@ describe('TaskChangeLedgerReader', () => { expect(snippets[2]?.ledger?.source).toBe('ledger-snapshot'); }); + it('projects partial OpenCode snapshot journal evidence to a later full-text upgrade', async () => { + tmpDir = await fsTempDir(); + const eventsDir = path.join(tmpDir, '.board-task-changes', 'events'); + const blobsDir = path.join(tmpDir, '.board-task-changes', 'blobs'); + await mkdir(eventsDir, { recursive: true }); + await mkdir(blobsDir, { recursive: true }); + + const beforeContent = 'export const value = 1;\n'; + const afterContent = 'export const value = 2;\n'; + await writeFile(path.join(blobsDir, 'before.txt'), beforeContent, 'utf8'); + await writeFile(path.join(blobsDir, 'after.txt'), afterContent, 'utf8'); + const sourceImportKey = 'opencode\0session-1\0part-edit\0src/file.ts'; + const baseEvent = { + schemaVersion: 1, + taskId: TASK_ID, + taskRef: TASK_ID, + taskRefKind: 'canonical', + phase: 'work', + executionSeq: 1, + sessionId: 'opencode-session-1', + memberName: 'bob', + toolUseId: 'part-edit', + source: 'opencode_toolpart_edit', + operation: 'modify', + confidence: 'high', + workspaceRoot: '/repo', + filePath: '/repo/src/file.ts', + relativePath: 'src/file.ts', + timestamp: '2026-03-01T10:00:00.000Z', + toolStatus: 'succeeded', + sourceRuntime: 'opencode', + sourceProvider: 'opencode', + sourceImportKey, + evidenceProof: 'opencode-snapshot', + beforeState: { exists: true, sha256: sha(beforeContent), sizeBytes: beforeContent.length }, + afterState: { exists: true, sha256: sha(afterContent), sizeBytes: afterContent.length }, + linesAdded: 1, + linesRemoved: 1, + }; + await writeFile( + path.join(eventsDir, `${encodeURIComponent(TASK_ID)}.jsonl`), + [ + { + ...baseEvent, + eventId: 'event-partial', + before: null, + after: { sha256: sha(afterContent), sizeBytes: afterContent.length, blobRef: 'after.txt' }, + }, + { + ...baseEvent, + eventId: 'event-full', + supersedesEventId: 'event-partial', + before: { sha256: sha(beforeContent), sizeBytes: beforeContent.length, blobRef: 'before.txt' }, + after: { sha256: sha(afterContent), sizeBytes: afterContent.length, blobRef: 'after.txt' }, + }, + ] + .map((entry) => JSON.stringify(entry)) + .join('\n') + '\n', + 'utf8' + ); + + const reader = new TaskChangeLedgerReader(); + const result = await reader.readTaskChanges({ + teamName: 'team', + taskId: TASK_ID, + projectDir: tmpDir, + projectPath: '/repo', + includeDetails: true, + }); + + expect(result?.files).toHaveLength(1); + const snippets = result?.files[0]?.snippets ?? []; + expect(snippets).toHaveLength(1); + expect(snippets[0]?.ledger?.eventId).toBe('event-full'); + expect(snippets[0]?.ledger?.originalFullContent).toBe(beforeContent); + expect(snippets[0]?.ledger?.modifiedFullContent).toBe(afterContent); + }); + it('groups rename relations in summary-only bundles without losing absolute paths', async () => { const relation = { kind: 'rename', oldPath: 'src/old.ts', newPath: 'src/new.ts' }; tmpDir = await makeLedgerBundle({ From 07a9f603de878c6a96bd47447479db1cc8f33872 Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 29 Apr 2026 00:14:53 +0300 Subject: [PATCH 18/18] fix(changes): require strict ledger hunk rejects --- .../services/team/ReviewApplierService.ts | 106 +++++++++++++++++- .../team/ReviewApplierService.test.ts | 95 +++++++++++++++- 2 files changed, 199 insertions(+), 2 deletions(-) diff --git a/src/main/services/team/ReviewApplierService.ts b/src/main/services/team/ReviewApplierService.ts index e81214d2..734c5174 100644 --- a/src/main/services/team/ReviewApplierService.ts +++ b/src/main/services/team/ReviewApplierService.ts @@ -293,6 +293,7 @@ export class ReviewApplierService { decision.fileDecision === 'rejected', allHunksRejected, rejectedHunkIndices, + decision.hunkContextHashes, fileContent.snippets ); if (ledgerOutcome.handled) { @@ -450,6 +451,7 @@ export class ReviewApplierService { fileRejected: boolean, allHunksRejected: boolean, rejectedHunkIndices: number[], + hunkContextHashes: Record | undefined, snippets: SnippetDiff[] ): Promise { const ledgerSnippets = snippets.filter((snippet) => snippet.ledger && !snippet.isError); @@ -497,6 +499,20 @@ export class ReviewApplierService { error: 'Ledger full text is unavailable; partial reject requires manual review.', }; } + const strictHunks = mapRejectedHunkIndicesByHashStrict( + original, + modified, + rejectedHunkIndices, + hunkContextHashes + ); + if (!strictHunks.ok) { + return { + handled: true, + status: strictHunks.code === 'conflict' ? 'conflict' : 'error', + code: strictHunks.code, + error: strictHunks.error, + }; + } const guard = await this.checkLedgerCurrentHash( filePath, lastLedger.afterState?.sha256 ?? lastLedger.afterHash ?? undefined @@ -504,7 +520,7 @@ export class ReviewApplierService { if (!guard.ok) { return guard.outcome; } - const patchResult = this.tryHunkLevelReject(original, modified, rejectedHunkIndices); + const patchResult = this.tryStrictHunkLevelReject(original, modified, strictHunks.indices); if (!patchResult) { return { handled: true, @@ -1035,6 +1051,46 @@ export class ReviewApplierService { hadConflicts: false, }; } + + private tryStrictHunkLevelReject( + original: string, + modified: string, + hunkIndices: number[] + ): RejectResult | null { + const patch = structuredPatch('file', 'file', original, modified); + + if (!patch.hunks || patch.hunks.length === 0) return null; + + const validIndices = hunkIndices.filter((idx) => idx >= 0 && idx < patch.hunks.length); + if (validIndices.length !== hunkIndices.length || validIndices.length === 0) return null; + + const inversedHunks: StructuredPatchHunk[] = []; + for (const idx of validIndices) { + const hunk = patch.hunks[idx]; + if (!hunk) return null; + inversedHunks.push(invertHunk(hunk)); + } + + const inversePatch = { + oldFileName: 'file', + newFileName: 'file', + oldHeader: undefined, + newHeader: undefined, + hunks: inversedHunks, + }; + + const result = applyPatch(modified, inversePatch, { fuzzFactor: 0 }); + if (result === false) { + logger.debug('Strict ledger hunk-level inverse patch не удался'); + return null; + } + + return { + success: true, + newContent: result, + hadConflicts: false, + }; + } } function buildHunkHashIndexMap(original: string, modified: string): Map { @@ -1086,6 +1142,54 @@ function mapRejectedHunkIndicesByHash( return [...out].sort((a, b) => a - b); } +function mapRejectedHunkIndicesByHashStrict( + original: string, + modified: string, + rejectedIndices: number[], + hunkContextHashes: Record | undefined +): { ok: true; indices: number[] } | { ok: false; code: ApplyErrorCode; error: string } { + if (rejectedIndices.length === 0) { + return { ok: true, indices: [] }; + } + if (!hunkContextHashes || Object.keys(hunkContextHashes).length === 0) { + return { + ok: false, + code: 'manual-review-required', + error: 'Ledger partial reject requires stable hunk context hashes.', + }; + } + + const hashMap = buildHunkHashIndexMap(original, modified); + const out = new Set(); + for (const idx of rejectedIndices) { + const hash = hunkContextHashes[idx]; + if (!hash) { + return { + ok: false, + code: 'manual-review-required', + error: 'Ledger partial reject is missing a hunk context hash.', + }; + } + const candidates = hashMap.get(hash); + if (!candidates || candidates.length === 0) { + return { + ok: false, + code: 'conflict', + error: 'Ledger partial reject hunk context changed; please re-review.', + }; + } + if (candidates.length > 1) { + return { + ok: false, + code: 'conflict', + error: 'Ledger partial reject hunk context is ambiguous; please re-review.', + }; + } + out.add(candidates[0]!); + } + return { ok: true, indices: [...out].sort((a, b) => a - b) }; +} + // ── Module-level helpers ── /** diff --git a/test/main/services/team/ReviewApplierService.test.ts b/test/main/services/team/ReviewApplierService.test.ts index 5b784788..528595ac 100644 --- a/test/main/services/team/ReviewApplierService.test.ts +++ b/test/main/services/team/ReviewApplierService.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { createHash } from 'crypto'; import { structuredPatch } from 'diff'; +import { computeDiffContextHash } from '@shared/utils/diffContextHash'; import type { SnippetDiff } from '@shared/types'; @@ -985,7 +986,8 @@ describe('ReviewApplierService', () => { { filePath, fileDecision: 'pending', - hunkDecisions: { 0: 'rejected' }, + hunkDecisions: { 0: 'rejected', 1: 'pending' }, + hunkContextHashes: buildHunkContextHashes(original, modified), }, ], }, @@ -1034,8 +1036,99 @@ describe('ReviewApplierService', () => { expect(res).toMatchObject({ applied: 1, conflicts: 0 }); expect(writeFile).toHaveBeenCalledWith(filePath, original, 'utf8'); }); + + it('ledger partial reject refuses stale hunk context instead of falling back to index', async () => { + const fsPromises = await import('fs/promises'); + const readFile = fsPromises.readFile as unknown as ReturnType; + const writeFile = fsPromises.writeFile as unknown as ReturnType; + + const filePath = '/tmp/stale-ledger.ts'; + const original = 'const value = 1;\nconst keep = true;\n'; + const modified = 'const value = 2;\nconst keep = true;\n'; + readFile.mockResolvedValue(modified); + + const { ReviewApplierService } = await import('@main/services/team/ReviewApplierService'); + const svc = new ReviewApplierService(); + + const res = await svc.applyReviewDecisions( + { + teamName: 'team', + decisions: [ + { + filePath, + fileDecision: 'pending', + hunkDecisions: { 0: 'rejected', 1: 'pending' }, + hunkContextHashes: { 0: 'stale-context-hash' }, + }, + ], + }, + new Map([ + [ + filePath, + { + filePath, + relativePath: 'stale-ledger.ts', + snippets: [ + { + toolUseId: 'ledger-1', + filePath, + toolName: 'Edit', + type: 'edit', + oldString: 'const value = 1;\n', + newString: 'const value = 2;\n', + replaceAll: false, + timestamp: '2026-03-01T10:00:00.000Z', + isError: false, + ledger: { + eventId: 'event-1', + source: 'ledger-exact', + confidence: 'exact', + originalFullContent: original, + modifiedFullContent: modified, + beforeHash: sha(original), + afterHash: sha(modified), + operation: 'modify', + beforeState: { exists: true, sha256: sha(original) }, + afterState: { exists: true, sha256: sha(modified) }, + }, + }, + ], + linesAdded: 1, + linesRemoved: 1, + isNewFile: false, + originalFullContent: original, + modifiedFullContent: modified, + contentSource: 'ledger-exact', + }, + ], + ]) + ); + + expect(res.applied).toBe(0); + expect(res.conflicts).toBe(1); + expect(res.errors[0]?.code).toBe('conflict'); + expect(writeFile).not.toHaveBeenCalled(); + }); }); function sha(content: string): string { return createHash('sha256').update(content).digest('hex'); } + +function buildHunkContextHashes(original: string, modified: string): Record { + const patch = structuredPatch('file', 'file', original, modified); + const out: Record = {}; + for (let i = 0; i < patch.hunks.length; i++) { + const hunk = patch.hunks[i]!; + const oldSideContent = hunk.lines + .filter((line) => !line.startsWith('+')) + .map((line) => line.slice(1)) + .join('\n'); + const newSideContent = hunk.lines + .filter((line) => !line.startsWith('-')) + .map((line) => line.slice(1)) + .join('\n'); + out[i] = computeDiffContextHash(oldSideContent, newSideContent); + } + return out; +}