fix(ralph-loop): settle idle before continuation

This commit is contained in:
YeonGyu-Kim
2026-05-10 12:46:00 +09:00
parent 1e4a51e21b
commit 806842981f
5 changed files with 78 additions and 43 deletions
+24 -2
View File
@@ -304,13 +304,35 @@ describe("ralph-loop", () => {
expect(state?.iteration).toBe(2) 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 () => { test("#given hanging toast #when session idles #then continuation still injects", async () => {
// given - TUI toast never settles // given - TUI toast never settles
const ctx = createMockPluginInput() const ctx = createMockPluginInput()
ctx.client.tui = { ctx.client.tui = {
showToast: () => new Promise(() => {}), showToast: () => new Promise(() => {}),
} as never } as never
const hook = createRalphLoopHook(ctx) const hook = createRalphLoopHook(ctx, { idleSettleMs: 0 })
hook.startLoop("session-123", "Build a feature", { maxIterations: 10 }) hook.startLoop("session-123", "Build a feature", { maxIterations: 10 })
// when - session goes idle // when - session goes idle
@@ -359,7 +381,7 @@ describe("ralph-loop", () => {
test("should stop loop when max iterations reached", async () => { test("should stop loop when max iterations reached", async () => {
// given - loop at max iteration // given - loop at max iteration
const hook = createRalphLoopHook(createMockPluginInput()) const hook = createRalphLoopHook(createMockPluginInput(), { idleSettleMs: 0 })
hook.startLoop("session-123", "Build something", { maxIterations: 2 }) hook.startLoop("session-123", "Build something", { maxIterations: 2 })
const state = hook.getState()! const state = hook.getState()!
@@ -20,7 +20,11 @@ type LoopStateController = {
setVerificationSessionID: (sessionID: string, verificationSessionID: string) => RalphLoopState | null setVerificationSessionID: (sessionID: string, verificationSessionID: string) => RalphLoopState | null
restartAfterFailedVerification: (sessionID: string, messageCountAtStart?: number) => 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<void> {
return ms > 0 ? new Promise((resolve) => setTimeout(resolve, ms)) : Promise.resolve()
}
function hasRunningBackgroundTasks( function hasRunningBackgroundTasks(
backgroundManager: RalphLoopOptions["backgroundManager"], backgroundManager: RalphLoopOptions["backgroundManager"],
@@ -281,6 +285,7 @@ export function createRalphLoopEventHandler(
}) })
showIterationToast(ctx, newState) showIterationToast(ctx, newState)
await sleep(options.idleSettleMs)
try { try {
await continueIteration(ctx, newState, { await continueIteration(ctx, newState, {
@@ -383,6 +388,7 @@ export function createRalphLoopEventHandler(
} }
showIterationToast(ctx, newState) showIterationToast(ctx, newState)
await sleep(options.idleSettleMs)
try { try {
await continueIteration(ctx, newState, { await continueIteration(ctx, newState, {
previousSessionID: sessionID, previousSessionID: sessionID,
+3
View File
@@ -22,6 +22,7 @@ export interface RalphLoopHook {
} }
const DEFAULT_API_TIMEOUT = 5000 as const const DEFAULT_API_TIMEOUT = 5000 as const
const DEFAULT_IDLE_SETTLE_MS = 150 as const
function getMessageCountFromResponse(messagesResponse: unknown): number { function getMessageCountFromResponse(messagesResponse: unknown): number {
if (Array.isArray(messagesResponse)) { if (Array.isArray(messagesResponse)) {
@@ -44,6 +45,7 @@ export function createRalphLoopHook(
const stateDir = config?.state_dir const stateDir = config?.state_dir
const getTranscriptPath = options?.getTranscriptPath ?? getDefaultTranscriptPath const getTranscriptPath = options?.getTranscriptPath ?? getDefaultTranscriptPath
const apiTimeout = options?.apiTimeout ?? DEFAULT_API_TIMEOUT const apiTimeout = options?.apiTimeout ?? DEFAULT_API_TIMEOUT
const idleSettleMs = options?.idleSettleMs ?? DEFAULT_IDLE_SETTLE_MS
const checkSessionExists = options?.checkSessionExists const checkSessionExists = options?.checkSessionExists
const backgroundManager = options?.backgroundManager const backgroundManager = options?.backgroundManager
@@ -56,6 +58,7 @@ export function createRalphLoopHook(
const event = createRalphLoopEventHandler(ctx, { const event = createRalphLoopEventHandler(ctx, {
directory: ctx.directory, directory: ctx.directory,
apiTimeoutMs: apiTimeout, apiTimeoutMs: apiTimeout,
idleSettleMs,
getTranscriptPath, getTranscriptPath,
checkSessionExists, checkSessionExists,
backgroundManager, backgroundManager,
@@ -43,49 +43,52 @@ describe("ralph-loop reset strategy race condition", () => {
let selectSessionCalls = 0 let selectSessionCalls = 0
const selectSessionDeferred = createDeferred() const selectSessionDeferred = createDeferred()
const hook = createRalphLoopHook({ const hook = createRalphLoopHook(
directory: process.cwd(), {
client: { directory: process.cwd(),
session: { client: {
prompt: async (options: { session: {
path: { id: string } prompt: async (options: {
body: { parts: Array<{ type: string; text: string }> } path: { id: string }
}) => { body: { parts: Array<{ type: string; text: string }> }
promptCalls.push({ }) => {
sessionID: options.path.id, promptCalls.push({
text: options.body.parts[0].text, sessionID: options.path.id,
}) text: options.body.parts[0].text,
return {} })
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: { tui: {
path: { id: string } showToast: async () => ({}),
body: { parts: Array<{ type: string; text: string }> } selectSession: async () => {
}) => { selectSessionCalls += 1
promptCalls.push({ await selectSessionDeferred.promise
sessionID: options.path.id, return {}
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 {}
}, },
}, },
}, } as unknown as Parameters<typeof createRalphLoopHook>[0],
} as unknown as Parameters<typeof createRalphLoopHook>[0]) { idleSettleMs: 0 },
)
hook.startLoop("session-old", "Build feature", { strategy: "reset" }) hook.startLoop("session-old", "Build feature", { strategy: "reset" })
+1
View File
@@ -21,6 +21,7 @@ export interface RalphLoopOptions {
config?: RalphLoopConfig config?: RalphLoopConfig
getTranscriptPath?: (sessionId: string) => string getTranscriptPath?: (sessionId: string) => string
apiTimeout?: number apiTimeout?: number
idleSettleMs?: number
checkSessionExists?: (sessionId: string) => Promise<boolean> checkSessionExists?: (sessionId: string) => Promise<boolean>
backgroundManager?: { getTasksByParentSession: (sessionId: string) => Array<{ status: string }> } backgroundManager?: { getTasksByParentSession: (sessionId: string) => Array<{ status: string }> }
} }