Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/cli/agents/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -159,6 +165,7 @@ export type RalphEvent =
| SessionTextDeltaEvent
| SessionToolStatusEvent
| SessionPermissionEvent
| SessionQuestionEvent
| ResolverStartEvent
| ResolverCompleteEvent
| WorktreeCreatedEvent
Expand Down
21 changes: 15 additions & 6 deletions src/cli/agents/ralph-serve-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
147 changes: 132 additions & 15 deletions src/cli/agents/resolver/index.ts
Original file line number Diff line number Diff line change
@@ -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<typeof createOpencodeClient>;
workerClient: ReturnType<typeof createOpencodeClient>;
bossClient: ReturnType<typeof createOpencodeClient>;
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<ResolverResult> {
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<ResolverResult> {
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 };
}
25 changes: 25 additions & 0 deletions src/cli/agents/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,31 @@ export async function runSession(options: SessionOptions): Promise<SessionResult
}
}

// Auto-reject questions for our session (Issue #50).
// Daemon/server runs have no UI, so a question would hang until timeout.
if (event.type === "question.asked") {
const request = event.properties as any;
if (request.sessionID === sessionId) {
const q =
(request.question as string) ||
(request.prompt as string) ||
(request.text as string) ||
"(question)";

if (bus && phase) {
bus.publish({
type: "session.question",
timestamp: Date.now(),
phase,
question: q,
});
}

append(`[Auto-rejecting question: ${q}]\n`);
await client.question.reject({ requestID: request.id });
}
}

if (event.type === "message.part.updated") {
const { part, delta } = event.properties;
if (part.sessionID !== sessionId) continue;
Expand Down
22 changes: 16 additions & 6 deletions src/cli/handlers/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,8 @@ function formatEventForCli(event: RalphEvent): string | null {
}
case "session.permission":
return `[Auto-approving ${event.permission}${event.description ? `: ${event.description}` : ""}]`;
case "session.question":
return `[Auto-rejecting question: ${event.question}]`;
case "resolver.start":
return `\n[ralph] ── Resolver resolving conflicts ──\n${event.conflicts}`;
case "resolver.complete":
Expand Down Expand Up @@ -438,20 +440,28 @@ async function runWithRalph(prefix: string, workspacePath: string, options: Ralp
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;
}

await abortMerge(worktreePath);
merge = await mergeMainIntoWorktree(worktreePath);
retries++;
}
}

if (merge.clean) {
await withMergeLock(async () => {
Expand Down
55 changes: 55 additions & 0 deletions src/lib/prompts/resolverBoss.ts
Original file line number Diff line number Diff line change
@@ -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
<instructions>`;
}