From 806842981f390420377c2792caeb94e396e7c143 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 10 May 2026 12:46:00 +0900 Subject: [PATCH] fix(ralph-loop): settle idle before continuation --- src/hooks/ralph-loop/index.test.ts | 26 +++++- .../ralph-loop/ralph-loop-event-handler.ts | 8 +- src/hooks/ralph-loop/ralph-loop-hook.ts | 3 + .../reset-strategy-race-condition.test.ts | 83 ++++++++++--------- src/hooks/ralph-loop/types.ts | 1 + 5 files changed, 78 insertions(+), 43 deletions(-) diff --git a/src/hooks/ralph-loop/index.test.ts b/src/hooks/ralph-loop/index.test.ts index 7418f4f2e..9676f9ba6 100644 --- a/src/hooks/ralph-loop/index.test.ts +++ b/src/hooks/ralph-loop/index.test.ts @@ -304,13 +304,35 @@ describe("ralph-loop", () => { expect(state?.iteration).toBe(2) }) + test("should settle idle before injecting continuation", async () => { + // given - active loop state with a configured idle settle delay + const hook = createRalphLoopHook(createMockPluginInput(), { idleSettleMs: 25 }) + hook.startLoop("session-123", "Build a feature", { maxIterations: 10 }) + + // when - session goes idle + const eventPromise = hook.event({ + event: { + type: "session.idle", + properties: { sessionID: "session-123" }, + }, + }) + await Promise.resolve() + + // then - continuation should not be injected in the same event-loop turn + expect(promptCalls.length).toBe(0) + + await eventPromise + expect(promptCalls.length).toBe(1) + expect(promptCalls[0].sessionID).toBe("session-123") + }) + test("#given hanging toast #when session idles #then continuation still injects", async () => { // given - TUI toast never settles const ctx = createMockPluginInput() ctx.client.tui = { showToast: () => new Promise(() => {}), } as never - const hook = createRalphLoopHook(ctx) + const hook = createRalphLoopHook(ctx, { idleSettleMs: 0 }) hook.startLoop("session-123", "Build a feature", { maxIterations: 10 }) // when - session goes idle @@ -359,7 +381,7 @@ describe("ralph-loop", () => { test("should stop loop when max iterations reached", async () => { // given - loop at max iteration - const hook = createRalphLoopHook(createMockPluginInput()) + const hook = createRalphLoopHook(createMockPluginInput(), { idleSettleMs: 0 }) hook.startLoop("session-123", "Build something", { maxIterations: 2 }) const state = hook.getState()! diff --git a/src/hooks/ralph-loop/ralph-loop-event-handler.ts b/src/hooks/ralph-loop/ralph-loop-event-handler.ts index 788aa2594..3f20ccf34 100644 --- a/src/hooks/ralph-loop/ralph-loop-event-handler.ts +++ b/src/hooks/ralph-loop/ralph-loop-event-handler.ts @@ -20,7 +20,11 @@ type LoopStateController = { setVerificationSessionID: (sessionID: string, verificationSessionID: string) => RalphLoopState | null restartAfterFailedVerification: (sessionID: string, messageCountAtStart?: number) => RalphLoopState | null } -type RalphLoopEventHandlerOptions = { directory: string; apiTimeoutMs: number; getTranscriptPath: (sessionID: string) => string | undefined; checkSessionExists?: RalphLoopOptions["checkSessionExists"]; backgroundManager?: RalphLoopOptions["backgroundManager"]; loopState: LoopStateController } +type RalphLoopEventHandlerOptions = { directory: string; apiTimeoutMs: number; idleSettleMs: number; getTranscriptPath: (sessionID: string) => string | undefined; checkSessionExists?: RalphLoopOptions["checkSessionExists"]; backgroundManager?: RalphLoopOptions["backgroundManager"]; loopState: LoopStateController } + +function sleep(ms: number): Promise { + return ms > 0 ? new Promise((resolve) => setTimeout(resolve, ms)) : Promise.resolve() +} function hasRunningBackgroundTasks( backgroundManager: RalphLoopOptions["backgroundManager"], @@ -281,6 +285,7 @@ export function createRalphLoopEventHandler( }) showIterationToast(ctx, newState) + await sleep(options.idleSettleMs) try { await continueIteration(ctx, newState, { @@ -383,6 +388,7 @@ export function createRalphLoopEventHandler( } showIterationToast(ctx, newState) + await sleep(options.idleSettleMs) try { await continueIteration(ctx, newState, { previousSessionID: sessionID, diff --git a/src/hooks/ralph-loop/ralph-loop-hook.ts b/src/hooks/ralph-loop/ralph-loop-hook.ts index 474ae633a..e03c9d730 100644 --- a/src/hooks/ralph-loop/ralph-loop-hook.ts +++ b/src/hooks/ralph-loop/ralph-loop-hook.ts @@ -22,6 +22,7 @@ export interface RalphLoopHook { } const DEFAULT_API_TIMEOUT = 5000 as const +const DEFAULT_IDLE_SETTLE_MS = 150 as const function getMessageCountFromResponse(messagesResponse: unknown): number { if (Array.isArray(messagesResponse)) { @@ -44,6 +45,7 @@ export function createRalphLoopHook( const stateDir = config?.state_dir const getTranscriptPath = options?.getTranscriptPath ?? getDefaultTranscriptPath const apiTimeout = options?.apiTimeout ?? DEFAULT_API_TIMEOUT + const idleSettleMs = options?.idleSettleMs ?? DEFAULT_IDLE_SETTLE_MS const checkSessionExists = options?.checkSessionExists const backgroundManager = options?.backgroundManager @@ -56,6 +58,7 @@ export function createRalphLoopHook( const event = createRalphLoopEventHandler(ctx, { directory: ctx.directory, apiTimeoutMs: apiTimeout, + idleSettleMs, getTranscriptPath, checkSessionExists, backgroundManager, diff --git a/src/hooks/ralph-loop/reset-strategy-race-condition.test.ts b/src/hooks/ralph-loop/reset-strategy-race-condition.test.ts index 8f31f8ec2..15de66084 100644 --- a/src/hooks/ralph-loop/reset-strategy-race-condition.test.ts +++ b/src/hooks/ralph-loop/reset-strategy-race-condition.test.ts @@ -43,49 +43,52 @@ describe("ralph-loop reset strategy race condition", () => { let selectSessionCalls = 0 const selectSessionDeferred = createDeferred() - const hook = createRalphLoopHook({ - directory: process.cwd(), - client: { - session: { - prompt: 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 {} + const hook = createRalphLoopHook( + { + directory: process.cwd(), + client: { + session: { + prompt: 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 {} + }, + 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 {} + }, + create: async (options: { + body: { parentID?: string; title?: string } + query?: { directory?: string } + }) => { + createSessionCalls.push({ parentID: options.body.parentID }) + return { data: { id: `new-session-${createSessionCalls.length}` } } + }, + messages: async () => ({ 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 {} - }, - create: async (options: { - body: { parentID?: string; title?: string } - query?: { directory?: string } - }) => { - createSessionCalls.push({ parentID: options.body.parentID }) - return { data: { id: `new-session-${createSessionCalls.length}` } } - }, - messages: async () => ({ data: [] }), - }, - tui: { - showToast: async () => ({}), - selectSession: async () => { - selectSessionCalls += 1 - await selectSessionDeferred.promise - return {} + tui: { + showToast: async () => ({}), + selectSession: async () => { + selectSessionCalls += 1 + await selectSessionDeferred.promise + return {} + }, }, }, - }, - } as unknown as Parameters[0]) + } as unknown as Parameters[0], + { idleSettleMs: 0 }, + ) hook.startLoop("session-old", "Build feature", { strategy: "reset" }) diff --git a/src/hooks/ralph-loop/types.ts b/src/hooks/ralph-loop/types.ts index 4c8470707..af864ccbc 100644 --- a/src/hooks/ralph-loop/types.ts +++ b/src/hooks/ralph-loop/types.ts @@ -21,6 +21,7 @@ export interface RalphLoopOptions { config?: RalphLoopConfig getTranscriptPath?: (sessionId: string) => string apiTimeout?: number + idleSettleMs?: number checkSessionExists?: (sessionId: string) => Promise backgroundManager?: { getTasksByParentSession: (sessionId: string) => Array<{ status: string }> } }