fix(ralph-loop): ignore synthetic idle replays
This commit is contained in:
@@ -58,6 +58,29 @@ describe("detectCompletionInSessionMessages", () => {
|
|||||||
// #then
|
// #then
|
||||||
expect(detected).toBe(true)
|
expect(detected).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("#when sinceMessageIndex equals current message count #then should NOT rescan old DONE", async () => {
|
||||||
|
// #given
|
||||||
|
const messages = [
|
||||||
|
{
|
||||||
|
info: { role: "assistant" },
|
||||||
|
parts: [{ type: "text", text: "Old completion <promise>DONE</promise>" }],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const ctx = createPluginInput(messages)
|
||||||
|
|
||||||
|
// #when
|
||||||
|
const detected = await detectCompletionInSessionMessages(ctx, {
|
||||||
|
sessionID: "session-123",
|
||||||
|
promise: "DONE",
|
||||||
|
apiTimeoutMs: 1000,
|
||||||
|
directory: "/tmp",
|
||||||
|
sinceMessageIndex: messages.length,
|
||||||
|
})
|
||||||
|
|
||||||
|
// #then
|
||||||
|
expect(detected).toBe(false)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("#given no sinceMessageIndex (backward compat)", () => {
|
describe("#given no sinceMessageIndex (backward compat)", () => {
|
||||||
|
|||||||
@@ -130,8 +130,8 @@ export async function detectCompletionInSessionMessages(
|
|||||||
: []
|
: []
|
||||||
|
|
||||||
const scopedMessages =
|
const scopedMessages =
|
||||||
typeof options.sinceMessageIndex === "number" && options.sinceMessageIndex >= 0 && options.sinceMessageIndex < messageArray.length
|
typeof options.sinceMessageIndex === "number" && options.sinceMessageIndex >= 0
|
||||||
? messageArray.slice(options.sinceMessageIndex)
|
? messageArray.slice(Math.min(options.sinceMessageIndex, messageArray.length))
|
||||||
: messageArray
|
: messageArray
|
||||||
|
|
||||||
const assistantMessages = (scopedMessages as OpenCodeSessionMessage[]).filter((msg) => msg.info?.role === "assistant")
|
const assistantMessages = (scopedMessages as OpenCodeSessionMessage[]).filter((msg) => msg.info?.role === "assistant")
|
||||||
|
|||||||
@@ -288,7 +288,7 @@ describe("ralph-loop", () => {
|
|||||||
await hook.event({
|
await hook.event({
|
||||||
event: {
|
event: {
|
||||||
type: "session.idle",
|
type: "session.idle",
|
||||||
properties: { sessionID: "session-123" },
|
properties: { sessionID: "session-123", synthetic: true },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -304,6 +304,62 @@ describe("ralph-loop", () => {
|
|||||||
expect(state?.iteration).toBe(2)
|
expect(state?.iteration).toBe(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("#given synthetic and real idle arrive back-to-back #then only one continuation is injected for the same iteration", async () => {
|
||||||
|
// given
|
||||||
|
const hook = createRalphLoopHook(createMockPluginInput(), { idleSettleMs: 0 })
|
||||||
|
hook.startLoop("session-123", "Build a feature", { maxIterations: 10 })
|
||||||
|
|
||||||
|
// when
|
||||||
|
await hook.event({
|
||||||
|
event: {
|
||||||
|
type: "session.idle",
|
||||||
|
properties: { sessionID: "session-123", synthetic: true },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await hook.event({
|
||||||
|
event: {
|
||||||
|
type: "session.idle",
|
||||||
|
properties: { sessionID: "session-123" },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(promptCalls.length).toBe(1)
|
||||||
|
expect(promptCalls[0].sessionID).toBe("session-123")
|
||||||
|
expect(hook.getState()?.iteration).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("#given new activity after an idle continuation #when session idles again #then next iteration can continue", async () => {
|
||||||
|
// given
|
||||||
|
const hook = createRalphLoopHook(createMockPluginInput(), { idleSettleMs: 0 })
|
||||||
|
hook.startLoop("session-123", "Build a feature", { maxIterations: 10 })
|
||||||
|
|
||||||
|
await hook.event({
|
||||||
|
event: {
|
||||||
|
type: "session.idle",
|
||||||
|
properties: { sessionID: "session-123" },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// when
|
||||||
|
await hook.event({
|
||||||
|
event: {
|
||||||
|
type: "message.part.updated",
|
||||||
|
properties: { sessionID: "session-123" },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await hook.event({
|
||||||
|
event: {
|
||||||
|
type: "session.idle",
|
||||||
|
properties: { sessionID: "session-123" },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(promptCalls.length).toBe(2)
|
||||||
|
expect(hook.getState()?.iteration).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
test("should inject continuation when idle event carries session id in info", async () => {
|
test("should inject continuation when idle event carries session id in info", async () => {
|
||||||
// given - active loop state and nested session event shape
|
// given - active loop state and nested session event shape
|
||||||
const hook = createRalphLoopHook(createMockPluginInput())
|
const hook = createRalphLoopHook(createMockPluginInput())
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import { continueIteration } from "./iteration-continuation"
|
|||||||
import { handlePendingVerification } from "./pending-verification-handler"
|
import { handlePendingVerification } from "./pending-verification-handler"
|
||||||
import { handleDeletedLoopSession, handleErroredLoopSession } from "./session-event-handler"
|
import { handleDeletedLoopSession, handleErroredLoopSession } from "./session-event-handler"
|
||||||
|
|
||||||
|
const RAPID_IDLE_DEDUP_MS = 500
|
||||||
|
|
||||||
type LoopStateController = {
|
type LoopStateController = {
|
||||||
getState: () => RalphLoopState | null
|
getState: () => RalphLoopState | null
|
||||||
clear: () => boolean
|
clear: () => boolean
|
||||||
@@ -62,6 +64,10 @@ function getRuntimeRetryActivitySessionID(
|
|||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isSyntheticIdle(props: Record<string, unknown> | undefined): boolean {
|
||||||
|
return props?.synthetic === true
|
||||||
|
}
|
||||||
|
|
||||||
function isAbortError(error: unknown): boolean {
|
function isAbortError(error: unknown): boolean {
|
||||||
return typeof error === "object"
|
return typeof error === "object"
|
||||||
&& error !== null
|
&& error !== null
|
||||||
@@ -183,17 +189,20 @@ export function createRalphLoopEventHandler(
|
|||||||
) {
|
) {
|
||||||
const inFlightSessions = new Set<string>()
|
const inFlightSessions = new Set<string>()
|
||||||
const runtimeErrorRetriedSessions = new Map<string, number>()
|
const runtimeErrorRetriedSessions = new Map<string, number>()
|
||||||
|
const recentHandledSyntheticIdleAt = new Map<string, number>()
|
||||||
|
|
||||||
return async ({ event }: { event: { type: string; properties?: unknown } }): Promise<void> => {
|
return async ({ event }: { event: { type: string; properties?: unknown } }): Promise<void> => {
|
||||||
const props = event.properties as Record<string, unknown> | undefined
|
const props = event.properties as Record<string, unknown> | undefined
|
||||||
const runtimeRetryActivitySessionID = getRuntimeRetryActivitySessionID(event.type, props)
|
const runtimeRetryActivitySessionID = getRuntimeRetryActivitySessionID(event.type, props)
|
||||||
if (runtimeRetryActivitySessionID) {
|
if (runtimeRetryActivitySessionID) {
|
||||||
runtimeErrorRetriedSessions.delete(runtimeRetryActivitySessionID)
|
runtimeErrorRetriedSessions.delete(runtimeRetryActivitySessionID)
|
||||||
|
recentHandledSyntheticIdleAt.delete(runtimeRetryActivitySessionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.type === "session.idle") {
|
if (event.type === "session.idle") {
|
||||||
const sessionID = resolveSessionEventID(props)
|
const sessionID = resolveSessionEventID(props)
|
||||||
if (!sessionID) return
|
if (!sessionID) return
|
||||||
|
const syntheticIdle = isSyntheticIdle(props)
|
||||||
|
|
||||||
if (inFlightSessions.has(sessionID)) {
|
if (inFlightSessions.has(sessionID)) {
|
||||||
log(`[${HOOK_NAME}] Skipped: handler in flight`, { sessionID })
|
log(`[${HOOK_NAME}] Skipped: handler in flight`, { sessionID })
|
||||||
@@ -241,6 +250,17 @@ export function createRalphLoopEventHandler(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const lastHandledSyntheticIdleAt = recentHandledSyntheticIdleAt.get(sessionID)
|
||||||
|
const now = Date.now()
|
||||||
|
if (!syntheticIdle && lastHandledSyntheticIdleAt !== undefined && now - lastHandledSyntheticIdleAt < RAPID_IDLE_DEDUP_MS) {
|
||||||
|
recentHandledSyntheticIdleAt.delete(sessionID)
|
||||||
|
log(`[${HOOK_NAME}] Skipped: duplicate real idle after synthetic idle`, { sessionID })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (syntheticIdle) {
|
||||||
|
recentHandledSyntheticIdleAt.set(sessionID, now)
|
||||||
|
}
|
||||||
|
|
||||||
if (await handleCompletionIfDetected(ctx, options, {
|
if (await handleCompletionIfDetected(ctx, options, {
|
||||||
sessionID,
|
sessionID,
|
||||||
state,
|
state,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ describe("normalizeSessionStatusToIdle", () => {
|
|||||||
type: "session.idle",
|
type: "session.idle",
|
||||||
properties: {
|
properties: {
|
||||||
sessionID: "ses_abc123",
|
sessionID: "ses_abc123",
|
||||||
|
synthetic: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export function normalizeSessionStatusToIdle(input: EventInput): EventInput | nu
|
|||||||
return {
|
return {
|
||||||
event: {
|
event: {
|
||||||
type: "session.idle",
|
type: "session.idle",
|
||||||
properties: { sessionID },
|
properties: { sessionID, synthetic: true },
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user