fix(changes): reread ledger after opencode backfill failure
This commit is contained in:
parent
ba09010fcb
commit
9d9c7fbd38
2 changed files with 143 additions and 69 deletions
|
|
@ -70,6 +70,11 @@ interface OpenCodeBackfillCacheEntry {
|
|||
expiresAt: number;
|
||||
}
|
||||
|
||||
interface OpenCodeBackfillAttempt {
|
||||
attempted: boolean;
|
||||
backfilled: boolean;
|
||||
}
|
||||
|
||||
interface OpenCodeDeliveryContextTempFile {
|
||||
filePath: string | null;
|
||||
cleanup: () => Promise<void>;
|
||||
|
|
@ -81,7 +86,7 @@ export class ChangeExtractorService {
|
|||
private taskChangeSummaryInFlight = new Map<string, Promise<TaskChangeSetV2>>();
|
||||
private taskChangeSummaryVersionByTask = new Map<string, number>();
|
||||
private taskChangeSummaryValidationInFlight = new Set<string>();
|
||||
private openCodeBackfillInFlight = new Map<string, Promise<boolean>>();
|
||||
private openCodeBackfillInFlight = new Map<string, Promise<OpenCodeBackfillAttempt>>();
|
||||
private openCodeBackfillCache = new Map<string, OpenCodeBackfillCacheEntry>();
|
||||
private openCodeTeamEligibilityCache = new Map<string, { value: boolean; expiresAt: number }>();
|
||||
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<boolean> {
|
||||
private async tryBackfillOpenCodeLedger(
|
||||
input: ResolvedTaskChangeComputeInput
|
||||
): Promise<OpenCodeBackfillAttempt> {
|
||||
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<boolean> {
|
||||
): Promise<OpenCodeBackfillAttempt> {
|
||||
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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -126,6 +126,70 @@ async function writeOpenCodeDeliveryLedger(
|
|||
return filePath;
|
||||
}
|
||||
|
||||
async function writeOpenCodeLedgerBundle(
|
||||
projectDir: string,
|
||||
projectPath: string,
|
||||
taskId: string = TASK_ID
|
||||
): Promise<void> {
|
||||
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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue