diff --git a/src/hooks/ralph-loop/iteration-continuation.ts b/src/hooks/ralph-loop/iteration-continuation.ts index 15fea10a..be067b76 100644 --- a/src/hooks/ralph-loop/iteration-continuation.ts +++ b/src/hooks/ralph-loop/iteration-continuation.ts @@ -33,15 +33,6 @@ export async function continueIteration( return } - const boundState = options.loopState.setSessionID(newSessionID) - if (!boundState) { - log(`[${HOOK_NAME}] Failed to bind loop state to new session`, { - previousSessionID: options.previousSessionID, - newSessionID, - }) - return - } - await injectContinuationPrompt(ctx, { sessionID: newSessionID, inheritFromSessionID: options.previousSessionID, @@ -51,6 +42,16 @@ export async function continueIteration( }) await selectSessionInTui(ctx.client, newSessionID) + + const boundState = options.loopState.setSessionID(newSessionID) + if (!boundState) { + log(`[${HOOK_NAME}] Failed to bind loop state to new session`, { + previousSessionID: options.previousSessionID, + newSessionID, + }) + return + } + return } diff --git a/src/hooks/ralph-loop/reset-strategy-race-condition.test.ts b/src/hooks/ralph-loop/reset-strategy-race-condition.test.ts new file mode 100644 index 00000000..c2f21332 --- /dev/null +++ b/src/hooks/ralph-loop/reset-strategy-race-condition.test.ts @@ -0,0 +1,113 @@ +/// +import { describe, expect, test } from "bun:test" +import { createRalphLoopHook } from "./index" + +function createDeferred(): { + promise: Promise + resolve: () => void +} { + let resolvePromise: (() => void) | null = null + const promise = new Promise((resolve) => { + resolvePromise = resolve + }) + + return { + promise, + resolve: () => { + if (resolvePromise) { + resolvePromise() + } + }, + } +} + +async function waitUntil(condition: () => boolean): Promise { + for (let index = 0; index < 100; index++) { + if (condition()) { + return + } + + await new Promise((resolve) => { + setTimeout(resolve, 0) + }) + } + + throw new Error("Condition was not met in time") +} + +describe("ralph-loop reset strategy race condition", () => { + test("should continue iteration when old session idle arrives before TUI switch completes", async () => { + // given - reset strategy loop with blocked TUI session switch + const promptCalls: Array<{ sessionID: string; text: string }> = [] + const createSessionCalls: Array<{ parentID?: string }> = [] + 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 {} + }, + 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 {} + }, + }, + }, + } as Parameters[0]) + + hook.startLoop("session-old", "Build feature", { strategy: "reset" }) + + // when - first idle is in-flight and old session fires idle again before TUI switch resolves + const firstIdleEvent = hook.event({ + event: { type: "session.idle", properties: { sessionID: "session-old" } }, + }) + + await waitUntil(() => selectSessionCalls > 0) + + const secondIdleEvent = hook.event({ + event: { type: "session.idle", properties: { sessionID: "session-old" } }, + }) + + await waitUntil(() => selectSessionCalls > 1) + + selectSessionDeferred.resolve() + await Promise.all([firstIdleEvent, secondIdleEvent]) + + // then - second idle should not be skipped during reset transition + expect(createSessionCalls.length).toBe(2) + expect(promptCalls.length).toBe(2) + expect(hook.getState()?.iteration).toBe(3) + }) +})