From a8891c36293e0919905a7614af0da8567c617b74 Mon Sep 17 00:00:00 2001 From: Dipesh Tharu Mahato Date: Fri, 13 Feb 2026 00:36:17 +0000 Subject: [PATCH 1/3] fix: auto-reject questions and boss-supervised merge resolver loop --- src/cli/agents/events.ts | 7 ++ src/cli/agents/ralph-serve-entry.ts | 4 +- src/cli/agents/resolver/index.ts | 134 ++++++++++++++++++++++++---- src/cli/agents/session.ts | 25 ++++++ src/cli/handlers/run.ts | 6 +- src/lib/prompts/resolverBoss.ts | 55 ++++++++++++ 6 files changed, 214 insertions(+), 17 deletions(-) create mode 100644 src/lib/prompts/resolverBoss.ts 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..a916d0d 100644 --- a/src/cli/agents/ralph-serve-entry.ts +++ b/src/cli/agents/ralph-serve-entry.ts @@ -131,10 +131,12 @@ export async function runRalphDaemon(argv: string[]) { }); await resolveConflicts({ - client: workerHandle.client, + workerClient: workerHandle.client, + bossClient: bossHandle.client, worktreePath, conflicts: merge.conflicts ?? "", model: agentModel, + evalModel, bus: publisher, }); diff --git a/src/cli/agents/resolver/index.ts b/src/cli/agents/resolver/index.ts index 5d385c0..1b03c5e 100644 --- a/src/cli/agents/resolver/index.ts +++ b/src/cli/agents/resolver/index.ts @@ -1,41 +1,145 @@ 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, + }); + + // If boss says DONE but deterministic checks disagree, force actionable retry. + if (boss.output.includes("VERDICT: DONE")) { + feedback = + `Deterministic checks still failing.\n` + + `mergeInProgress=${stateAfter.mergeInProgress}\n` + + `dirty=${Boolean(stateAfter.statusPorcelain)}\n` + + `markers=${Boolean(stateAfter.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>>>>>>) + 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 From 31d0f704f99d66343d40715c66ac9549663966b4 Mon Sep 17 00:00:00 2001 From: Dipesh Tharu Mahato Date: Sat, 14 Feb 2026 21:34:29 +0000 Subject: [PATCH 2/3] fix: stop extra merge cycles by honoring resolver result --- src/cli/agents/ralph-serve-entry.ts | 22 +++++++++++++++++----- src/cli/agents/resolver/index.ts | 23 ++++++++++++++++++----- src/cli/handlers/run.ts | 19 ++++++++++++++----- 3 files changed, 49 insertions(+), 15 deletions(-) diff --git a/src/cli/agents/ralph-serve-entry.ts b/src/cli/agents/ralph-serve-entry.ts index a916d0d..1584a16 100644 --- a/src/cli/agents/ralph-serve-entry.ts +++ b/src/cli/agents/ralph-serve-entry.ts @@ -129,8 +129,8 @@ export async function runRalphDaemon(argv: string[]) { branchName, conflicts: merge.conflicts ?? "", }); - - await resolveConflicts({ + + const rr = await resolveConflicts({ workerClient: workerHandle.client, bossClient: bossHandle.client, worktreePath, @@ -139,12 +139,24 @@ export async function runRalphDaemon(argv: string[]) { evalModel, bus: publisher, }); - - // Abort any leftover merge state before retrying + + // If resolver finished cleanly, don't abort and re-merge. + // That would burn a cycle and can discard resolved state. + // if (rr.done) { + // merge = { clean: true }; + // break; + // } + if (rr.done) { + // refresh merge state for correctness + merge = await mergeMainIntoWorktree(worktreePath); // or call a cheap "check" helper if have one + if (merge.clean) 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 1b03c5e..e0b1f0a 100644 --- a/src/cli/agents/resolver/index.ts +++ b/src/cli/agents/resolver/index.ts @@ -127,13 +127,26 @@ export async function resolve(options: ResolverOptions): Promise timeoutMs: 10 * 60 * 1000, }); - // If boss says DONE but deterministic checks disagree, force actionable retry. - if (boss.output.includes("VERDICT: DONE")) { + 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=${stateAfter.mergeInProgress}\n` + - `dirty=${Boolean(stateAfter.statusPorcelain)}\n` + - `markers=${Boolean(stateAfter.conflictMarkers)}\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; diff --git a/src/cli/handlers/run.ts b/src/cli/handlers/run.ts index 06eefb7..1b9969a 100644 --- a/src/cli/handlers/run.ts +++ b/src/cli/handlers/run.ts @@ -440,8 +440,8 @@ async function runWithRalph(prefix: string, workspacePath: string, options: Ralp branchName, conflicts: merge.conflicts ?? "", }); - - await resolveConflicts({ + + const rr = await resolveConflicts({ workerClient: workerHandle.client, bossClient: bossHandle.client, worktreePath, @@ -450,12 +450,21 @@ async function runWithRalph(prefix: string, workspacePath: string, options: Ralp evalModel, bus: publisher, }); - - // Abort any leftover merge state before retrying + + // if (rr.done) { + // merge = { clean: true }; + // break; + // } + if (rr.done) { + // refresh merge state for correctness + merge = await mergeMainIntoWorktree(worktreePath); // or call a cheap "check" helper if have one + if (merge.clean) break; + } + await abortMerge(worktreePath); merge = await mergeMainIntoWorktree(worktreePath); retries++; - } + } if (merge.clean) { await withMergeLock(async () => { From e16c77b0a468ad4638935e57b8bfee68c84b448c Mon Sep 17 00:00:00 2001 From: Dipesh Tharu Mahato Date: Sat, 14 Feb 2026 21:50:56 +0000 Subject: [PATCH 3/3] =?UTF-8?q?remove=20the=20merge=20=3D=20await=20mergeM?= =?UTF-8?q?ainIntoWorktree(worktreePath)=20=E2=80=9Crefresh=E2=80=9D=20ins?= =?UTF-8?q?ide=20the=20if=20(rr.done)=20block.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/cli/agents/ralph-serve-entry.ts | 13 ++++--------- src/cli/handlers/run.ts | 13 +++++-------- 2 files changed, 9 insertions(+), 17 deletions(-) diff --git a/src/cli/agents/ralph-serve-entry.ts b/src/cli/agents/ralph-serve-entry.ts index 1584a16..9a36b10 100644 --- a/src/cli/agents/ralph-serve-entry.ts +++ b/src/cli/agents/ralph-serve-entry.ts @@ -140,16 +140,11 @@ export async function runRalphDaemon(argv: string[]) { bus: publisher, }); - // If resolver finished cleanly, don't abort and re-merge. - // That would burn a cycle and can discard resolved state. - // if (rr.done) { - // merge = { clean: true }; - // break; - // } + // 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) { - // refresh merge state for correctness - merge = await mergeMainIntoWorktree(worktreePath); // or call a cheap "check" helper if have one - if (merge.clean) break; + merge.clean = true; + break; } // If still not resolved, then reset merge state and retry from a clean merge attempt. diff --git a/src/cli/handlers/run.ts b/src/cli/handlers/run.ts index 1b9969a..8910951 100644 --- a/src/cli/handlers/run.ts +++ b/src/cli/handlers/run.ts @@ -451,15 +451,12 @@ async function runWithRalph(prefix: string, workspacePath: string, options: Ralp bus: publisher, }); - // if (rr.done) { - // merge = { clean: true }; - // break; - // } + // 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) { - // refresh merge state for correctness - merge = await mergeMainIntoWorktree(worktreePath); // or call a cheap "check" helper if have one - if (merge.clean) break; - } + merge.clean = true; + break; + } await abortMerge(worktreePath); merge = await mergeMainIntoWorktree(worktreePath);