diff --git a/src/cli/agents/events.ts b/src/cli/agents/events.ts index 1a0daf7..e29ba93 100644 --- a/src/cli/agents/events.ts +++ b/src/cli/agents/events.ts @@ -105,6 +105,12 @@ export interface SessionPermissionEvent extends BaseEvent { description: string; } +export interface SessionQuestionEvent extends BaseEvent { + type: "session.question"; + phase: "executor" | "validator" | "resolver"; + question: string; +} + export interface RalphInterruptedEvent extends BaseEvent { type: "ralph.interrupted"; reason: "user_quit" | "user_stop"; @@ -159,6 +165,7 @@ export type RalphEvent = | SessionTextDeltaEvent | SessionToolStatusEvent | SessionPermissionEvent + | SessionQuestionEvent | ResolverStartEvent | ResolverCompleteEvent | WorktreeCreatedEvent diff --git a/src/cli/agents/ralph-serve-entry.ts b/src/cli/agents/ralph-serve-entry.ts index 9ae2ae3..9a36b10 100644 --- a/src/cli/agents/ralph-serve-entry.ts +++ b/src/cli/agents/ralph-serve-entry.ts @@ -129,20 +129,29 @@ export async function runRalphDaemon(argv: string[]) { branchName, conflicts: merge.conflicts ?? "", }); - - await resolveConflicts({ - client: workerHandle.client, + + const rr = await resolveConflicts({ + workerClient: workerHandle.client, + bossClient: bossHandle.client, worktreePath, conflicts: merge.conflicts ?? "", model: agentModel, + evalModel, bus: publisher, }); - - // Abort any leftover merge state before retrying + + // rr.done already implies deterministic git checks passed inside the resolver. + // Do not re-run mergeMainIntoWorktree here (that would re-attempt the merge). + if (rr.done) { + merge.clean = true; + break; + } + + // If still not resolved, then reset merge state and retry from a clean merge attempt. await abortMerge(worktreePath); merge = await mergeMainIntoWorktree(worktreePath); retries++; - } + } if (merge.clean) { await withMergeLock(async () => { diff --git a/src/cli/agents/resolver/index.ts b/src/cli/agents/resolver/index.ts index 5d385c0..e0b1f0a 100644 --- a/src/cli/agents/resolver/index.ts +++ b/src/cli/agents/resolver/index.ts @@ -1,41 +1,158 @@ import type { createOpencodeClient } from "@opencode-ai/sdk/v2"; import { runSession } from "../session"; import { resolverPrompt } from "../../../lib/prompts/resolver"; +import { resolverBossPrompt } from "../../../lib/prompts/resolverBoss"; import type { EventPublisher } from "../bus"; export interface ResolverOptions { - client: ReturnType; + workerClient: ReturnType; + bossClient: ReturnType; worktreePath: string; conflicts: string; model: string; + evalModel: string; + maxIterations?: number; // default 4 bus?: EventPublisher; } export interface ResolverResult { output: string; + done: boolean; } -export async function resolve(options: ResolverOptions): Promise { - const { client, conflicts, model, bus } = options; +async function execGit( + args: string[], + cwd: string, +): Promise<{ code: number; stdout: string; stderr: string }> { + const proc = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", stderr: "pipe" }); + const code = await proc.exited; + const stdout = (await new Response(proc.stdout).text()).trim(); + const stderr = (await new Response(proc.stderr).text()).trim(); + return { code, stdout, stderr }; +} - if (bus) { - bus.publish({ type: "resolver.start", timestamp: Date.now(), conflicts }); - } +async function getMergeState(worktreePath: string): Promise<{ + mergeInProgress: boolean; + statusPorcelain: string; + conflictMarkers: string; + conflictedFiles: string; +}> { + const mergeHead = await execGit(["rev-parse", "-q", "--verify", "MERGE_HEAD"], worktreePath); + const mergeInProgress = mergeHead.code === 0; + + const status = await execGit(["status", "--porcelain"], worktreePath); + + // 0 = found markers, 1 = none, 2 = error + const markers = await execGit(["grep", "-n", "-E", "^(<<<<<<<|=======|>>>>>>>)", "--", "."], worktreePath); + const conflictMarkers = markers.code === 0 ? markers.stdout : ""; - const prompt = resolverPrompt(conflicts); + const conflicts = await execGit(["diff", "--name-only", "--diff-filter=U"], worktreePath); - const { output } = await runSession({ - client, - prompt, - title: "merge-resolver", + return { + mergeInProgress, + statusPorcelain: status.stdout, + conflictMarkers, + conflictedFiles: conflicts.stdout.trim(), + }; +} + +export async function resolve(options: ResolverOptions): Promise { + const { + workerClient, + bossClient, + worktreePath, + conflicts: originalConflicts, model, - phase: "resolver", + evalModel, + maxIterations = 4, bus, - }); + } = options; if (bus) { - bus.publish({ type: "resolver.complete", timestamp: Date.now() }); + bus.publish({ type: "resolver.start", timestamp: Date.now(), conflicts: originalConflicts }); + } + + let feedback = ""; + let lastOutput = ""; + + for (let iter = 1; iter <= maxIterations; iter++) { + const stateBefore = await getMergeState(worktreePath); + const currentConflicts = stateBefore.conflictedFiles || originalConflicts; + + const prompt = + resolverPrompt(currentConflicts) + + (feedback ? `\n\n## Boss feedback\n${feedback}` : ""); + + const { output } = await runSession({ + client: workerClient, + prompt, + title: `merge-resolver-${iter}`, + model, + phase: "resolver", + bus, + }); + + lastOutput = output; + + const stateAfter = await getMergeState(worktreePath); + + // Deterministic checks are the source of truth. + const done = + !stateAfter.mergeInProgress && + !stateAfter.conflictMarkers && + !stateAfter.statusPorcelain; + + if (done) { + if (bus) bus.publish({ type: "resolver.complete", timestamp: Date.now() }); + return { output: lastOutput, done: true }; + } + + // Boss provides targeted instructions (non-interactive). + const bossPrompt = resolverBossPrompt({ + originalConflicts, + currentConflicts: stateAfter.conflictedFiles, + mergeInProgress: stateAfter.mergeInProgress, + statusPorcelain: stateAfter.statusPorcelain, + conflictMarkers: stateAfter.conflictMarkers, + resolverOutput: lastOutput.slice(-8000), + }); + + const boss = await runSession({ + client: bossClient, + prompt: bossPrompt, + title: `merge-resolver-boss-${iter}`, + model: evalModel, + phase: "validator", + bus, + timeoutMs: 10 * 60 * 1000, + }); + + const bossSaysDone = boss.output.includes("VERDICT: DONE"); + + if (bossSaysDone) { + // Boss can't change state, but re-checking here makes the logic explicit and robust. + const stateNow = await getMergeState(worktreePath); + const doneNow = + !stateNow.mergeInProgress && + !stateNow.conflictMarkers && + !stateNow.statusPorcelain; + + if (doneNow) { + if (bus) bus.publish({ type: "resolver.complete", timestamp: Date.now() }); + return { output: lastOutput, done: true }; + } + + feedback = + `Deterministic checks still failing.\n` + + `mergeInProgress=${stateNow.mergeInProgress}\n` + + `dirty=${Boolean(stateNow.statusPorcelain)}\n` + + `markers=${Boolean(stateNow.conflictMarkers)}\n` + + `Fix remaining conflicts, git add, and git commit.`; + } else { + feedback = boss.output; + } } - return { output }; + if (bus) bus.publish({ type: "resolver.complete", timestamp: Date.now() }); + return { output: lastOutput, done: false }; } diff --git a/src/cli/agents/session.ts b/src/cli/agents/session.ts index 69d8e26..4845e64 100644 --- a/src/cli/agents/session.ts +++ b/src/cli/agents/session.ts @@ -83,6 +83,31 @@ export async function runSession(options: SessionOptions): Promise { diff --git a/src/lib/prompts/resolverBoss.ts b/src/lib/prompts/resolverBoss.ts new file mode 100644 index 0000000..197d6ad --- /dev/null +++ b/src/lib/prompts/resolverBoss.ts @@ -0,0 +1,55 @@ +export function resolverBossPrompt(args: { + originalConflicts: string; + currentConflicts: string; + mergeInProgress: boolean; + statusPorcelain: string; + conflictMarkers: string; + resolverOutput: string; + }): string { + const { + originalConflicts, + currentConflicts, + mergeInProgress, + statusPorcelain, + conflictMarkers, + resolverOutput, + } = args; + + return `You are the Merge Resolver Boss. + + Your job is to decide whether the merge conflict resolution is COMPLETE. + + Hard requirements (must all be true): + 1) No merge in progress (MERGE_HEAD absent) + 2) No conflict markers remain (<<<<<<<, =======, >>>>>>>) + 3) Working tree is clean (git status --porcelain is empty) + + If any requirement fails: + - respond with VERDICT: NOT DONE + - provide specific actionable instructions + - NEVER ask questions + + Inputs: + Original conflicted files: + ${originalConflicts || "(none)"} + + Current conflicted files: + ${currentConflicts || "(none)"} + + Deterministic checks: + mergeInProgress: ${mergeInProgress} + git status --porcelain: + ${statusPorcelain || "(clean)"} + + conflict markers grep: + ${conflictMarkers || "(none)"} + + Resolver output: + ${resolverOutput} + + Return exactly one of: + VERDICT: DONE + VERDICT: NOT DONE + `; + } + \ No newline at end of file