diff --git a/src/create-hooks.ts b/src/create-hooks.ts index 436f8e2b9..510f702ca 100644 --- a/src/create-hooks.ts +++ b/src/create-hooks.ts @@ -59,6 +59,7 @@ export function createHooks(args: { ctx, pluginConfig, modelCacheState, + backgroundManager, modelFallbackControllerAccessor, isHookEnabled, safeHookEnabled, diff --git a/src/hooks/ralph-loop/index.test.ts b/src/hooks/ralph-loop/index.test.ts index 9755e8e65..fa7d0032c 100644 --- a/src/hooks/ralph-loop/index.test.ts +++ b/src/hooks/ralph-loop/index.test.ts @@ -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[0] { return { client: { session: { @@ -63,7 +63,7 @@ describe("ralph-loop", () => { }, }, directory: TEST_DIR, - } as unknown as Parameters[0] + } as Parameters[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, }) diff --git a/src/hooks/ralph-loop/ralph-loop-event-handler.ts b/src/hooks/ralph-loop/ralph-loop-event-handler.ts index 0093e890a..73aa73c43 100644 --- a/src/hooks/ralph-loop/ralph-loop-event-handler.ts +++ b/src/hooks/ralph-loop/ralph-loop-event-handler.ts @@ -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 diff --git a/src/hooks/ralph-loop/ralph-loop-hook.ts b/src/hooks/ralph-loop/ralph-loop-hook.ts index 9e0ee3d04..9c3293a2e 100644 --- a/src/hooks/ralph-loop/ralph-loop-hook.ts +++ b/src/hooks/ralph-loop/ralph-loop-hook.ts @@ -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, }) diff --git a/src/hooks/ralph-loop/types.ts b/src/hooks/ralph-loop/types.ts index 0c19a1f9b..4c8470707 100644 --- a/src/hooks/ralph-loop/types.ts +++ b/src/hooks/ralph-loop/types.ts @@ -22,4 +22,5 @@ export interface RalphLoopOptions { getTranscriptPath?: (sessionId: string) => string apiTimeout?: number checkSessionExists?: (sessionId: string) => Promise + backgroundManager?: { getTasksByParentSession: (sessionId: string) => Array<{ status: string }> } } diff --git a/src/plugin/hooks/create-core-hooks.ts b/src/plugin/hooks/create-core-hooks.ts index 5a36aa026..4b3f6b0fb 100644 --- a/src/plugin/hooks/create-core-hooks.ts +++ b/src/plugin/hooks/create-core-hooks.ts @@ -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, diff --git a/src/plugin/hooks/create-session-hooks.ts b/src/plugin/hooks/create-session-hooks.ts index 9d437bc75..ae2d7bb11 100644 --- a/src/plugin/hooks/create-session-hooks.ts +++ b/src/plugin/hooks/create-session-hooks.ts @@ -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 = (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