diff --git a/src/hooks/sisyphus-orchestrator/index.test.ts b/src/hooks/sisyphus-orchestrator/index.test.ts index fda298c9..3863200a 100644 --- a/src/hooks/sisyphus-orchestrator/index.test.ts +++ b/src/hooks/sisyphus-orchestrator/index.test.ts @@ -862,6 +862,46 @@ describe("sisyphus-orchestrator hook", () => { expect(mockInput._promptMock).not.toHaveBeenCalled() }) + test("should debounce rapid continuation injections (prevent infinite loop)", async () => { + // #given - boulder state with incomplete plan + const planPath = join(TEST_DIR, "test-plan.md") + writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [ ] Task 2") + + const state: BoulderState = { + active_plan: planPath, + started_at: "2026-01-02T10:00:00Z", + session_ids: [MAIN_SESSION_ID], + plan_name: "test-plan", + } + writeBoulderState(TEST_DIR, state) + + const mockInput = createMockPluginInput() + const hook = createSisyphusOrchestratorHook(mockInput) + + // #when - fire multiple idle events in rapid succession (simulating infinite loop bug) + await hook.handler({ + event: { + type: "session.idle", + properties: { sessionID: MAIN_SESSION_ID }, + }, + }) + await hook.handler({ + event: { + type: "session.idle", + properties: { sessionID: MAIN_SESSION_ID }, + }, + }) + await hook.handler({ + event: { + type: "session.idle", + properties: { sessionID: MAIN_SESSION_ID }, + }, + }) + + // #then - should only call prompt ONCE due to debouncing + expect(mockInput._promptMock).toHaveBeenCalledTimes(1) + }) + test("should cleanup on session.deleted", async () => { // #given - boulder state const planPath = join(TEST_DIR, "test-plan.md") diff --git a/src/hooks/sisyphus-orchestrator/index.ts b/src/hooks/sisyphus-orchestrator/index.ts index 16fc17ce..8b7a4f66 100644 --- a/src/hooks/sisyphus-orchestrator/index.ts +++ b/src/hooks/sisyphus-orchestrator/index.ts @@ -402,8 +402,11 @@ function isCallerOrchestrator(sessionID?: string): boolean { interface SessionState { lastEventWasAbortError?: boolean + lastContinuationInjectedAt?: number } +const CONTINUATION_COOLDOWN_MS = 5000 + export interface SisyphusOrchestratorHookOptions { directory: string backgroundManager?: BackgroundManager @@ -576,6 +579,13 @@ export function createSisyphusOrchestratorHook( return } + const now = Date.now() + if (state.lastContinuationInjectedAt && now - state.lastContinuationInjectedAt < CONTINUATION_COOLDOWN_MS) { + log(`[${HOOK_NAME}] Skipped: continuation cooldown active`, { sessionID, cooldownRemaining: CONTINUATION_COOLDOWN_MS - (now - state.lastContinuationInjectedAt) }) + return + } + + state.lastContinuationInjectedAt = now const remaining = progress.total - progress.completed injectContinuation(sessionID, boulderState.plan_name, remaining, progress.total) return