feat(ingest): repair isolated diff semantic gate failures

This commit is contained in:
Andrey Avtomonov 2026-05-18 00:37:09 +02:00
parent b05c9a4f0d
commit 51a5530995
2 changed files with 200 additions and 6 deletions

View file

@ -298,4 +298,107 @@ describe('integrateWorkUnitPatch', () => {
expect(await git.revParseHead()).toBe(acceptedHead);
await expect(readFile(join(configDir, 'wiki/global/a.md'), 'utf-8')).resolves.toBe('accepted\n');
});
it('repairs semantic gate failures after a patch applies cleanly', async () => {
const { homeDir, configDir, git, baseSha } = await makeRepo();
const childDir = join(homeDir, 'child-semantic-repair');
await git.addWorktree(childDir, 'child-semantic-repair', baseSha);
const childGit = git.forWorktree(childDir);
await writeFile(join(childDir, 'wiki/global/a.md'), 'bad semantic ref\n');
await childGit.commitFiles(['wiki/global/a.md'], 'bad semantic edit', 'System User', 'system@example.com');
const patchPath = join(homeDir, 'patches/semantic-repair.patch');
await childGit.writeBinaryNoRenamePatch(baseSha, 'HEAD', patchPath);
const trace = new FileIngestTraceWriter({
tracePath: join(homeDir, '.ktx/ingest-traces/job-semantic-repair/trace.jsonl'),
jobId: 'job-semantic-repair',
connectionId: 'c1',
sourceKey: 'fake',
level: 'trace',
});
const validateAppliedTree = vi
.fn()
.mockRejectedValueOnce(new Error('final artifact gates failed:\na: unknown semantic-layer entity'))
.mockResolvedValueOnce(undefined);
const result = await integrateWorkUnitPatch({
unitKey: 'wu-repairable',
patchPath,
integrationGit: git,
trace,
author: { name: 'KTX Test', email: 'system@ktx.local' },
validateAppliedTree,
slDisallowed: false,
allowedTargetConnectionIds: new Set(['c1']),
repairGateFailure: vi.fn(async (context) => {
expect(context).toMatchObject({
unitKey: 'wu-repairable',
patchPath,
touchedPaths: ['wiki/global/a.md'],
});
await writeFile(join(configDir, 'wiki/global/a.md'), 'repaired semantic ref\n', 'utf-8');
return {
status: 'repaired' as const,
attempts: 1,
changedPaths: ['wiki/global/a.md'],
};
}),
});
expect(result).toMatchObject({
status: 'accepted',
touchedPaths: ['wiki/global/a.md'],
gateRepair: {
status: 'repaired',
attempts: 1,
changedPaths: ['wiki/global/a.md'],
},
});
expect(validateAppliedTree).toHaveBeenCalledTimes(2);
await expect(readFile(join(configDir, 'wiki/global/a.md'), 'utf-8')).resolves.toBe('repaired semantic ref\n');
await expect(readFile(trace.tracePath, 'utf-8')).resolves.toContain('patch_accepted_after_gate_repair');
});
it('keeps the pre-apply tree when semantic gate repair fails', async () => {
const { homeDir, configDir, git, baseSha } = await makeRepo();
const childDir = join(homeDir, 'child-semantic-repair-fails');
await git.addWorktree(childDir, 'child-semantic-repair-fails', baseSha);
const childGit = git.forWorktree(childDir);
await writeFile(join(childDir, 'wiki/global/a.md'), 'bad semantic ref\n');
await childGit.commitFiles(['wiki/global/a.md'], 'bad semantic edit', 'System User', 'system@example.com');
const patchPath = join(homeDir, 'patches/semantic-repair-fails.patch');
await childGit.writeBinaryNoRenamePatch(baseSha, 'HEAD', patchPath);
const trace = new FileIngestTraceWriter({
tracePath: join(homeDir, '.ktx/ingest-traces/job-semantic-repair-fails/trace.jsonl'),
jobId: 'job-semantic-repair-fails',
connectionId: 'c1',
sourceKey: 'fake',
level: 'trace',
});
const result = await integrateWorkUnitPatch({
unitKey: 'wu-not-repaired',
patchPath,
integrationGit: git,
trace,
author: { name: 'KTX Test', email: 'system@ktx.local' },
validateAppliedTree: vi.fn().mockRejectedValue(new Error('final artifact gates failed')),
slDisallowed: false,
allowedTargetConnectionIds: new Set(['c1']),
repairGateFailure: vi.fn(async () => ({
status: 'failed' as const,
attempts: 1,
reason: 'gate repair completed without editing an allowed path',
})),
});
expect(result).toMatchObject({
status: 'semantic_conflict',
gateRepair: {
status: 'failed',
attempts: 1,
reason: 'gate repair completed without editing an allowed path',
},
});
await expect(readFile(join(configDir, 'wiki/global/a.md'), 'utf-8')).resolves.toBe('old\n');
});
});

View file

@ -1,5 +1,6 @@
import { readFile } from 'node:fs/promises';
import type { GitService } from '../../core/index.js';
import type { FinalGateRepairResult } from '../final-gate-repair.js';
import type { IngestTraceWriter } from '../ingest-trace.js';
import { traceTimed } from '../ingest-trace.js';
import { assertPatchAllowedForWorkUnit, parsePatchTouchedPaths } from './git-patch.js';
@ -10,18 +11,26 @@ export type PatchIntegrationTextualResolution =
| { status: 'failed'; attempts: number; reason: string };
export type PatchIntegrationResult =
| { status: 'accepted'; commitSha: string; touchedPaths: string[]; textualResolution?: PatchIntegrationTextualResolution }
| {
status: 'accepted';
commitSha: string;
touchedPaths: string[];
textualResolution?: PatchIntegrationTextualResolution;
gateRepair?: FinalGateRepairResult;
}
| {
status: 'textual_conflict';
reason: string;
touchedPaths: string[];
textualResolution?: PatchIntegrationTextualResolution;
gateRepair?: FinalGateRepairResult;
}
| {
status: 'semantic_conflict';
reason: string;
touchedPaths: string[];
textualResolution?: PatchIntegrationTextualResolution;
gateRepair?: FinalGateRepairResult;
};
export interface IntegrateWorkUnitPatchInput {
@ -39,6 +48,12 @@ export interface IntegrateWorkUnitPatchInput {
touchedPaths: string[];
reason: string;
}): Promise<TextualConflictResolutionResult>;
repairGateFailure?(input: {
unitKey: string;
patchPath: string;
touchedPaths: string[];
reason: string;
}): Promise<FinalGateRepairResult>;
}
function errorMessage(error: unknown): string {
@ -200,18 +215,94 @@ export async function integrateWorkUnitPatch(input: IntegrateWorkUnitPatchInput)
await input.validateAppliedTree(touchedPaths);
});
} catch (error) {
if (preApplyHead) {
await input.integrationGit.resetHardTo(preApplyHead);
}
const reason = errorMessage(error);
await input.trace.event('error', 'integration', 'patch_semantic_conflict', {
unitKey: input.unitKey,
patchPath: input.patchPath,
touchedPaths,
reason: errorMessage(error),
reason,
});
if (input.repairGateFailure) {
const gateRepair = await input.repairGateFailure({
unitKey: input.unitKey,
patchPath: input.patchPath,
touchedPaths,
reason,
});
if (gateRepair.status === 'failed') {
if (preApplyHead) {
await input.integrationGit.resetHardTo(preApplyHead);
}
return {
status: 'semantic_conflict',
reason: gateRepair.reason,
touchedPaths,
gateRepair,
};
}
try {
await traceTimed(
input.trace,
'integration',
'semantic_gate_after_gate_repair',
{ unitKey: input.unitKey, touchedPaths: gateRepair.changedPaths },
async () => {
await input.validateAppliedTree(gateRepair.changedPaths);
},
);
} catch (repairValidationError) {
if (preApplyHead) {
await input.integrationGit.resetHardTo(preApplyHead);
}
return {
status: 'semantic_conflict',
reason: errorMessage(repairValidationError),
touchedPaths: gateRepair.changedPaths,
gateRepair,
};
}
const commit = await input.integrationGit.commitFiles(
gateRepair.changedPaths,
`ingest: repair WorkUnit ${input.unitKey} gates`,
input.author.name,
input.author.email,
);
if (!commit.created) {
if (preApplyHead) {
await input.integrationGit.resetHardTo(preApplyHead);
}
return {
status: 'semantic_conflict',
reason: 'gate repair produced no committable changes',
touchedPaths: gateRepair.changedPaths,
gateRepair,
};
}
await input.trace.event('debug', 'integration', 'patch_accepted_after_gate_repair', {
unitKey: input.unitKey,
commitSha: commit.commitHash,
touchedPaths: gateRepair.changedPaths,
attempts: gateRepair.attempts,
});
return {
status: 'accepted',
commitSha: commit.commitHash,
touchedPaths: gateRepair.changedPaths,
gateRepair,
};
}
if (preApplyHead) {
await input.integrationGit.resetHardTo(preApplyHead);
}
return {
status: 'semantic_conflict',
reason: errorMessage(error),
reason,
touchedPaths,
};
}