From 61ba4e3b419b26e55026752b519520be59a909be Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 13 May 2026 18:59:07 +0900 Subject: [PATCH] fix(ralph-loop): guard delayed start snapshots Prevent delayed loop-start message counts from overwriting active Ralph Loop state after the loop has already advanced, so ULW completion can still enter Oracle verification instead of iterating forever. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/hooks/ralph-loop/index.test.ts | 45 +++++++++++++++++++ src/hooks/ralph-loop/loop-state-controller.ts | 14 +++++- src/hooks/ralph-loop/ralph-loop-hook.ts | 7 ++- 3 files changed, 64 insertions(+), 2 deletions(-) diff --git a/src/hooks/ralph-loop/index.test.ts b/src/hooks/ralph-loop/index.test.ts index d131744b7..b083502aa 100644 --- a/src/hooks/ralph-loop/index.test.ts +++ b/src/hooks/ralph-loop/index.test.ts @@ -1291,6 +1291,51 @@ Original task: Build something` expect(verificationToast!.message).toMatch(/Oracle verification is now required/) }) + test("#given loop-start message count resolves late after progress #when ulw DONE appears #then oracle verification still starts", async () => { + // given - the initial message-count request is delayed past the first continuation + let messageCallCount = 0 + let resolveInitialMessages: ((value: { data: typeof mockSessionMessages }) => void) | undefined + const delayedMock = createMockPluginInput() + Object.defineProperty(delayedMock.client.session, "messages", { + value: async (opts: { path: { id: string } }) => { + messagesCalls.push({ sessionID: opts.path.id }) + messageCallCount += 1 + if (messageCallCount === 1) { + return new Promise<{ data: typeof mockSessionMessages }>((resolve) => { + resolveInitialMessages = resolve + }) + } + + return { data: mockSessionMessages } + }, + }) + const hook = createRalphLoopHook(delayedMock, { + getTranscriptPath: () => join(TEST_DIR, "missing-transcript.jsonl"), + idleSettleMs: 0, + }) + hook.startLoop("session-123", "Build API", { ultrawork: true }) + + await hook.event({ event: { type: "session.idle", properties: { sessionID: "session-123" } } }) + expect(hook.getState()?.iteration).toBe(2) + + mockSessionMessages = [ + { + info: { role: "assistant" }, + parts: [{ type: "text", text: "All work is complete. DONE" }], + }, + ] + + // when - delayed start snapshot resolves after the loop has already advanced + resolveInitialMessages?.({ data: mockSessionMessages }) + await new Promise((resolve) => setTimeout(resolve, 0)) + await hook.event({ event: { type: "session.idle", properties: { sessionID: "session-123" } } }) + + // then - the late snapshot must not hide the DONE message from verification gating + expect(hook.getState()?.verification_pending).toBe(true) + expect(hook.getState()?.completion_promise).toBe("VERIFIED") + expect(promptCalls[promptCalls.length - 1]?.text).toContain('task(subagent_type="oracle"') + }) + test("should show regular completion toast when ultrawork disabled", async () => { // given - hook without ultrawork const transcriptPath = join(TEST_DIR, "transcript.jsonl") diff --git a/src/hooks/ralph-loop/loop-state-controller.ts b/src/hooks/ralph-loop/loop-state-controller.ts index 3679a3dab..7ff1ea01d 100644 --- a/src/hooks/ralph-loop/loop-state-controller.ts +++ b/src/hooks/ralph-loop/loop-state-controller.ts @@ -104,11 +104,23 @@ export function createLoopStateController(options: { return state }, - setMessageCountAtStart(sessionID: string, messageCountAtStart: number): RalphLoopState | null { + setMessageCountAtStart( + sessionID: string, + messageCountAtStart: number, + expectedStartedAt?: string, + ): RalphLoopState | null { const state = readState(directory, stateDir) if (!state || state.session_id !== sessionID) { return null } + if ( + state.iteration !== 1 + || state.verification_pending + || state.message_count_at_start !== undefined + || (expectedStartedAt !== undefined && state.started_at !== expectedStartedAt) + ) { + return null + } state.message_count_at_start = messageCountAtStart if (!writeState(directory, state, stateDir)) { diff --git a/src/hooks/ralph-loop/ralph-loop-hook.ts b/src/hooks/ralph-loop/ralph-loop-hook.ts index e03c9d730..9cadd3434 100644 --- a/src/hooks/ralph-loop/ralph-loop-hook.ts +++ b/src/hooks/ralph-loop/ralph-loop-hook.ts @@ -73,6 +73,11 @@ export function createRalphLoopHook( return startSuccess } + const startedState = loopState.getState() + const expectedStartedAt = startedState?.session_id === sessionID + ? startedState.started_at + : undefined + ctx.client.session .messages({ path: { id: sessionID }, @@ -80,7 +85,7 @@ export function createRalphLoopHook( }) .then((messagesResponse: unknown) => { const messageCountAtStart = getMessageCountFromResponse(messagesResponse) - loopState.setMessageCountAtStart(sessionID, messageCountAtStart) + loopState.setMessageCountAtStart(sessionID, messageCountAtStart, expectedStartedAt) }) .catch(() => {})