diff --git a/packages/context/src/ingest/isolated-diff/patch-integrator.ts b/packages/context/src/ingest/isolated-diff/patch-integrator.ts index 4c6e46f5..ad9d25cd 100644 --- a/packages/context/src/ingest/isolated-diff/patch-integrator.ts +++ b/packages/context/src/ingest/isolated-diff/patch-integrator.ts @@ -3,11 +3,26 @@ import type { GitService } from '../../core/index.js'; import type { IngestTraceWriter } from '../ingest-trace.js'; import { traceTimed } from '../ingest-trace.js'; import { assertPatchAllowedForWorkUnit, parsePatchTouchedPaths } from './git-patch.js'; +import type { TextualConflictResolutionResult } from './textual-conflict-resolver.js'; + +export type PatchIntegrationTextualResolution = + | { status: 'repaired'; attempts: number; changedPaths: string[] } + | { status: 'failed'; attempts: number; reason: string }; export type PatchIntegrationResult = - | { status: 'accepted'; commitSha: string; touchedPaths: string[] } - | { status: 'textual_conflict'; reason: string; touchedPaths: string[] } - | { status: 'semantic_conflict'; reason: string; touchedPaths: string[] }; + | { status: 'accepted'; commitSha: string; touchedPaths: string[]; textualResolution?: PatchIntegrationTextualResolution } + | { + status: 'textual_conflict'; + reason: string; + touchedPaths: string[]; + textualResolution?: PatchIntegrationTextualResolution; + } + | { + status: 'semantic_conflict'; + reason: string; + touchedPaths: string[]; + textualResolution?: PatchIntegrationTextualResolution; + }; export interface IntegrateWorkUnitPatchInput { unitKey: string; @@ -18,6 +33,12 @@ export interface IntegrateWorkUnitPatchInput { slDisallowed: boolean; allowedTargetConnectionIds: ReadonlySet; validateAppliedTree(touchedPaths: string[]): Promise; + resolveTextualConflict?(input: { + unitKey: string; + patchPath: string; + touchedPaths: string[]; + reason: string; + }): Promise; } function errorMessage(error: unknown): string { @@ -73,16 +94,104 @@ export async function integrateWorkUnitPatch(input: IntegrateWorkUnitPatchInput) if (preApplyHead) { await input.integrationGit.resetHardTo(preApplyHead); } + const reason = errorMessage(error); await input.trace.event('error', 'integration', 'patch_textual_conflict', { unitKey: input.unitKey, patchPath: input.patchPath, touchedPaths, - reason: errorMessage(error), + reason, + }); + + if (!input.resolveTextualConflict) { + return { + status: 'textual_conflict', + reason, + touchedPaths, + }; + } + + const textualResolution = await input.resolveTextualConflict({ + unitKey: input.unitKey, + patchPath: input.patchPath, + touchedPaths, + reason, + }); + + if (textualResolution.status === 'failed') { + if (preApplyHead) { + await input.integrationGit.resetHardTo(preApplyHead); + } + return { + status: 'textual_conflict', + reason: textualResolution.reason, + touchedPaths, + textualResolution, + }; + } + + try { + await traceTimed( + input.trace, + 'integration', + 'semantic_gate_after_textual_resolution', + { unitKey: input.unitKey, touchedPaths: textualResolution.changedPaths }, + async () => { + await input.validateAppliedTree(textualResolution.changedPaths); + }, + ); + } catch (semanticError) { + if (preApplyHead) { + await input.integrationGit.resetHardTo(preApplyHead); + } + await input.trace.event('error', 'integration', 'patch_semantic_conflict_after_textual_resolution', { + unitKey: input.unitKey, + patchPath: input.patchPath, + touchedPaths: textualResolution.changedPaths, + reason: errorMessage(semanticError), + }); + return { + status: 'semantic_conflict', + reason: errorMessage(semanticError), + touchedPaths: textualResolution.changedPaths, + textualResolution, + }; + } + + const commit = await input.integrationGit.commitFiles( + textualResolution.changedPaths, + `ingest: resolve WorkUnit ${input.unitKey} conflict`, + input.author.name, + input.author.email, + ); + if (!commit.created) { + if (preApplyHead) { + await input.integrationGit.resetHardTo(preApplyHead); + } + const noChangeReason = 'textual resolver produced no committable changes'; + await input.trace.event('error', 'integration', 'textual_conflict_resolver_noop', { + unitKey: input.unitKey, + patchPath: input.patchPath, + touchedPaths: textualResolution.changedPaths, + }); + return { + status: 'textual_conflict', + reason: noChangeReason, + touchedPaths: textualResolution.changedPaths, + textualResolution, + }; + } + + await input.trace.event('debug', 'integration', 'patch_accepted_after_textual_resolution', { + unitKey: input.unitKey, + commitSha: commit.commitHash, + touchedPaths: textualResolution.changedPaths, + attempts: textualResolution.attempts, }); return { - status: 'textual_conflict', - reason: errorMessage(error), - touchedPaths, + status: 'accepted', + commitSha: commit.commitHash, + touchedPaths: textualResolution.changedPaths, + textualResolution, }; } diff --git a/packages/context/src/ingest/isolated-diff/textual-conflict-resolver.ts b/packages/context/src/ingest/isolated-diff/textual-conflict-resolver.ts new file mode 100644 index 00000000..c5128291 --- /dev/null +++ b/packages/context/src/ingest/isolated-diff/textual-conflict-resolver.ts @@ -0,0 +1,238 @@ +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { z } from 'zod'; +import type { AgentRunnerPort, KtxRuntimeToolSet } from '../../llm/index.js'; +import type { IngestTraceWriter } from '../ingest-trace.js'; +import { traceTimed } from '../ingest-trace.js'; + +export type TextualConflictResolutionResult = + | { status: 'repaired'; attempts: number; changedPaths: string[] } + | { status: 'failed'; attempts: number; reason: string }; + +export interface ResolveTextualConflictInput { + agentRunner: AgentRunnerPort; + workdir: string; + unitKey: string; + patchPath: string; + touchedPaths: string[]; + trace: IngestTraceWriter; + reason: string; + maxAttempts?: number; + stepBudget?: number; +} + +const readIntegrationFileSchema = z.object({ + path: z.string().min(1), +}); + +const writeIntegrationFileSchema = z.object({ + path: z.string().min(1), + content: z.string(), +}); + +const deleteIntegrationFileSchema = z.object({ + path: z.string().min(1), +}); + +function normalizeRepoPath(path: string): string { + const normalized = path.replace(/\\/g, '/').replace(/^\/+/, ''); + const parts = normalized.split('/').filter((part) => part.length > 0); + if (parts.length === 0 || parts.some((part) => part === '.' || part === '..')) { + throw new Error(`resolver path must be a repository-relative path: ${path}`); + } + return parts.join('/'); +} + +function assertAllowedPath(path: string, allowedPaths: ReadonlySet): string { + const normalized = normalizeRepoPath(path); + if (!allowedPaths.has(normalized)) { + throw new Error(`resolver path not allowed: ${normalized}`); + } + return normalized; +} + +async function readOptionalFile(path: string): Promise<{ exists: boolean; content: string }> { + try { + return { exists: true, content: await readFile(path, 'utf-8') }; + } catch (error) { + if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') { + return { exists: false, content: '' }; + } + throw error; + } +} + +function buildResolverSystemPrompt(): string { + return ` +You repair one failed KTX isolated-diff patch inside the integration worktree. + + + +- Preserve accepted integration content that is unrelated to the failed patch. +- Incorporate the failed patch only when the patch evidence is compatible with the current file. +- Edit only paths exposed by the resolver tools. +- Prefer the smallest text edit that makes the composed artifact coherent. +- Do not create new facts that are absent from the current file or failed patch. +- Stop after writing the repaired file content. +`; +} + +function buildResolverUserPrompt(input: { + unitKey: string; + patchPath: string; + touchedPaths: string[]; + reason: string; + attempt: number; + maxAttempts: number; +}): string { + return `Repair isolated-diff textual conflict. + +WorkUnit: ${input.unitKey} +Attempt: ${input.attempt} of ${input.maxAttempts} +Patch path: ${input.patchPath} +Touched paths: +${input.touchedPaths.map((path) => `- ${path}`).join('\n')} + +Git apply failure: +${input.reason} + +Use read_failed_patch first. Then read the touched integration files, write the +repaired content, and stop.`; +} + +function buildToolSet(input: { + workdir: string; + patchPath: string; + allowedPaths: ReadonlySet; + editedPaths: Set; +}): KtxRuntimeToolSet { + return { + read_failed_patch: { + name: 'read_failed_patch', + description: 'Read the failed Git patch that could not be applied to the integration worktree.', + inputSchema: z.object({}), + execute: async () => { + const patch = await readFile(input.patchPath, 'utf-8'); + return { + markdown: patch, + structured: { patchPath: input.patchPath, bytes: Buffer.byteLength(patch) }, + }; + }, + }, + read_integration_file: { + name: 'read_integration_file', + description: 'Read one allowed file from the current integration worktree.', + inputSchema: readIntegrationFileSchema, + execute: async ({ path }: z.infer) => { + const normalized = assertAllowedPath(path, input.allowedPaths); + const file = await readOptionalFile(join(input.workdir, normalized)); + return { + markdown: file.exists ? file.content : `(missing file: ${normalized})`, + structured: { path: normalized, exists: file.exists }, + }; + }, + }, + write_integration_file: { + name: 'write_integration_file', + description: 'Replace one allowed integration worktree file with repaired text content.', + inputSchema: writeIntegrationFileSchema, + execute: async ({ path, content }: z.infer) => { + const normalized = assertAllowedPath(path, input.allowedPaths); + const fullPath = join(input.workdir, normalized); + await mkdir(dirname(fullPath), { recursive: true }); + await writeFile(fullPath, content, 'utf-8'); + input.editedPaths.add(normalized); + return { + markdown: `Wrote ${normalized}`, + structured: { path: normalized, bytes: Buffer.byteLength(content) }, + }; + }, + }, + delete_integration_file: { + name: 'delete_integration_file', + description: 'Delete one allowed integration worktree file when the failed patch proves the deletion is correct.', + inputSchema: deleteIntegrationFileSchema, + execute: async ({ path }: z.infer) => { + const normalized = assertAllowedPath(path, input.allowedPaths); + await rm(join(input.workdir, normalized), { force: true }); + input.editedPaths.add(normalized); + return { + markdown: `Deleted ${normalized}`, + structured: { path: normalized }, + }; + }, + }, + }; +} + +export async function resolveTextualConflict( + input: ResolveTextualConflictInput, +): Promise { + const allowedPaths = new Set(input.touchedPaths.map(normalizeRepoPath)); + const maxAttempts = input.maxAttempts ?? 1; + const stepBudget = input.stepBudget ?? 12; + let lastFailure = 'resolver did not run'; + + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + const editedPaths = new Set(); + const traceData = { + unitKey: input.unitKey, + patchPath: input.patchPath, + touchedPaths: [...allowedPaths].sort(), + attempt, + maxAttempts, + reason: input.reason, + }; + const result = await traceTimed(input.trace, 'resolver', 'textual_conflict_resolver', traceData, async () => + input.agentRunner.runLoop({ + modelRole: 'repair', + systemPrompt: buildResolverSystemPrompt(), + userPrompt: buildResolverUserPrompt({ + unitKey: input.unitKey, + patchPath: input.patchPath, + touchedPaths: [...allowedPaths].sort(), + reason: input.reason, + attempt, + maxAttempts, + }), + toolSet: buildToolSet({ + workdir: input.workdir, + patchPath: input.patchPath, + allowedPaths, + editedPaths, + }), + stepBudget, + telemetryTags: { + operationName: 'ingest-isolated-diff-textual-resolver', + source: input.trace.context.sourceKey, + jobId: input.trace.context.jobId, + unitKey: input.unitKey, + }, + }), + ); + + if (result.stopReason === 'error') { + lastFailure = result.error?.message ?? 'resolver agent loop errored'; + await input.trace.event('error', 'resolver', 'textual_conflict_resolver_failed', traceData, result.error); + continue; + } + + const changedPaths = [...editedPaths].sort(); + if (changedPaths.length === 0) { + lastFailure = 'resolver completed without editing an allowed path'; + await input.trace.event('error', 'resolver', 'textual_conflict_resolver_failed', { + ...traceData, + reason: lastFailure, + }); + continue; + } + + await input.trace.event('debug', 'resolver', 'textual_conflict_resolver_repaired', { + ...traceData, + changedPaths, + }); + return { status: 'repaired', attempts: attempt, changedPaths }; + } + + return { status: 'failed', attempts: maxAttempts, reason: lastFailure }; +}