diff --git a/src/ralph-loop.ts b/src/ralph-loop.ts index 346a2955..f7be5176 100644 --- a/src/ralph-loop.ts +++ b/src/ralph-loop.ts @@ -281,7 +281,14 @@ export class RalphLoop extends EventEmitter { // Guard: only reschedule if still running AND no timer is pending // (prevents race where stop() clears timer between our check and setTimeout) if (this._status === 'running' && this.loopTimer === null) { - this.loopTimer = setTimeout(() => this.runLoop(), this.pollIntervalMs); + // Null the handle when the timer fires, BEFORE re-entering runLoop — + // otherwise the `loopTimer === null` guard above stays false on the + // next pass and the loop stops rescheduling after 2 ticks. + // Mirrors the orchestrator-loop reschedule pattern. + this.loopTimer = setTimeout(() => { + this.loopTimer = null; + this.runLoop(); + }, this.pollIntervalMs); } }); } diff --git a/test/ralph-loop.test.ts b/test/ralph-loop.test.ts index 32beac6a..c54d0730 100644 --- a/test/ralph-loop.test.ts +++ b/test/ralph-loop.test.ts @@ -129,6 +129,36 @@ describe('RalphLoop', () => { vi.useRealTimers(); }); + describe('reschedule loop (regression)', () => { + // tick() is the only path that calls setRalphLoopState with lastCheckAt and + // no status field; start() carries status:'running', stop() status:'stopped'. + const countTicks = () => + (mockState.store.setRalphLoopState as any).mock.calls.filter( + (c: any[]) => c[0]?.lastCheckAt !== undefined && c[0]?.status === undefined + ).length; + + it('keeps rescheduling past the second tick', async () => { + // Real timers + a tiny poll interval: tick()'s async internals settle + // naturally on the event loop, avoiding fake-timer microtask fragility. + vi.useRealTimers(); + loop.destroy(); + // Keep the loop in the "running, should keep polling" state so it actually + // exercises the reschedule path: min duration unreached + autoGenerate on + // makes shouldStop() false (idle sessions are mocked empty, so nothing is + // actually generated). Otherwise an empty queue self-stops after 1 tick. + loop = new RalphLoop({ pollIntervalMs: 5, minDurationMs: 60_000, autoGenerateTasks: true }); + + await loop.start(); + await new Promise((resolve) => setTimeout(resolve, 80)); // ~16 poll intervals + loop.stop(); + + // The bug: the loopTimer handle is never nulled in the setTimeout callback, + // so the `loopTimer === null` reschedule guard is false after the first fire + // and the loop dies at exactly 2 ticks. Fixed -> many ticks. + expect(countTicks()).toBeGreaterThanOrEqual(3); + }); + }); + describe('initial state', () => { it('should start in stopped status', () => { expect(loop.status).toBe('stopped'); @@ -296,11 +326,7 @@ describe('RalphLoop', () => { describe('getStats', () => { it('should return complete stats object', async () => { const mockRunningTask = { id: '2', status: 'running', isTimedOut: () => false }; - mockState.taskQueue.tasks = [ - { id: '1', status: 'pending' }, - mockRunningTask, - { id: '3', status: 'completed' }, - ]; + mockState.taskQueue.tasks = [{ id: '1', status: 'pending' }, mockRunningTask, { id: '3', status: 'completed' }]; mockState.taskQueue.getRunningTasks.mockReturnValue([mockRunningTask]); await loop.start();