import { randomUUID } from 'node:crypto'; import { join } from 'node:path'; import { z } from 'zod'; import { sha256Canonical } from '../control-plane/snapshots.ts'; import { validateRepoRelativePath } from '../workspace/repo-store.ts'; import type { RunState } from './state.ts'; import { readState, writeState } from './state.ts'; import { readPaused, savePaused } from './resume.ts'; import type { OutboundItem } from './communication-types.ts'; import { cancelOutboundDelivery, listOutbound, finalizeDelivery } from './outbound.ts'; import { Trace } from '../harness/trace.ts'; import { patchpawPaths } from '../config/paths.ts'; import { atomicJson, immutableJson, readJson } from './durable-json.ts'; import { applyRunPhase } from './phases.ts'; export const CONFLICT_PROPOSAL_SCHEMA_VERSION = 'patchpaw.conflict-proposal.v1'; export const CONFLICT_PROPOSAL_CURRENT_SCHEMA_VERSION = 'patchpaw.conflict-proposal-current.v1'; // Stage 07 enables the mechanical approval gate. The gate itself never decides whether a // proposal is semantically correct; it only verifies the durable proposal, actor, Git facts, // snapshot and configured permission before handing the retained workspace to the Repair task. export const CONFLICT_APPROVAL_ENABLED = true; const text = (max: number) => z.string().trim().min(1).max(max); export const conflictProposalDraftSchema = z.object({ summary: text(12_000), pr_intent: text(12_000), current_base_intent: text(12_000), conflicts: z.array(z.object({ path: text(1_024), issue: text(12_000), pr_side: text(12_000), base_side: text(12_000), proposed_resolution: text(12_000), disagreement_or_tradeoff: text(12_000), })).min(1).max(200), affected_files: z.array(text(1_024)).min(1).max(500), verification_plan: z.array(text(4_000)).min(1).max(100), risks_or_open_questions: z.array(text(12_000)).max(100), human_markdown_summary: text(20_000), }); export type ConflictProposalDraft = z.infer; export interface ConflictProposalBasis { pr_head_sha: string; /** Added by Stage 07; absent legacy v1 proposals remain readable but cannot be approved. */ pr_head_ref?: string; pr_head_repo?: string; current_base_tip_sha: string; base_ref: string; workspace_evidence_sha256: string; command_snapshot_id: string; command_snapshot_sha256: string; } export interface ConflictProposal { schema_version: typeof CONFLICT_PROPOSAL_SCHEMA_VERSION; proposal_id: string; proposal_revision: number; execution_id: string; /** The approval/repair execution identity; discussion executions never replace it. */ repair_execution_id: string; /** The run directory containing the original repair snapshot/evidence. */ repair_run_id: string; run_id: string; summary: string; pr_intent: string; current_base_intent: string; conflicts: ConflictProposalDraft['conflicts']; affected_files: string[]; verification_plan: string[]; risks_or_open_questions: string[]; human_markdown_summary: string; basis: ConflictProposalBasis; /** A read-only conversation execution/snapshot that produced a revision, when applicable. */ discussion_execution_id?: string; discussion_snapshot_id?: string; discussion_snapshot_sha256?: string; proposal_hash: string; /** Status is kept in a sidecar/current pointer so the version payload remains immutable. */ status: ConflictProposalStatus; created_at: string; publication?: ConflictProposalPublication; } export type ConflictProposalStatus = 'draft' | 'publication_pending' | 'published' | 'superseded' | 'stale'; export interface ConflictProposalPublication { delivery_id: string; remote_id?: number; remote_url?: string; published_at?: string; } export interface ConflictProposalPointer { schema_version: typeof CONFLICT_PROPOSAL_CURRENT_SCHEMA_VERSION; proposal_id: string; proposal_revision: number; proposal_hash: string; status: ConflictProposalStatus; proposal_path: string; run_id: string; execution_id: string; workspace_path: string; pr_head_sha: string; pr_head_ref?: string; pr_head_repo?: string; current_base_tip_sha: string; base_ref: string; workspace_evidence_sha256: string; command_snapshot_id: string; command_snapshot_sha256: string; publication_delivery_id?: string; publication_remote_id?: number; publication_remote_url?: string; published_at?: string; updated_at: string; } export interface ConflictProposalInput { draft: unknown; proposalId?: string; proposalRevision: number; executionId: string; runId: string; repairRunId?: string; basis: ConflictProposalBasis; discussion?: { discussion_execution_id: string; discussion_snapshot_id: string; discussion_snapshot_sha256: string; }; createdAt?: string; } export interface ConflictProposalWorkspaceState { proposal: ConflictProposal; pointer: ConflictProposalPointer; } const statusValues = new Set(['draft', 'publication_pending', 'published', 'superseded', 'stale']); function assertRevision(revision: number) { if (!Number.isSafeInteger(revision) || revision < 1) throw new Error('Conflict proposal revision must be a positive integer'); } function assertSha(value: string, label: string) { if (!/^[a-f0-9]{7,64}$/i.test(value)) throw new Error(`Conflict proposal ${label} must be a Git object/hash`); } function normalizeDraft(input: unknown): ConflictProposalDraft { const parsed = conflictProposalDraftSchema.safeParse(input); if (!parsed.success) throw new Error(`Invalid conflict proposal: ${parsed.error.issues.map(issue => issue.path.join('.') + ' ' + issue.message).join('; ')}`); const draft = parsed.data; const affected = new Set(); for (const file of draft.affected_files) affected.add(validateRepoRelativePath(file)); const conflicts = draft.conflicts.map(conflict => ({ ...conflict, path: validateRepoRelativePath(conflict.path) })); const conflictPaths = new Set(conflicts.map(conflict => conflict.path)); for (const path of conflictPaths) { if (!affected.has(path)) throw new Error(`Conflict proposal affected_files must include conflict path: ${path}`); } return { ...draft, conflicts, affected_files: [...affected] }; } function immutablePayload(proposal: Pick) { const { proposal_hash: _hash, status: _status, created_at: _createdAt, publication: _publication, ...payload } = proposal; return payload; } export function conflictProposalHash(proposal: Pick) { return sha256Canonical(immutablePayload(proposal)); } export function createConflictProposal(input: ConflictProposalInput): ConflictProposal { assertRevision(input.proposalRevision); if (!input.executionId || !input.runId) throw new Error('Conflict proposal execution and run identity are required'); if (!input.basis.pr_head_ref || !input.basis.pr_head_repo) throw new Error('Conflict proposal source ref and repository are required'); assertSha(input.basis.pr_head_sha, 'pr_head_sha'); assertSha(input.basis.current_base_tip_sha, 'current_base_tip_sha'); assertSha(input.basis.workspace_evidence_sha256, 'workspace_evidence_sha256'); assertSha(input.basis.command_snapshot_sha256, 'command_snapshot_sha256'); const draft = normalizeDraft(input.draft); const proposal = { schema_version: CONFLICT_PROPOSAL_SCHEMA_VERSION, proposal_id: input.proposalId ?? `proposal-${randomUUID()}`, proposal_revision: input.proposalRevision, execution_id: input.executionId, repair_execution_id: input.executionId, repair_run_id: input.repairRunId ?? input.runId, run_id: input.runId, ...draft, basis: input.basis, ...(input.discussion ?? {}), proposal_hash: '', status: 'draft' as const, created_at: input.createdAt ?? new Date().toISOString(), } satisfies ConflictProposal; proposal.proposal_hash = conflictProposalHash(proposal); return proposal; } export function validateConflictProposal(input: unknown): ConflictProposal { if (!input || typeof input !== 'object') throw new Error('Conflict proposal is not an object'); const value = input as Partial; if (value.schema_version !== CONFLICT_PROPOSAL_SCHEMA_VERSION) throw new Error('Unsupported conflict proposal schema'); assertRevision(value.proposal_revision as number); if (typeof value.proposal_id !== 'string' || !value.proposal_id || typeof value.execution_id !== 'string' || !value.execution_id || typeof value.repair_execution_id !== 'string' || value.repair_execution_id !== value.execution_id || typeof value.run_id !== 'string' || !value.run_id || typeof value.proposal_hash !== 'string') throw new Error('Conflict proposal identity is incomplete'); const repairRunId = typeof value.repair_run_id === 'string' && value.repair_run_id ? value.repair_run_id : value.run_id; const draft = normalizeDraft(value); const basis = value.basis as ConflictProposalBasis | undefined; if (!basis?.pr_head_sha || !basis.current_base_tip_sha || !basis.base_ref || !basis.workspace_evidence_sha256 || !basis.command_snapshot_id || !basis.command_snapshot_sha256) throw new Error('Conflict proposal evidence basis is incomplete'); if ((basis.pr_head_ref === undefined) !== (basis.pr_head_repo === undefined)) throw new Error('Conflict proposal source binding is incomplete'); assertSha(basis.pr_head_sha, 'pr_head_sha'); assertSha(basis.current_base_tip_sha, 'current_base_tip_sha'); assertSha(basis.workspace_evidence_sha256, 'workspace_evidence_sha256'); assertSha(basis.command_snapshot_sha256, 'command_snapshot_sha256'); const discussion = [value.discussion_execution_id, value.discussion_snapshot_id, value.discussion_snapshot_sha256]; if (discussion.some(item => item !== undefined)) { if (discussion.some(item => typeof item !== 'string' || !item)) throw new Error('Conflict proposal discussion snapshot is incomplete'); assertSha(value.discussion_snapshot_sha256 as string, 'discussion_snapshot_sha256'); } if (!statusValues.has(value.status as ConflictProposalStatus)) throw new Error('Conflict proposal status is invalid'); if (typeof value.created_at !== 'string' || !value.created_at) throw new Error('Conflict proposal creation time is missing'); const proposal = { ...value, ...draft, basis, repair_run_id: repairRunId, status: value.status as ConflictProposalStatus } as ConflictProposal; const hashMatches = conflictProposalHash(proposal) === proposal.proposal_hash || (value.repair_run_id === undefined && conflictProposalHash({ ...proposal, repair_run_id: undefined } as unknown as ConflictProposal) === proposal.proposal_hash); if (!hashMatches) throw new Error('Conflict proposal hash does not match its immutable payload'); return proposal; } export function renderConflictProposal(proposal: ConflictProposal) { const conflicts = proposal.conflicts.map((conflict, index) => [ `### 冲突 ${index + 1}:\`${conflict.path}\``, `- 问题:${conflict.issue}`, `- PR 侧:${conflict.pr_side}`, `- 当前 base 侧:${conflict.base_side}`, `- 拟议解决:${conflict.proposed_resolution}`, `- 分歧/取舍:${conflict.disagreement_or_tradeoff}`, ].join('\n')).join('\n\n'); const list = (values: string[]) => values.map(value => `- ${value}`).join('\n'); return [ `## PatchPaw Conflict Proposal v${proposal.proposal_revision}`, `提案 hash:\`${proposal.proposal_hash}\``, '', `### PR 意图\n${proposal.pr_intent}`, `### 当前 base 意图\n${proposal.current_base_intent}`, `### 总结\n${proposal.summary}`, `### 冲突与拟议方向\n${conflicts}`, `### 影响文件\n${list(proposal.affected_files)}`, `### 验证计划\n${list(proposal.verification_plan)}`, `### 风险与待决问题\n${proposal.risks_or_open_questions.length ? list(proposal.risks_or_open_questions) : '- 暂无已知风险或待决问题。'}`, `### Agent 面向人类的摘要\n${proposal.human_markdown_summary}`, '', `依据:PR head \`${proposal.basis.pr_head_sha}\`,current base \`${proposal.basis.current_base_tip_sha}\`,base ref \`${proposal.basis.base_ref}\`。`, '以上内容由 Harness 从已验证的结构化提案生成;当前只读阶段不会修改、commit 或 push。', '如确认该版本的方向,请在此 PR 新增一条单独评论:`/approval`。普通讨论不会授权修复。', ].join('\n\n'); } export function conflictProposalDirectory(statePath: string) { return `${statePath}.conflict-proposals`; } export function conflictProposalVersionPath(statePath: string, revision: number) { assertRevision(revision); return join(conflictProposalDirectory(statePath), `conflict-proposal-v${revision}.json`); } function conflictProposalStatusPath(statePath: string, revision: number) { return join(conflictProposalDirectory(statePath), `conflict-proposal-v${revision}.status.json`); } export function conflictProposalCurrentPath(statePath: string) { return join(conflictProposalDirectory(statePath), 'conflict-proposal-current.json'); } function pointerFromProposal(statePath: string, proposal: ConflictProposal, workspacePath: string, updatedAt = new Date().toISOString()): ConflictProposalPointer { return { schema_version: CONFLICT_PROPOSAL_CURRENT_SCHEMA_VERSION, proposal_id: proposal.proposal_id, proposal_revision: proposal.proposal_revision, proposal_hash: proposal.proposal_hash, status: proposal.status, proposal_path: conflictProposalVersionPath(statePath, proposal.proposal_revision), run_id: proposal.run_id, execution_id: proposal.execution_id, workspace_path: workspacePath, pr_head_sha: proposal.basis.pr_head_sha, pr_head_ref: proposal.basis.pr_head_ref, pr_head_repo: proposal.basis.pr_head_repo, current_base_tip_sha: proposal.basis.current_base_tip_sha, base_ref: proposal.basis.base_ref, workspace_evidence_sha256: proposal.basis.workspace_evidence_sha256, command_snapshot_id: proposal.basis.command_snapshot_id, command_snapshot_sha256: proposal.basis.command_snapshot_sha256, ...(proposal.publication ? { publication_delivery_id: proposal.publication.delivery_id, publication_remote_id: proposal.publication.remote_id, publication_remote_url: proposal.publication.remote_url, published_at: proposal.publication.published_at, } : {}), updated_at: updatedAt, }; } export async function readConflictProposal(statePath: string, revision: number): Promise { const raw = await readJson(conflictProposalVersionPath(statePath, revision)); if (!raw) return null; const proposal = validateConflictProposal(raw); const status = await readJson<{ status: ConflictProposalStatus; publication?: ConflictProposalPublication }>(conflictProposalStatusPath(statePath, revision)); return { ...proposal, ...(status ?? {}) }; } export async function readCurrentConflictProposal(statePath: string): Promise { const pointer = await readJson(conflictProposalCurrentPath(statePath)); if (!pointer) return null; if (pointer.schema_version !== CONFLICT_PROPOSAL_CURRENT_SCHEMA_VERSION) throw new Error('Conflict proposal current pointer schema is unsupported'); const proposal = await readConflictProposal(statePath, pointer.proposal_revision); if (!proposal || proposal.proposal_id !== pointer.proposal_id || proposal.proposal_hash !== pointer.proposal_hash) { throw new Error('Conflict proposal current pointer does not resolve to an immutable version'); } return { proposal, pointer }; } export async function saveConflictProposal(statePath: string, proposal: ConflictProposal, workspacePath: string) { const normalized = validateConflictProposal(proposal); const versionPath = conflictProposalVersionPath(statePath, normalized.proposal_revision); const existing = await readJson(versionPath); if (existing) { const valid = validateConflictProposal(existing); if (valid.proposal_hash !== normalized.proposal_hash || valid.proposal_id !== normalized.proposal_id) { throw new Error(`Conflict proposal revision v${normalized.proposal_revision} is immutable and already contains different content`); } } else { await immutableJson(versionPath, { ...normalized, status: 'draft', publication: undefined }); } await atomicJson(conflictProposalStatusPath(statePath, normalized.proposal_revision), { status: normalized.status }); const current = await readCurrentConflictProposal(statePath); if (!current || current.proposal.proposal_revision <= normalized.proposal_revision) { await atomicJson(conflictProposalCurrentPath(statePath), pointerFromProposal(statePath, normalized, workspacePath)); } return readCurrentConflictProposal(statePath); } export async function markConflictProposalStatus(statePath: string, revision: number, status: ConflictProposalStatus, workspacePath: string, publication?: ConflictProposalPublication) { const proposal = await readConflictProposal(statePath, revision); if (!proposal) throw new Error(`Conflict proposal v${revision} is missing`); await atomicJson(conflictProposalStatusPath(statePath, revision), { status, ...(publication ? { publication } : {}) }); const current = await readCurrentConflictProposal(statePath); if (current?.proposal.proposal_revision === revision) { const next = { ...proposal, status, ...(publication ? { publication } : {}) }; await atomicJson(conflictProposalCurrentPath(statePath), pointerFromProposal(statePath, next, workspacePath)); } return readCurrentConflictProposal(statePath); } export function proposalPointerForState(proposal: ConflictProposal, workspacePath: string): NonNullable { return { proposal_id: proposal.proposal_id, proposal_revision: proposal.proposal_revision, proposal_hash: proposal.proposal_hash, status: proposal.status, run_id: proposal.run_id, execution_id: proposal.execution_id, workspace_path: workspacePath, pr_head_sha: proposal.basis.pr_head_sha, pr_head_ref: proposal.basis.pr_head_ref, pr_head_repo: proposal.basis.pr_head_repo, current_base_tip_sha: proposal.basis.current_base_tip_sha, base_ref: proposal.basis.base_ref, workspace_evidence_sha256: proposal.basis.workspace_evidence_sha256, command_snapshot_id: proposal.basis.command_snapshot_id, command_snapshot_sha256: proposal.basis.command_snapshot_sha256, }; } export async function saveProposalState(path: string, proposal: ConflictProposal, workspacePath: string, patch: Partial = {}) { const current = await readState(path); if (current && current.conflict_proposal && current.conflict_proposal.proposal_revision > proposal.proposal_revision) return current; const pointer = proposalPointerForState(proposal, workspacePath); const next: RunState = applyRunPhase({ ...(current ?? {} as RunState), ...patch, conflict_proposal: pointer, active: patch.active ?? current?.active ?? false }, patch.phase ?? current?.phase ?? proposal.status); await writeState(path, next); return next; } export async function updateProposalState(path: string, proposal: ConflictProposal, workspacePath: string, patch: Partial = {}) { const current = await readState(path); if (current?.conflict_proposal && current.conflict_proposal.proposal_hash !== proposal.proposal_hash && current.conflict_proposal.proposal_revision > proposal.proposal_revision) return current; return saveProposalState(path, proposal, workspacePath, patch); } export async function markCurrentConflictProposalStale(path: string, workspacePath: string, reason: string) { const current = await readCurrentConflictProposal(path); if (!current) return null; await markConflictProposalStatus(path, current.proposal.proposal_revision, 'stale', workspacePath); const state = await readState(path); if (state?.conflict_proposal?.proposal_hash === current.proposal.proposal_hash) { await writeState(path, applyRunPhase({ ...state, active: false, conflict_proposal: { ...state.conflict_proposal, status: 'stale' } }, 'stale')); } return { ...current, reason }; } export function proposalDeliverySemanticKey(proposal: ConflictProposal) { return `conflict-proposal:${proposal.proposal_id}:v${proposal.proposal_revision}:${proposal.proposal_hash}`; } export async function cancelConflictProposalPublication(root: string, repo: string, prNumber: number, proposal: ConflictProposal) { const delivery = (await listOutbound(root, { repo, prNumber })).find(item => item.item.semantic_key === proposalDeliverySemanticKey(proposal)); if (!delivery) return undefined; return cancelOutboundDelivery(root, delivery, 'conflict_proposal_superseded'); } export function proposalPublicationFromOutbound(item: OutboundItem): ConflictProposalPublication { return { delivery_id: item.delivery_id, remote_id: item.receipt?.id, remote_url: item.receipt?.html_url, published_at: item.receipt?.published_at ?? new Date().toISOString(), }; } /** * Reconcile the proposal projection from the durable communication receipt. This is safe to * call from a worker retry or the communication scheduler: it never calls a model or grants * write access, and it refuses to move a superseded/stale version back to awaiting approval. */ export async function reconcileConflictProposalPublication(root: string, statePath: string, repo: string, prNumber: number, finalize = true) { const current = await readCurrentConflictProposal(statePath); if (!current || !['draft', 'publication_pending', 'published'].includes(current.proposal.status)) return current; // /close removes the proposal directory and retires the local generation. If a delayed // scheduler sees an old outbox row during that boundary, it may finalize delivery but must // not recreate a closed run's state, trace, or retained workspace. const stateBefore = await readState(statePath); if (stateBefore?.phase === 'closed') return current; const delivery = (await listOutbound(root, { repo, prNumber })).find(item => item.item.semantic_key === proposalDeliverySemanticKey(current.proposal)); if (!delivery || delivery.item.status !== 'delivered' || !delivery.item.receipt) return current; const publication = proposalPublicationFromOutbound(delivery.item); const updated = current.proposal.status === 'published' ? current : await markConflictProposalStatus(statePath, current.proposal.proposal_revision, 'published', current.pointer.workspace_path, publication); const state = await readState(statePath); if (updated && state && state.phase !== 'closed' && (state.conflict_proposal?.proposal_hash !== current.proposal.proposal_hash || state.conflict_proposal?.status !== 'published' || state.phase !== 'awaiting_approval')) { const next: RunState = applyRunPhase({ ...state, active: false, conflict_proposal: { ...proposalPointerForState(updated.proposal, current.pointer.workspace_path), status: 'published', publication_delivery_id: publication.delivery_id, publication_remote_id: publication.remote_id, publication_remote_url: publication.remote_url, published_at: publication.published_at } }, 'awaiting_approval'); await writeState(statePath, next); const paused = await readPaused(statePath); if (paused?.workspace.path === current.pointer.workspace_path) await savePaused(statePath, { ...paused, status: 'awaiting_approval' }); const dir = join(patchpawPaths(root).runs, current.proposal.run_id); const trace = new Trace(dir); trace.save('conflict-proposal-publication.json', { ...publication, proposal_revision: current.proposal.proposal_revision, proposal_hash: current.proposal.proposal_hash, status: 'published' }); trace.emit('conflict_proposal_published', publication); const resultPath = join(dir, 'result.json'); const existing = await readJson>(resultPath); if (existing && ['publication_pending', 'draft'].includes(String(existing.status))) { trace.save('result.json', { ...existing, status: 'awaiting_approval', proposal_revision: current.proposal.proposal_revision, proposal_hash: current.proposal.proposal_hash, publication }); } } // Finalization is idempotent and is intentionally performed even when the projection was // already repaired by an earlier worker pass. if (finalize) await finalizeDelivery(root, delivery); return updated; } export async function reconcilePendingConflictProposal(root: string, statePath: string, repo: string, prNumber: number) { return reconcileConflictProposalPublication(root, statePath, repo, prNumber); }