fix(ralph-loop): settle idle before continuation
This commit is contained in:
@@ -304,13 +304,35 @@ describe("ralph-loop", () => {
|
|||||||
expect(state?.iteration).toBe(2)
|
expect(state?.iteration).toBe(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("should settle idle before injecting continuation", async () => {
|
||||||
|
// given - active loop state with a configured idle settle delay
|
||||||
|
const hook = createRalphLoopHook(createMockPluginInput(), { idleSettleMs: 25 })
|
||||||
|
hook.startLoop("session-123", "Build a feature", { maxIterations: 10 })
|
||||||
|
|
||||||
|
// when - session goes idle
|
||||||
|
const eventPromise = hook.event({
|
||||||
|
event: {
|
||||||
|
type: "session.idle",
|
||||||
|
properties: { sessionID: "session-123" },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await Promise.resolve()
|
||||||
|
|
||||||
|
// then - continuation should not be injected in the same event-loop turn
|
||||||
|
expect(promptCalls.length).toBe(0)
|
||||||
|
|
||||||
|
await eventPromise
|
||||||
|
expect(promptCalls.length).toBe(1)
|
||||||
|
expect(promptCalls[0].sessionID).toBe("session-123")
|
||||||
|
})
|
||||||
|
|
||||||
test("#given hanging toast #when session idles #then continuation still injects", async () => {
|
test("#given hanging toast #when session idles #then continuation still injects", async () => {
|
||||||
// given - TUI toast never settles
|
// given - TUI toast never settles
|
||||||
const ctx = createMockPluginInput()
|
const ctx = createMockPluginInput()
|
||||||
ctx.client.tui = {
|
ctx.client.tui = {
|
||||||
showToast: () => new Promise(() => {}),
|
showToast: () => new Promise(() => {}),
|
||||||
} as never
|
} as never
|
||||||
const hook = createRalphLoopHook(ctx)
|
const hook = createRalphLoopHook(ctx, { idleSettleMs: 0 })
|
||||||
hook.startLoop("session-123", "Build a feature", { maxIterations: 10 })
|
hook.startLoop("session-123", "Build a feature", { maxIterations: 10 })
|
||||||
|
|
||||||
// when - session goes idle
|
// when - session goes idle
|
||||||
@@ -359,7 +381,7 @@ describe("ralph-loop", () => {
|
|||||||
|
|
||||||
test("should stop loop when max iterations reached", async () => {
|
test("should stop loop when max iterations reached", async () => {
|
||||||
// given - loop at max iteration
|
// given - loop at max iteration
|
||||||
const hook = createRalphLoopHook(createMockPluginInput())
|
const hook = createRalphLoopHook(createMockPluginInput(), { idleSettleMs: 0 })
|
||||||
hook.startLoop("session-123", "Build something", { maxIterations: 2 })
|
hook.startLoop("session-123", "Build something", { maxIterations: 2 })
|
||||||
|
|
||||||
const state = hook.getState()!
|
const state = hook.getState()!
|
||||||
|
|||||||
@@ -20,7 +20,11 @@ type LoopStateController = {
|
|||||||
setVerificationSessionID: (sessionID: string, verificationSessionID: string) => RalphLoopState | null
|
setVerificationSessionID: (sessionID: string, verificationSessionID: string) => RalphLoopState | null
|
||||||
restartAfterFailedVerification: (sessionID: string, messageCountAtStart?: number) => RalphLoopState | null
|
restartAfterFailedVerification: (sessionID: string, messageCountAtStart?: number) => RalphLoopState | null
|
||||||
}
|
}
|
||||||
type RalphLoopEventHandlerOptions = { directory: string; apiTimeoutMs: number; getTranscriptPath: (sessionID: string) => string | undefined; checkSessionExists?: RalphLoopOptions["checkSessionExists"]; backgroundManager?: RalphLoopOptions["backgroundManager"]; loopState: LoopStateController }
|
type RalphLoopEventHandlerOptions = { directory: string; apiTimeoutMs: number; idleSettleMs: number; getTranscriptPath: (sessionID: string) => string | undefined; checkSessionExists?: RalphLoopOptions["checkSessionExists"]; backgroundManager?: RalphLoopOptions["backgroundManager"]; loopState: LoopStateController }
|
||||||
|
|
||||||
|
function sleep(ms: number): Promise<void> {
|
||||||
|
return ms > 0 ? new Promise((resolve) => setTimeout(resolve, ms)) : Promise.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
function hasRunningBackgroundTasks(
|
function hasRunningBackgroundTasks(
|
||||||
backgroundManager: RalphLoopOptions["backgroundManager"],
|
backgroundManager: RalphLoopOptions["backgroundManager"],
|
||||||
@@ -281,6 +285,7 @@ export function createRalphLoopEventHandler(
|
|||||||
})
|
})
|
||||||
|
|
||||||
showIterationToast(ctx, newState)
|
showIterationToast(ctx, newState)
|
||||||
|
await sleep(options.idleSettleMs)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await continueIteration(ctx, newState, {
|
await continueIteration(ctx, newState, {
|
||||||
@@ -383,6 +388,7 @@ export function createRalphLoopEventHandler(
|
|||||||
}
|
}
|
||||||
|
|
||||||
showIterationToast(ctx, newState)
|
showIterationToast(ctx, newState)
|
||||||
|
await sleep(options.idleSettleMs)
|
||||||
try {
|
try {
|
||||||
await continueIteration(ctx, newState, {
|
await continueIteration(ctx, newState, {
|
||||||
previousSessionID: sessionID,
|
previousSessionID: sessionID,
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export interface RalphLoopHook {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_API_TIMEOUT = 5000 as const
|
const DEFAULT_API_TIMEOUT = 5000 as const
|
||||||
|
const DEFAULT_IDLE_SETTLE_MS = 150 as const
|
||||||
|
|
||||||
function getMessageCountFromResponse(messagesResponse: unknown): number {
|
function getMessageCountFromResponse(messagesResponse: unknown): number {
|
||||||
if (Array.isArray(messagesResponse)) {
|
if (Array.isArray(messagesResponse)) {
|
||||||
@@ -44,6 +45,7 @@ export function createRalphLoopHook(
|
|||||||
const stateDir = config?.state_dir
|
const stateDir = config?.state_dir
|
||||||
const getTranscriptPath = options?.getTranscriptPath ?? getDefaultTranscriptPath
|
const getTranscriptPath = options?.getTranscriptPath ?? getDefaultTranscriptPath
|
||||||
const apiTimeout = options?.apiTimeout ?? DEFAULT_API_TIMEOUT
|
const apiTimeout = options?.apiTimeout ?? DEFAULT_API_TIMEOUT
|
||||||
|
const idleSettleMs = options?.idleSettleMs ?? DEFAULT_IDLE_SETTLE_MS
|
||||||
const checkSessionExists = options?.checkSessionExists
|
const checkSessionExists = options?.checkSessionExists
|
||||||
const backgroundManager = options?.backgroundManager
|
const backgroundManager = options?.backgroundManager
|
||||||
|
|
||||||
@@ -56,6 +58,7 @@ export function createRalphLoopHook(
|
|||||||
const event = createRalphLoopEventHandler(ctx, {
|
const event = createRalphLoopEventHandler(ctx, {
|
||||||
directory: ctx.directory,
|
directory: ctx.directory,
|
||||||
apiTimeoutMs: apiTimeout,
|
apiTimeoutMs: apiTimeout,
|
||||||
|
idleSettleMs,
|
||||||
getTranscriptPath,
|
getTranscriptPath,
|
||||||
checkSessionExists,
|
checkSessionExists,
|
||||||
backgroundManager,
|
backgroundManager,
|
||||||
|
|||||||
@@ -43,49 +43,52 @@ describe("ralph-loop reset strategy race condition", () => {
|
|||||||
let selectSessionCalls = 0
|
let selectSessionCalls = 0
|
||||||
const selectSessionDeferred = createDeferred()
|
const selectSessionDeferred = createDeferred()
|
||||||
|
|
||||||
const hook = createRalphLoopHook({
|
const hook = createRalphLoopHook(
|
||||||
directory: process.cwd(),
|
{
|
||||||
client: {
|
directory: process.cwd(),
|
||||||
session: {
|
client: {
|
||||||
prompt: async (options: {
|
session: {
|
||||||
path: { id: string }
|
prompt: async (options: {
|
||||||
body: { parts: Array<{ type: string; text: string }> }
|
path: { id: string }
|
||||||
}) => {
|
body: { parts: Array<{ type: string; text: string }> }
|
||||||
promptCalls.push({
|
}) => {
|
||||||
sessionID: options.path.id,
|
promptCalls.push({
|
||||||
text: options.body.parts[0].text,
|
sessionID: options.path.id,
|
||||||
})
|
text: options.body.parts[0].text,
|
||||||
return {}
|
})
|
||||||
|
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: [] }),
|
||||||
},
|
},
|
||||||
promptAsync: async (options: {
|
tui: {
|
||||||
path: { id: string }
|
showToast: async () => ({}),
|
||||||
body: { parts: Array<{ type: string; text: string }> }
|
selectSession: async () => {
|
||||||
}) => {
|
selectSessionCalls += 1
|
||||||
promptCalls.push({
|
await selectSessionDeferred.promise
|
||||||
sessionID: options.path.id,
|
return {}
|
||||||
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 unknown as Parameters<typeof createRalphLoopHook>[0],
|
||||||
} as unknown as Parameters<typeof createRalphLoopHook>[0])
|
{ idleSettleMs: 0 },
|
||||||
|
)
|
||||||
|
|
||||||
hook.startLoop("session-old", "Build feature", { strategy: "reset" })
|
hook.startLoop("session-old", "Build feature", { strategy: "reset" })
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export interface RalphLoopOptions {
|
|||||||
config?: RalphLoopConfig
|
config?: RalphLoopConfig
|
||||||
getTranscriptPath?: (sessionId: string) => string
|
getTranscriptPath?: (sessionId: string) => string
|
||||||
apiTimeout?: number
|
apiTimeout?: number
|
||||||
|
idleSettleMs?: number
|
||||||
checkSessionExists?: (sessionId: string) => Promise<boolean>
|
checkSessionExists?: (sessionId: string) => Promise<boolean>
|
||||||
backgroundManager?: { getTasksByParentSession: (sessionId: string) => Array<{ status: string }> }
|
backgroundManager?: { getTasksByParentSession: (sessionId: string) => Array<{ status: string }> }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user