fix(ralph): loop stops rescheduling after two ticks

The reschedule guard is `this._status === 'running' && this.loopTimer === null`,
but the timer callback never nulls `loopTimer`. So the handle stays non-null from
the first fire onward, the guard is false on every subsequent pass, and the Ralph
loop silently stops polling after exactly two ticks.

It stops without changing status: `status` stays `running`, `stop()` is never
called, and no error is raised — the loop just quietly never runs again, which is
what makes it hard to notice on a long autonomous run.

Null the handle inside the callback before re-entering `runLoop()`, which is the
pattern `orchestrator-loop.ts` already uses for its own reschedule.

Test: a regression case in test/ralph-loop.test.ts that runs a real 5ms-interval
loop for ~16 intervals and asserts it ticks at least 3 times. Against the unfixed
source it reports exactly 2.
This commit is contained in:
Aamer Akhter
2026-08-20 12:58:17 -04:00
parent 07b9c7fd7b
commit a35438ba34
2 changed files with 39 additions and 6 deletions
+8 -1
View File
@@ -281,7 +281,14 @@ export class RalphLoop extends EventEmitter {
// Guard: only reschedule if still running AND no timer is pending // Guard: only reschedule if still running AND no timer is pending
// (prevents race where stop() clears timer between our check and setTimeout) // (prevents race where stop() clears timer between our check and setTimeout)
if (this._status === 'running' && this.loopTimer === null) { 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);
} }
}); });
} }
+31 -5
View File
@@ -129,6 +129,36 @@ describe('RalphLoop', () => {
vi.useRealTimers(); 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', () => { describe('initial state', () => {
it('should start in stopped status', () => { it('should start in stopped status', () => {
expect(loop.status).toBe('stopped'); expect(loop.status).toBe('stopped');
@@ -296,11 +326,7 @@ describe('RalphLoop', () => {
describe('getStats', () => { describe('getStats', () => {
it('should return complete stats object', async () => { it('should return complete stats object', async () => {
const mockRunningTask = { id: '2', status: 'running', isTimedOut: () => false }; const mockRunningTask = { id: '2', status: 'running', isTimedOut: () => false };
mockState.taskQueue.tasks = [ mockState.taskQueue.tasks = [{ id: '1', status: 'pending' }, mockRunningTask, { id: '3', status: 'completed' }];
{ id: '1', status: 'pending' },
mockRunningTask,
{ id: '3', status: 'completed' },
];
mockState.taskQueue.getRunningTasks.mockReturnValue([mockRunningTask]); mockState.taskQueue.getRunningTasks.mockReturnValue([mockRunningTask]);
await loop.start(); await loop.start();