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 <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -1291,6 +1291,51 @@ Original task: Build something`
|
|||||||
expect(verificationToast!.message).toMatch(/Oracle verification is now required/)
|
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. <promise>DONE</promise>" }],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// 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 () => {
|
test("should show regular completion toast when ultrawork disabled", async () => {
|
||||||
// given - hook without ultrawork
|
// given - hook without ultrawork
|
||||||
const transcriptPath = join(TEST_DIR, "transcript.jsonl")
|
const transcriptPath = join(TEST_DIR, "transcript.jsonl")
|
||||||
|
|||||||
@@ -104,11 +104,23 @@ export function createLoopStateController(options: {
|
|||||||
return state
|
return state
|
||||||
},
|
},
|
||||||
|
|
||||||
setMessageCountAtStart(sessionID: string, messageCountAtStart: number): RalphLoopState | null {
|
setMessageCountAtStart(
|
||||||
|
sessionID: string,
|
||||||
|
messageCountAtStart: number,
|
||||||
|
expectedStartedAt?: string,
|
||||||
|
): RalphLoopState | null {
|
||||||
const state = readState(directory, stateDir)
|
const state = readState(directory, stateDir)
|
||||||
if (!state || state.session_id !== sessionID) {
|
if (!state || state.session_id !== sessionID) {
|
||||||
return null
|
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
|
state.message_count_at_start = messageCountAtStart
|
||||||
if (!writeState(directory, state, stateDir)) {
|
if (!writeState(directory, state, stateDir)) {
|
||||||
|
|||||||
@@ -73,6 +73,11 @@ export function createRalphLoopHook(
|
|||||||
return startSuccess
|
return startSuccess
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const startedState = loopState.getState()
|
||||||
|
const expectedStartedAt = startedState?.session_id === sessionID
|
||||||
|
? startedState.started_at
|
||||||
|
: undefined
|
||||||
|
|
||||||
ctx.client.session
|
ctx.client.session
|
||||||
.messages({
|
.messages({
|
||||||
path: { id: sessionID },
|
path: { id: sessionID },
|
||||||
@@ -80,7 +85,7 @@ export function createRalphLoopHook(
|
|||||||
})
|
})
|
||||||
.then((messagesResponse: unknown) => {
|
.then((messagesResponse: unknown) => {
|
||||||
const messageCountAtStart = getMessageCountFromResponse(messagesResponse)
|
const messageCountAtStart = getMessageCountFromResponse(messagesResponse)
|
||||||
loopState.setMessageCountAtStart(sessionID, messageCountAtStart)
|
loopState.setMessageCountAtStart(sessionID, messageCountAtStart, expectedStartedAt)
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user