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)
+ })
+})