Merge pull request #3672 from code-yeongyu/fix/ralph-loop-bg-task-guard

fix(ralph-loop): skip continuation when background tasks are pending (fixes #3526)
This commit is contained in:
YeonGyu-Kim
2026-04-27 18:09:29 +09:00
committed by GitHub
7 changed files with 58 additions and 18 deletions
+1
View File
@@ -59,6 +59,7 @@ export function createHooks(args: {
ctx,
pluginConfig,
modelCacheState,
backgroundManager,
modelFallbackControllerAccessor,
isHookEnabled,
safeHookEnabled,
+36 -15
View File
@@ -17,7 +17,7 @@ describe("ralph-loop", () => {
let mockSessionMessages: Array<{ info?: { role?: string }; parts?: Array<{ type: string; text?: string }> }>
let mockMessagesApiResponseShape: "data" | "array"
function createMockPluginInput() {
function createMockPluginInput(): Parameters<typeof createRalphLoopHook>[0] {
return {
client: {
session: {
@@ -63,7 +63,7 @@ describe("ralph-loop", () => {
},
},
directory: TEST_DIR,
} as unknown as Parameters<typeof createRalphLoopHook>[0]
} as Parameters<typeof createRalphLoopHook>[0]
}
beforeEach(() => {
@@ -304,6 +304,33 @@ describe("ralph-loop", () => {
expect(state?.iteration).toBe(2)
})
test("should skip continuation when background task is running", async () => {
// given - active loop state with a running background task
const hook = createRalphLoopHook(createMockPluginInput(), {
backgroundManager: {
getTasksByParentSession: (sessionID: string) => sessionID === "session-123"
? [{ status: "running" }]
: [],
},
})
hook.startLoop("session-123", "Build a feature", { maxIterations: 10 })
// when - session goes idle
await hook.event({
event: {
type: "session.idle",
properties: { sessionID: "session-123" },
},
})
// then - no continuation should be injected
expect(promptCalls.length).toBe(0)
// then - iteration should not be incremented
const state = hook.getState()
expect(state?.iteration).toBe(1)
})
test("should stop loop when max iterations reached", async () => {
// given - loop at max iteration
const hook = createRalphLoopHook(createMockPluginInput())
@@ -1144,20 +1171,14 @@ Original task: Build something`
test("should not hang when session.messages() throws", async () => {
// given - API that throws (simulates timeout error)
let apiCallCount = 0
const errorMock = {
...createMockPluginInput(),
client: {
...createMockPluginInput().client,
session: {
...createMockPluginInput().client.session,
messages: async () => {
apiCallCount++
throw new Error("API timeout")
},
},
const errorMock = createMockPluginInput()
Object.defineProperty(errorMock.client.session, "messages", {
value: async () => {
apiCallCount++
throw new Error("API timeout")
},
}
const hook = createRalphLoopHook(errorMock as any, {
})
const hook = createRalphLoopHook(errorMock, {
getTranscriptPath: () => join(TEST_DIR, "nonexistent.jsonl"),
apiTimeout: 100,
})
@@ -25,7 +25,7 @@ type LoopStateController = {
setVerificationSessionID: (sessionID: string, verificationSessionID: string) => RalphLoopState | null
restartAfterFailedVerification: (sessionID: string, messageCountAtStart?: number) => RalphLoopState | null
}
type RalphLoopEventHandlerOptions = { directory: string; apiTimeoutMs: number; getTranscriptPath: (sessionID: string) => string | undefined; checkSessionExists?: RalphLoopOptions["checkSessionExists"]; sessionRecovery: SessionRecovery; loopState: LoopStateController }
type RalphLoopEventHandlerOptions = { directory: string; apiTimeoutMs: number; getTranscriptPath: (sessionID: string) => string | undefined; checkSessionExists?: RalphLoopOptions["checkSessionExists"]; backgroundManager?: RalphLoopOptions["backgroundManager"]; sessionRecovery: SessionRecovery; loopState: LoopStateController }
export function createRalphLoopEventHandler(
ctx: PluginInput,
@@ -59,6 +59,15 @@ export function createRalphLoopEventHandler(
return
}
const hasRunningBackgroundTasks = options.backgroundManager
? options.backgroundManager.getTasksByParentSession(sessionID).some((task: { status: string }) => task.status === "running")
: false
if (hasRunningBackgroundTasks) {
log(`[${HOOK_NAME}] Skipped: background tasks running`, { sessionID })
return
}
const verificationSessionID = state.verification_pending
? state.verification_session_id
: undefined
+2
View File
@@ -46,6 +46,7 @@ export function createRalphLoopHook(
const getTranscriptPath = options?.getTranscriptPath ?? getDefaultTranscriptPath
const apiTimeout = options?.apiTimeout ?? DEFAULT_API_TIMEOUT
const checkSessionExists = options?.checkSessionExists
const backgroundManager = options?.backgroundManager
const loopState = createLoopStateController({
directory: ctx.directory,
@@ -59,6 +60,7 @@ export function createRalphLoopHook(
apiTimeoutMs: apiTimeout,
getTranscriptPath,
checkSessionExists,
backgroundManager,
sessionRecovery,
loopState,
})
+1
View File
@@ -22,4 +22,5 @@ export interface RalphLoopOptions {
getTranscriptPath?: (sessionId: string) => string
apiTimeout?: number
checkSessionExists?: (sessionId: string) => Promise<boolean>
backgroundManager?: { getTasksByParentSession: (sessionId: string) => Array<{ status: string }> }
}
+4 -1
View File
@@ -1,4 +1,5 @@
import type { HookName, OhMyOpenCodeConfig } from "../../config"
import type { BackgroundManager } from "../../features/background-agent"
import type { ModelFallbackControllerAccessor } from "../../hooks/model-fallback"
import type { PluginContext } from "../types"
import type { ModelCacheState } from "../../plugin-state"
@@ -11,16 +12,18 @@ export function createCoreHooks(args: {
ctx: PluginContext
pluginConfig: OhMyOpenCodeConfig
modelCacheState: ModelCacheState
backgroundManager: BackgroundManager
modelFallbackControllerAccessor?: ModelFallbackControllerAccessor
isHookEnabled: (hookName: HookName) => boolean
safeHookEnabled: boolean
}) {
const { ctx, pluginConfig, modelCacheState, modelFallbackControllerAccessor, isHookEnabled, safeHookEnabled } = args
const { ctx, pluginConfig, modelCacheState, backgroundManager, modelFallbackControllerAccessor, isHookEnabled, safeHookEnabled } = args
const session = createSessionHooks({
ctx,
pluginConfig,
modelCacheState,
backgroundManager,
modelFallbackControllerAccessor,
isHookEnabled,
safeHookEnabled,
+4 -1
View File
@@ -1,4 +1,5 @@
import type { OhMyOpenCodeConfig, HookName } from "../../config"
import type { BackgroundManager } from "../../features/background-agent"
import type { ModelFallbackControllerAccessor } from "../../hooks/model-fallback"
import type { ModelCacheState } from "../../plugin-state"
import type { PluginContext } from "../types"
@@ -70,11 +71,12 @@ export function createSessionHooks(args: {
ctx: PluginContext
pluginConfig: OhMyOpenCodeConfig
modelCacheState: ModelCacheState
backgroundManager: BackgroundManager
modelFallbackControllerAccessor?: ModelFallbackControllerAccessor
isHookEnabled: (hookName: HookName) => boolean
safeHookEnabled: boolean
}): SessionHooks {
const { ctx, pluginConfig, modelCacheState, modelFallbackControllerAccessor, isHookEnabled, safeHookEnabled } = args
const { ctx, pluginConfig, modelCacheState, backgroundManager, modelFallbackControllerAccessor, isHookEnabled, safeHookEnabled } = args
const safeHook = <T>(hookName: HookName, factory: () => T): T | null =>
safeCreateHook(hookName, factory, { enabled: safeHookEnabled })
@@ -211,6 +213,7 @@ export function createSessionHooks(args: {
createRalphLoopHook(ctx, {
config: pluginConfig.ralph_loop,
checkSessionExists: async (sessionId) => await sessionExists(sessionId),
backgroundManager,
}))
: null