From 8e1719968b122f569b83479c44392abec55beff8 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 6 May 2026 16:08:13 +0900 Subject: [PATCH 1/4] fix(ralph-loop): retry runtime errors immediately Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../non-abort-error-continuation.test.ts | 73 ++++++++- .../ralph-loop/ralph-loop-event-handler.ts | 143 ++++++++++++++++-- 2 files changed, 197 insertions(+), 19 deletions(-) diff --git a/src/hooks/ralph-loop/non-abort-error-continuation.test.ts b/src/hooks/ralph-loop/non-abort-error-continuation.test.ts index 470aa8326..a61207cbb 100644 --- a/src/hooks/ralph-loop/non-abort-error-continuation.test.ts +++ b/src/hooks/ralph-loop/non-abort-error-continuation.test.ts @@ -25,7 +25,7 @@ describe("ralph-loop non-abort error continuation", () => { } }) - test("continues on next idle after non-abort session error", async () => { + test("continues immediately after non-abort session error", async () => { // given - an active Ralph Loop receives a recoverable command error const hook = createRalphLoopHook({ directory: testDirectory, @@ -81,16 +81,75 @@ describe("ralph-loop non-abort error continuation", () => { }, }) - // when - OpenCode emits the idle event caused by that failed command - await hook.event({ - event: { type: "session.idle", properties: { sessionID: "session-123" } }, - }) - - // then - the loop should continue instead of skipping idle as recovery + // then - the loop should continue without waiting for a later idle event expect(promptCalls).toHaveLength(1) expect(promptCalls[0]?.sessionID).toBe("session-123") expect(promptCalls[0]?.text).toContain("Keep working") expect(messagesCalls.length).toBeGreaterThan(0) expect(hook.getState()?.iteration).toBe(2) }) + + test("stops retrying runtime errors after max iterations", async () => { + // given - an active Ralph Loop has one retry remaining + const hook = createRalphLoopHook({ + directory: testDirectory, + project: testDirectory, + worktree: testDirectory, + serverUrl: "http://localhost:4096", + $: async () => ({}), + client: { + session: { + messages: async (options: { path: { id: string } }) => { + messagesCalls.push({ sessionID: options.path.id }) + return { data: [] } + }, + promptAsync: async (options: { + path: { id: string } + body: { parts: Array<{ type: string; text: string }> } + }) => { + promptCalls.push({ + sessionID: options.path.id, + text: options.body.parts[0]?.text ?? "", + }) + return {} + }, + prompt: async () => ({}), + }, + tui: { + showToast: async () => ({}), + }, + }, + } as never) + + hook.startLoop("session-123", "Keep working", { + messageCountAtStart: 0, + maxIterations: 2, + }) + + // when - the first runtime error consumes the final allowed attempt + await hook.event({ + event: { + type: "session.error", + properties: { + sessionID: "session-123", + error: { name: "RuntimeError" }, + }, + }, + }) + + // when - another runtime error arrives after the retry budget is exhausted + await hook.event({ + event: { + type: "session.error", + properties: { + sessionID: "session-123", + error: { name: "RuntimeError" }, + }, + }, + }) + + // then - the loop does not exceed the configured retry count + expect(promptCalls).toHaveLength(1) + expect(hook.getState()).toBeNull() + }) }) diff --git a/src/hooks/ralph-loop/ralph-loop-event-handler.ts b/src/hooks/ralph-loop/ralph-loop-event-handler.ts index 030723c6a..fcffb9db4 100644 --- a/src/hooks/ralph-loop/ralph-loop-event-handler.ts +++ b/src/hooks/ralph-loop/ralph-loop-event-handler.ts @@ -22,11 +22,42 @@ type LoopStateController = { } type RalphLoopEventHandlerOptions = { directory: string; apiTimeoutMs: number; getTranscriptPath: (sessionID: string) => string | undefined; checkSessionExists?: RalphLoopOptions["checkSessionExists"]; backgroundManager?: RalphLoopOptions["backgroundManager"]; loopState: LoopStateController } +function isAbortError(error: unknown): boolean { + return typeof error === "object" + && error !== null + && "name" in error + && (error as { name?: unknown }).name === "MessageAbortedError" +} + +async function showMaxIterationsToast( + ctx: PluginInput, + state: RalphLoopState, +): Promise { + await ctx.client.tui?.showToast?.({ + body: { title: "Ralph Loop Stopped", message: `Max iterations (${state.max_iterations}) reached without completion`, variant: "warning", duration: 5000 }, + }).catch(() => {}) +} + +async function showIterationToast( + ctx: PluginInput, + state: RalphLoopState, +): Promise { + await ctx.client.tui?.showToast?.({ + body: { + title: "Ralph Loop", + message: `Iteration ${state.iteration}/${typeof state.max_iterations === "number" ? state.max_iterations : "unbounded"}`, + variant: "info", + duration: 2000, + }, + }).catch(() => {}) +} + export function createRalphLoopEventHandler( ctx: PluginInput, options: RalphLoopEventHandlerOptions, ) { const inFlightSessions = new Set() + const runtimeErrorRetriedSessions = new Map() return async ({ event }: { event: { type: string; properties?: unknown } }): Promise => { const props = event.properties as Record | undefined @@ -121,6 +152,7 @@ export function createRalphLoopEventHandler( }) if (completionViaTranscript || completionViaApi) { + runtimeErrorRetriedSessions.delete(sessionID) log(`[${HOOK_NAME}] Completion detected!`, { sessionID, iteration: state.iteration, @@ -160,6 +192,15 @@ export function createRalphLoopEventHandler( return } + if (runtimeErrorRetriedSessions.get(sessionID) === state.iteration) { + runtimeErrorRetriedSessions.delete(sessionID) + log(`[${HOOK_NAME}] Skipped stale idle after runtime error retry`, { + sessionID, + iteration: state.iteration, + }) + return + } + if ( typeof state.max_iterations === "number" && state.iteration >= state.max_iterations @@ -171,9 +212,7 @@ export function createRalphLoopEventHandler( }) options.loopState.clear() - await ctx.client.tui?.showToast?.({ - body: { title: "Ralph Loop Stopped", message: `Max iterations (${state.max_iterations}) reached without completion`, variant: "warning", duration: 5000 }, - }).catch(() => {}) + await showMaxIterationsToast(ctx, state) return } @@ -189,14 +228,7 @@ export function createRalphLoopEventHandler( max: newState.max_iterations, }) - await ctx.client.tui?.showToast?.({ - body: { - title: "Ralph Loop", - message: `Iteration ${newState.iteration}/${typeof newState.max_iterations === "number" ? newState.max_iterations : "unbounded"}`, - variant: "info", - duration: 2000, - }, - }).catch(() => {}) + await showIterationToast(ctx, newState) try { await continueIteration(ctx, newState, { @@ -223,7 +255,94 @@ export function createRalphLoopEventHandler( } if (event.type === "session.error") { - handleErroredLoopSession(props, options.loopState) + const sessionID = props?.sessionID as string | undefined + const error = props?.error + if (!sessionID || isAbortError(error)) { + handleErroredLoopSession(props, options.loopState) + return + } + + if (inFlightSessions.has(sessionID)) { + log(`[${HOOK_NAME}] Skipped runtime error retry: handler in flight`, { sessionID }) + return + } + + inFlightSessions.add(sessionID) + try { + const state = options.loopState.getState() + if (!state || !state.active) { + handleErroredLoopSession(props, options.loopState) + return + } + + const verificationSessionID = state.verification_pending + ? state.verification_session_id + : undefined + const matchesParentSession = state.session_id === undefined || state.session_id === sessionID + const matchesVerificationSession = verificationSessionID === sessionID + if (!matchesParentSession && !matchesVerificationSession) { + handleErroredLoopSession(props, options.loopState) + return + } + + log(`[${HOOK_NAME}] Retrying after runtime session error`, { + sessionID, + iteration: state.iteration, + error: String(error), + }) + + if (state.verification_pending) { + await handlePendingVerification(ctx, { + sessionID, + state, + verificationSessionID, + matchesParentSession, + matchesVerificationSession, + loopState: options.loopState, + directory: options.directory, + apiTimeoutMs: options.apiTimeoutMs, + }) + return + } + + if ( + typeof state.max_iterations === "number" + && state.iteration >= state.max_iterations + ) { + log(`[${HOOK_NAME}] Runtime error retry budget exhausted`, { + sessionID, + iteration: state.iteration, + max: state.max_iterations, + }) + options.loopState.clear() + await showMaxIterationsToast(ctx, state) + return + } + + const newState = options.loopState.incrementIteration() + if (!newState) { + log(`[${HOOK_NAME}] Failed to increment iteration after runtime error`, { sessionID }) + return + } + + await showIterationToast(ctx, newState) + try { + await continueIteration(ctx, newState, { + previousSessionID: sessionID, + directory: options.directory, + apiTimeoutMs: options.apiTimeoutMs, + loopState: options.loopState, + }) + runtimeErrorRetriedSessions.set(sessionID, newState.iteration) + } catch (err) { + log(`[${HOOK_NAME}] Failed to retry after runtime error`, { + sessionID, + error: String(err), + }) + } + } finally { + inFlightSessions.delete(sessionID) + } } } } From 6a2d19d605557f6f4c4aa2f50bdfc078bfc576ee Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 7 May 2026 11:27:44 +0900 Subject: [PATCH 2/4] fix(atlas): retry boulder after runtime errors --- .../atlas/boulder-continuation-injector.ts | 5 +- src/hooks/atlas/event-handler.ts | 13 ++ src/hooks/atlas/idle-event.ts | 6 + src/hooks/atlas/index.test.ts | 171 ++++++++++++++++-- src/hooks/atlas/tool-execute-after.ts | 2 +- src/hooks/atlas/types.ts | 8 +- 6 files changed, 183 insertions(+), 22 deletions(-) diff --git a/src/hooks/atlas/boulder-continuation-injector.ts b/src/hooks/atlas/boulder-continuation-injector.ts index 9e340e69b..8fc401b06 100644 --- a/src/hooks/atlas/boulder-continuation-injector.ts +++ b/src/hooks/atlas/boulder-continuation-injector.ts @@ -1,5 +1,4 @@ import type { PluginInput } from "@opencode-ai/plugin" -import type { BackgroundManager } from "../../features/background-agent" import { isAgentRegistered, resolveRegisteredAgentName, @@ -9,7 +8,7 @@ import { createInternalAgentTextPart, resolveInheritedPromptTools } from "../../ import { HOOK_NAME } from "./hook-name" import { BOULDER_CONTINUATION_PROMPT } from "./system-reminder-templates" import { resolveRecentPromptContextForSession } from "./recent-model-resolver" -import type { SessionState } from "./types" +import type { BackgroundTaskStatusProvider, SessionState } from "./types" export type BoulderContinuationResult = "injected" | "skipped_background_tasks" | "skipped_agent_unavailable" | "failed" @@ -25,7 +24,7 @@ export async function injectBoulderContinuation(input: { worktreePath?: string preferredTaskSessionId?: string preferredTaskTitle?: string - backgroundManager?: BackgroundManager + backgroundManager?: BackgroundTaskStatusProvider sessionState: SessionState }): Promise { const { diff --git a/src/hooks/atlas/event-handler.ts b/src/hooks/atlas/event-handler.ts index 95cdbe531..e9358b7ad 100644 --- a/src/hooks/atlas/event-handler.ts +++ b/src/hooks/atlas/event-handler.ts @@ -25,6 +25,16 @@ export function createAtlasEventHandler(input: { state.lastEventWasAbortError = isAbort log(`[${HOOK_NAME}] session.error`, { sessionID, isAbort }) + if (!isAbort) { + const previousInjectedAt = state.lastContinuationInjectedAt + await handleAtlasSessionIdle({ ctx, options, getState, sessionID }) + if ( + state.lastContinuationInjectedAt !== undefined + && state.lastContinuationInjectedAt !== previousInjectedAt + ) { + state.skipNextIdleAfterRuntimeErrorRetry = true + } + } return } @@ -44,6 +54,7 @@ export function createAtlasEventHandler(input: { const state = sessions.get(sessionID) if (state) { state.lastEventWasAbortError = false + state.skipNextIdleAfterRuntimeErrorRetry = false if (role === "user") { state.waitingForFinalWaveApproval = false } @@ -60,6 +71,7 @@ export function createAtlasEventHandler(input: { const state = sessions.get(sessionID) if (state) { state.lastEventWasAbortError = false + state.skipNextIdleAfterRuntimeErrorRetry = false } } return @@ -71,6 +83,7 @@ export function createAtlasEventHandler(input: { const state = sessions.get(sessionID) if (state) { state.lastEventWasAbortError = false + state.skipNextIdleAfterRuntimeErrorRetry = false } } return diff --git a/src/hooks/atlas/idle-event.ts b/src/hooks/atlas/idle-event.ts index 41df724bb..af148838b 100644 --- a/src/hooks/atlas/idle-event.ts +++ b/src/hooks/atlas/idle-event.ts @@ -254,6 +254,12 @@ export async function handleAtlasSessionIdle(input: { return } + if (sessionState.skipNextIdleAfterRuntimeErrorRetry) { + sessionState.skipNextIdleAfterRuntimeErrorRetry = false + log(`[${HOOK_NAME}] Skipped: stale idle after runtime error retry`, { sessionID }) + return + } + if (sessionState.promptFailureCount >= MAX_CONSECUTIVE_PROMPT_FAILURES) { const timeSinceLastFailure = sessionState.lastFailureAt !== undefined ? now - sessionState.lastFailureAt : Number.POSITIVE_INFINITY diff --git a/src/hooks/atlas/index.test.ts b/src/hooks/atlas/index.test.ts index a2e80cf78..4162f1793 100644 --- a/src/hooks/atlas/index.test.ts +++ b/src/hooks/atlas/index.test.ts @@ -66,7 +66,7 @@ describe("atlas hook", () => { }, _promptMock: promptMock, _sessionGetMock: sessionGetMock, - } as unknown as Parameters[0] & { + } as Parameters[0] & { _promptMock: ReturnType _sessionGetMock: ReturnType } @@ -122,7 +122,7 @@ describe("atlas hook", () => { // when - calling with undefined output const result = await hook["tool.execute.after"]( { tool: "task", sessionID: "session-123" }, - undefined as unknown as { title: string; output: string; metadata: Record } + undefined ) // then - returns undefined without throwing @@ -1531,6 +1531,142 @@ session_id: ses_untrusted_999 expect(mockInput._promptMock).not.toHaveBeenCalled() }) + test("#given boulder has incomplete tasks #when non-abort session error fires #then continuation injects immediately", async () => { + // given - boulder state with incomplete plan + const planPath = join(TEST_DIR, "test-plan.md") + writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [ ] Task 2") + + const state: BoulderState = { + active_plan: planPath, + started_at: "2026-01-02T10:00:00Z", + session_ids: [MAIN_SESSION_ID], + plan_name: "test-plan", + } + writeBoulderState(TEST_DIR, state) + + const mockInput = createMockPluginInput() + const hook = createAtlasHook(mockInput) + + // when - a recoverable runtime error fires without waiting for idle + await hook.handler({ + event: { + type: "session.error", + properties: { + sessionID: MAIN_SESSION_ID, + error: { name: "RuntimeError", message: "provider overloaded" }, + }, + }, + }) + + // then - boulder resumes work immediately + expect(mockInput._promptMock).toHaveBeenCalledTimes(1) + const callArgs = mockInput._promptMock.mock.calls[0][0] + expect(callArgs.path.id).toBe(MAIN_SESSION_ID) + expect(callArgs.body.parts[0].text).toContain("incomplete tasks") + expect(callArgs.body.parts[0].text).toContain("2 remaining") + }) + + test("#given boulder retried a runtime error #when stale idle follows #then no delayed duplicate retry is scheduled", async () => { + // given - boulder state with incomplete plan + const planPath = join(TEST_DIR, "test-plan.md") + writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [ ] Task 2") + + const state: BoulderState = { + active_plan: planPath, + started_at: "2026-01-02T10:00:00Z", + session_ids: [MAIN_SESSION_ID], + plan_name: "test-plan", + } + writeBoulderState(TEST_DIR, state) + + const originalSetTimeout = globalThis.setTimeout + const scheduledDelays: number[] = [] + globalThis.setTimeout = ((_handler: TimerHandler, timeout?: number, ..._args: unknown[]) => { + scheduledDelays.push(timeout ?? 0) + return originalSetTimeout(() => undefined, 0) + }) as typeof setTimeout + + try { + const mockInput = createMockPluginInput() + const hook = createAtlasHook(mockInput) + + // when - runtime error resumes immediately and OpenCode later emits stale idle + await hook.handler({ + event: { + type: "session.error", + properties: { + sessionID: MAIN_SESSION_ID, + error: { name: "RuntimeError", message: "provider overloaded" }, + }, + }, + }) + await hook.handler({ + event: { + type: "session.idle", + properties: { sessionID: MAIN_SESSION_ID }, + }, + }) + + // then - stale idle is consumed, not converted into another scheduled continuation + expect(mockInput._promptMock).toHaveBeenCalledTimes(1) + expect(scheduledDelays).toHaveLength(0) + } finally { + globalThis.setTimeout = originalSetTimeout + } + }) + + test("#given boulder retried a runtime error #when assistant activity arrives #then next idle can continue", async () => { + // given - boulder state with incomplete plan + const planPath = join(TEST_DIR, "test-plan.md") + writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [ ] Task 2") + + const state: BoulderState = { + active_plan: planPath, + started_at: "2026-01-02T10:00:00Z", + session_ids: [MAIN_SESSION_ID], + plan_name: "test-plan", + } + writeBoulderState(TEST_DIR, state) + + const originalDateNow = Date.now + let now = 1000 + Date.now = () => now + + try { + const mockInput = createMockPluginInput() + const hook = createAtlasHook(mockInput) + + // when - runtime error resumes immediately and then the retry run emits assistant activity + await hook.handler({ + event: { + type: "session.error", + properties: { + sessionID: MAIN_SESSION_ID, + error: { name: "RuntimeError", message: "provider overloaded" }, + }, + }, + }) + await hook.handler({ + event: { + type: "message.updated", + properties: { info: { sessionID: MAIN_SESSION_ID, role: "assistant" } }, + }, + }) + now = 7000 + await hook.handler({ + event: { + type: "session.idle", + properties: { sessionID: MAIN_SESSION_ID }, + }, + }) + + // then - assistant activity marks the following idle as real work completion + expect(mockInput._promptMock).toHaveBeenCalledTimes(2) + } finally { + Date.now = originalDateNow + } + }) + test("should skip when background tasks are running", async () => { // given - boulder state with incomplete plan const planPath = join(TEST_DIR, "test-plan.md") @@ -1551,7 +1687,7 @@ session_id: ses_untrusted_999 const mockInput = createMockPluginInput() const hook = createAtlasHook(mockInput, { directory: TEST_DIR, - backgroundManager: mockBackgroundManager as any, + backgroundManager: mockBackgroundManager, }) // when @@ -2223,8 +2359,7 @@ session_id: ses_untrusted_999 }) describe("delayed retry timer (abort-stuck fix)", () => { - const capturedTimers = new Map() - let nextFakeId = 99000 + const capturedTimers = new Map, { callback: () => void | Promise; cleared: boolean }>() const originalSetTimeout = globalThis.setTimeout const originalClearTimeout = globalThis.clearTimeout const originalDateNow = Date.now @@ -2232,28 +2367,32 @@ session_id: ses_untrusted_999 beforeEach(() => { capturedTimers.clear() - nextFakeId = 99000 fakeNow = 10000 Date.now = () => fakeNow - globalThis.setTimeout = ((callback: Function, delay?: number, ...args: unknown[]) => { + globalThis.setTimeout = ((callback: TimerHandler, delay?: number, ...args: unknown[]) => { const normalized = typeof delay === "number" ? delay : 0 if (normalized >= 5000) { - const id = nextFakeId++ - capturedTimers.set(id, { callback: () => callback(...args), cleared: false }) - return id as unknown as ReturnType + const timerID = originalSetTimeout(() => undefined, 0) + const capturedCallback = typeof callback === "function" + ? () => callback(...args) + : () => undefined + capturedTimers.set(timerID, { callback: capturedCallback, cleared: false }) + return timerID } - return originalSetTimeout(callback as Parameters[0], delay) - }) as unknown as typeof setTimeout + return typeof callback === "function" + ? originalSetTimeout(callback, delay, ...args) + : originalSetTimeout(() => undefined, delay) + }) as typeof setTimeout - globalThis.clearTimeout = ((id?: number | ReturnType) => { - if (typeof id === "number" && capturedTimers.has(id)) { + globalThis.clearTimeout = ((id?: ReturnType) => { + if (id && capturedTimers.has(id)) { capturedTimers.get(id)!.cleared = true capturedTimers.delete(id) return } - originalClearTimeout(id as Parameters[0]) - }) as unknown as typeof clearTimeout + originalClearTimeout(id) + }) as typeof clearTimeout }) afterEach(() => { diff --git a/src/hooks/atlas/tool-execute-after.ts b/src/hooks/atlas/tool-execute-after.ts index 5fd5808ed..a2d9c9e0a 100644 --- a/src/hooks/atlas/tool-execute-after.ts +++ b/src/hooks/atlas/tool-execute-after.ts @@ -32,7 +32,7 @@ export function createToolExecuteAfterHandler(input: { pendingTaskRefs: Map autoCommit: boolean getState: (sessionID: string) => SessionState -}): (toolInput: ToolExecuteAfterInput, toolOutput: ToolExecuteAfterOutput) => Promise { +}): (toolInput: ToolExecuteAfterInput, toolOutput: ToolExecuteAfterOutput | undefined) => Promise { const { ctx, pendingFilePaths, pendingTaskRefs, autoCommit, getState } = input return async (toolInput, toolOutput): Promise => { // Guard against undefined output (e.g., from /review command - see issue #1035) diff --git a/src/hooks/atlas/types.ts b/src/hooks/atlas/types.ts index 534478da2..f82f8ca14 100644 --- a/src/hooks/atlas/types.ts +++ b/src/hooks/atlas/types.ts @@ -1,12 +1,15 @@ import type { AgentOverrides } from "../../config" -import type { BackgroundManager } from "../../features/background-agent" import type { TopLevelTaskRef } from "../../features/boulder-state" export type ModelInfo = { providerID: string; modelID: string; variant?: string } +export interface BackgroundTaskStatusProvider { + getTasksByParentSession: (sessionID: string) => Array<{ status: string }> +} + export interface AtlasHookOptions { directory: string - backgroundManager?: BackgroundManager + backgroundManager?: BackgroundTaskStatusProvider isContinuationStopped?: (sessionID: string) => boolean agentOverrides?: AgentOverrides /** Enable auto-commit after each atomic task completion (default: true) */ @@ -34,6 +37,7 @@ export type PendingTaskRef = export interface SessionState { lastEventWasAbortError?: boolean + skipNextIdleAfterRuntimeErrorRetry?: boolean lastContinuationInjectedAt?: number isInjectingContinuation?: boolean promptFailureCount: number From 2c70938d8177cc0935dda4141d53661cccb2fa86 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 7 May 2026 11:27:46 +0900 Subject: [PATCH 3/4] test(ralph-loop): cover ultrawork runtime retry --- .../non-abort-error-continuation.test.ts | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/src/hooks/ralph-loop/non-abort-error-continuation.test.ts b/src/hooks/ralph-loop/non-abort-error-continuation.test.ts index a61207cbb..54f9eda85 100644 --- a/src/hooks/ralph-loop/non-abort-error-continuation.test.ts +++ b/src/hooks/ralph-loop/non-abort-error-continuation.test.ts @@ -89,6 +89,62 @@ describe("ralph-loop non-abort error continuation", () => { expect(hook.getState()?.iteration).toBe(2) }) + test("continues ultrawork loop immediately after non-abort session error", async () => { + // given - an active ULW Loop receives a recoverable runtime error + const hook = createRalphLoopHook({ + directory: testDirectory, + project: testDirectory, + worktree: testDirectory, + serverUrl: "http://localhost:4096", + $: async () => ({}), + client: { + session: { + messages: async (options: { path: { id: string } }) => { + messagesCalls.push({ sessionID: options.path.id }) + return { data: [] } + }, + promptAsync: async (options: { + path: { id: string } + body: { parts: Array<{ type: string; text: string }> } + }) => { + promptCalls.push({ + sessionID: options.path.id, + text: options.body.parts[0]?.text ?? "", + }) + return {} + }, + prompt: async () => ({}), + }, + tui: { + showToast: async () => ({}), + }, + }, + } as never) + + hook.startLoop("session-123", "Keep ultraworking", { + messageCountAtStart: 0, + maxIterations: 5, + ultrawork: true, + }) + + await hook.event({ + event: { + type: "session.error", + properties: { + sessionID: "session-123", + error: { name: "RuntimeError" }, + }, + }, + }) + + // then - the ULW continuation keeps the ultrawork directive + expect(promptCalls).toHaveLength(1) + expect(promptCalls[0]?.sessionID).toBe("session-123") + expect(promptCalls[0]?.text).toMatch(/^ultrawork /) + expect(promptCalls[0]?.text).toContain("Keep ultraworking") + expect(hook.getState()?.iteration).toBe(2) + }) + test("stops retrying runtime errors after max iterations", async () => { // given - an active Ralph Loop has one retry remaining const hook = createRalphLoopHook({ From ebe26eab17ae726a216b5ee9cd5073c508689fe8 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 7 May 2026 11:50:44 +0900 Subject: [PATCH 4/4] fix(ralph-loop): guard runtime error retries --- .../non-abort-error-continuation.test.ts | 128 ++++++++++++++++++ .../ralph-loop/ralph-loop-event-handler.ts | 90 +++++++++--- 2 files changed, 196 insertions(+), 22 deletions(-) diff --git a/src/hooks/ralph-loop/non-abort-error-continuation.test.ts b/src/hooks/ralph-loop/non-abort-error-continuation.test.ts index 056ebfe07..5c5f63ca7 100644 --- a/src/hooks/ralph-loop/non-abort-error-continuation.test.ts +++ b/src/hooks/ralph-loop/non-abort-error-continuation.test.ts @@ -144,6 +144,134 @@ describe("ralph-loop non-abort error continuation", () => { expect(hook.getState()?.iteration).toBe(2) }) + test("continues after retry run activity when no stale idle arrived", async () => { + // given - an active loop retries a recoverable runtime error + const hook = createRalphLoopHook({ + directory: testDirectory, + project: testDirectory, + worktree: testDirectory, + serverUrl: "http://localhost:4096", + $: async () => ({}), + client: { + session: { + messages: async (options: { path: { id: string } }) => { + messagesCalls.push({ sessionID: options.path.id }) + return { data: [] } + }, + promptAsync: async (options: { + path: { id: string } + body: { parts: Array<{ type: string; text: string }> } + }) => { + promptCalls.push({ + sessionID: options.path.id, + text: options.body.parts[0]?.text ?? "", + }) + return {} + }, + prompt: async () => ({}), + }, + tui: { + showToast: async () => ({}), + }, + }, + } as never) + + hook.startLoop("session-123", "Keep working", { + messageCountAtStart: 0, + maxIterations: 5, + }) + + await hook.event({ + event: { + type: "session.error", + properties: { + sessionID: "session-123", + error: { name: "RuntimeError" }, + }, + }, + }) + + // when - the retried run emits real assistant activity before any stale idle + await hook.event({ + event: { + type: "message.part.delta", + properties: { + sessionID: "session-123", + messageID: "msg-1", + partID: "part-1", + field: "text", + delta: "working", + }, + }, + }) + await hook.event({ + event: { type: "session.idle", properties: { sessionID: "session-123" } }, + }) + + // then - the real idle is allowed to continue the loop + expect(promptCalls).toHaveLength(2) + expect(hook.getState()?.iteration).toBe(3) + }) + + test("skips immediate runtime retry while background tasks are running", async () => { + // given - an active loop owns running background work + const hook = createRalphLoopHook({ + directory: testDirectory, + project: testDirectory, + worktree: testDirectory, + serverUrl: "http://localhost:4096", + $: async () => ({}), + client: { + session: { + messages: async (options: { path: { id: string } }) => { + messagesCalls.push({ sessionID: options.path.id }) + return { data: [] } + }, + promptAsync: async (options: { + path: { id: string } + body: { parts: Array<{ type: string; text: string }> } + }) => { + promptCalls.push({ + sessionID: options.path.id, + text: options.body.parts[0]?.text ?? "", + }) + return {} + }, + prompt: async () => ({}), + }, + tui: { + showToast: async () => ({}), + }, + }, + } as never, { + backgroundManager: { + getTasksByParentSession: (sessionID: string) => sessionID === "session-123" + ? [{ status: "running" }] + : [], + }, + }) + + hook.startLoop("session-123", "Keep working", { + messageCountAtStart: 0, + maxIterations: 5, + }) + + // when - the same session reports a recoverable runtime error + await hook.event({ + event: { + type: "session.error", + properties: { + sessionID: "session-123", + error: { name: "RuntimeError" }, + }, + }, + }) + + // then - Ralph waits for background work instead of starting overlapping continuation + expect(promptCalls).toHaveLength(0) + expect(hook.getState()?.iteration).toBe(1) + }) + test("stops retrying runtime errors after max iterations", async () => { // given - an active Ralph Loop has one retry remaining const hook = createRalphLoopHook({ diff --git a/src/hooks/ralph-loop/ralph-loop-event-handler.ts b/src/hooks/ralph-loop/ralph-loop-event-handler.ts index fcffb9db4..a0ed83498 100644 --- a/src/hooks/ralph-loop/ralph-loop-event-handler.ts +++ b/src/hooks/ralph-loop/ralph-loop-event-handler.ts @@ -22,6 +22,47 @@ type LoopStateController = { } type RalphLoopEventHandlerOptions = { directory: string; apiTimeoutMs: number; getTranscriptPath: (sessionID: string) => string | undefined; checkSessionExists?: RalphLoopOptions["checkSessionExists"]; backgroundManager?: RalphLoopOptions["backgroundManager"]; loopState: LoopStateController } +function hasRunningBackgroundTasks( + backgroundManager: RalphLoopOptions["backgroundManager"], + sessionID: string, +): boolean { + return backgroundManager + ? backgroundManager.getTasksByParentSession(sessionID).some((task: { status: string }) => task.status === "running") + : false +} + +function getInfoSessionID(props: Record | undefined): string | undefined { + const info = props?.info as Record | undefined + const sessionID = info?.sessionID + return typeof sessionID === "string" ? sessionID : undefined +} + +function getRuntimeRetryActivitySessionID( + eventType: string, + props: Record | undefined, +): string | undefined { + if (eventType === "message.updated") { + const info = props?.info as Record | undefined + const role = info?.role + return role === "assistant" ? getInfoSessionID(props) : undefined + } + + if (eventType === "message.part.updated") { + if (typeof props?.sessionID === "string") return props.sessionID + return getInfoSessionID(props) + } + + if (eventType === "message.part.delta") { + return typeof props?.sessionID === "string" ? props.sessionID : undefined + } + + if (eventType === "tool.execute.before" || eventType === "tool.execute.after") { + return typeof props?.sessionID === "string" ? props.sessionID : undefined + } + + return undefined +} + function isAbortError(error: unknown): boolean { return typeof error === "object" && error !== null @@ -61,6 +102,10 @@ export function createRalphLoopEventHandler( return async ({ event }: { event: { type: string; properties?: unknown } }): Promise => { const props = event.properties as Record | undefined + const runtimeRetryActivitySessionID = getRuntimeRetryActivitySessionID(event.type, props) + if (runtimeRetryActivitySessionID) { + runtimeErrorRetriedSessions.delete(runtimeRetryActivitySessionID) + } if (event.type === "session.idle") { const sessionID = props?.sessionID as string | undefined @@ -75,18 +120,14 @@ export function createRalphLoopEventHandler( try { const state = options.loopState.getState() - if (!state || !state.active) { - return - } + if (!state || !state.active) { + return + } - const hasRunningBackgroundTasks = options.backgroundManager - ? options.backgroundManager.getTasksByParentSession(sessionID).some((task: { status: string }) => task.status === "running") - : false - - if (hasRunningBackgroundTasks) { - log(`[${HOOK_NAME}] Skipped: background tasks running`, { sessionID }) - return - } + if (hasRunningBackgroundTasks(options.backgroundManager, sessionID)) { + log(`[${HOOK_NAME}] Skipped: background tasks running`, { sessionID }) + return + } const verificationSessionID = state.verification_pending ? state.verification_session_id @@ -278,18 +319,23 @@ export function createRalphLoopEventHandler( const verificationSessionID = state.verification_pending ? state.verification_session_id : undefined - const matchesParentSession = state.session_id === undefined || state.session_id === sessionID - const matchesVerificationSession = verificationSessionID === sessionID - if (!matchesParentSession && !matchesVerificationSession) { - handleErroredLoopSession(props, options.loopState) - return - } + const matchesParentSession = state.session_id === undefined || state.session_id === sessionID + const matchesVerificationSession = verificationSessionID === sessionID + if (!matchesParentSession && !matchesVerificationSession) { + handleErroredLoopSession(props, options.loopState) + return + } - log(`[${HOOK_NAME}] Retrying after runtime session error`, { - sessionID, - iteration: state.iteration, - error: String(error), - }) + if (hasRunningBackgroundTasks(options.backgroundManager, sessionID)) { + log(`[${HOOK_NAME}] Skipped runtime error retry: background tasks running`, { sessionID }) + return + } + + log(`[${HOOK_NAME}] Retrying after runtime session error`, { + sessionID, + iteration: state.iteration, + error: String(error), + }) if (state.verification_pending) { await handlePendingVerification(ctx, {