From f502715185f7470f282ef98ac62e8d8058462bb3 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Wed, 28 Oct 2026 02:39:58 -0700 Subject: [PATCH] fix(scheduler): stop() clears the startup timers (TASK-030) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit stop() stopped the cron jobs and left the eight one-shot startup runs armed. schedulerService.syntheticPurge.test calls the real start() with real timers, so the 30 s refreshSkillsIndex later fired in the same jest worker, fetched live GitHub data and rewrote the checked-in skills catalog. Track the startup timers and clear them in stop(). Written by Ember, a Commonly agent — https://commonly.me/v2/pods/6addc5e87f8fc798a6f2f5d2 Co-Authored-By: Claude Opus 5.5 Co-authored-by: Ember Agent-Model: claude/claude-opus-5-5/high --- .../schedulerService.startupTimers.test.js | 42 +++++++++++++++++++ backend/services/schedulerService.ts | 39 ++++++++++------- 2 files changed, 65 insertions(+), 16 deletions(-) create mode 100644 backend/__tests__/unit/services/schedulerService.startupTimers.test.js diff --git a/backend/__tests__/unit/services/schedulerService.startupTimers.test.js b/backend/__tests__/unit/services/schedulerService.startupTimers.test.js new file mode 100644 index 0000000000..f25f7004ab --- /dev/null +++ b/backend/__tests__/unit/services/schedulerService.startupTimers.test.js @@ -0,0 +1,42 @@ +// stop() must clear the one-shot startup runs as well as the cron jobs. Before +// TASK-030 it stopped only the crons, so a test that called the real start() +// and stop() left a 30 s refreshSkillsIndex armed in the jest worker. That +// refresh fetched live GitHub data and rewrote the checked-in skills catalog. +jest.mock('node-cron', () => ({ + schedule: jest.fn(() => ({ start: jest.fn(), stop: jest.fn() })), +})); +jest.mock('../../../services/skillsRefreshService', () => ({ + refreshSkillsIndex: jest.fn().mockResolvedValue({ + refreshed: 0, reposUpdated: 0, reposTotal: 0, errors: [], + }), +})); + +const { refreshSkillsIndex } = require('../../../services/skillsRefreshService'); +const scheduler = require('../../../services/schedulerService'); + +describe('scheduler: stop() clears the startup timers', () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + scheduler.stop(); + jest.clearAllTimers(); + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + it('arms the startup runs on start()', () => { + scheduler.start(); + expect(jest.getTimerCount()).toBeGreaterThanOrEqual(8); + }); + + it('leaves no timer pending after stop(), so the catalog refresh never fires', () => { + scheduler.start(); + scheduler.stop(); + expect(jest.getTimerCount()).toBe(0); + jest.advanceTimersByTime(31000); + expect(refreshSkillsIndex).not.toHaveBeenCalled(); + }); +}); diff --git a/backend/services/schedulerService.ts b/backend/services/schedulerService.ts index 4aa6e69a1a..3d6609c4d2 100644 --- a/backend/services/schedulerService.ts +++ b/backend/services/schedulerService.ts @@ -145,9 +145,14 @@ class SchedulerService { jobs: CronJob[]; + // One-shot startup runs (5–30 s after start). stop() clears them, so a + // stopped scheduler never fires them later in the same process. + startupTimers: NodeJS.Timeout[]; + constructor() { this.isRunning = false; this.jobs = []; + this.startupTimers = []; } start(): void { @@ -697,40 +702,40 @@ class SchedulerService { console.log('- Skills catalog refreshes from upstream every 6 hours (stars re-fetched via GitHub API)'); console.log('- Installable reconciliation runs every 5 minutes (CAS/idempotent; no lease required)'); - setTimeout(() => { + this.startupTimers.push(setTimeout(() => { SchedulerService.runSummarizer().catch((error) => { console.error('Error in initial summarizer run:', error); }); - }, 5000); + }, 5000)); - setTimeout(() => { + this.startupTimers.push(setTimeout(() => { externalFeedService.syncExternalFeeds().catch((error: unknown) => { console.error('Error in initial external feed sync:', error); }); - }, 7000); + }, 7000)); - setTimeout(() => { + this.startupTimers.push(setTimeout(() => { AgentEventService.garbageCollect().catch((error: unknown) => { console.error('Error in initial agent event GC:', error); }); - }, 8000); + }, 8000)); - setTimeout(() => { + this.startupTimers.push(setTimeout(() => { PodCurationService.runThemedPodAutonomy({ hours: 12, minMatches: 4 }).catch((error: unknown) => { console.error('Error in initial themed pod autonomy run:', error); }); - }, 9000); + }, 9000)); - setTimeout(() => { + this.startupTimers.push(setTimeout(() => { SchedulerService.dispatchScheduledAgentHeartbeats({ trigger: 'startup', respectIntervals: true, }).catch((error: unknown) => { console.error('Error in initial heartbeat dispatch:', error); }); - }, 11000); + }, 11000)); - setTimeout(() => { + this.startupTimers.push(setTimeout(() => { if (!AgentEventService.isSessionResetDue()) return; AgentEventService.clearOpenClawSessionsForActiveInstallations({ source: 'startup-session-reset', @@ -738,17 +743,17 @@ class SchedulerService { }).catch((error: unknown) => { console.error('Error in initial OpenClaw session reset:', error); }); - }, 12000); + }, 12000)); - setTimeout(() => { + this.startupTimers.push(setTimeout(() => { AgentAutoJoinService.runAutoJoinAgentOwnedPods({ source: 'startup-autojoin' }).catch((error: unknown) => { console.error('Error in initial auto-join run:', error); }); - }, 13000); + }, 13000)); // Kick a skill catalog refresh 30s after startup so dev gets fresh data // without waiting for the first 6-hourly cron window. - setTimeout(() => { + this.startupTimers.push(setTimeout(() => { refreshSkillsIndex() .then((result: { refreshed: number; reposUpdated: number; reposTotal: number; errors: Error[] }) => { console.log( @@ -759,7 +764,7 @@ class SchedulerService { .catch((error: unknown) => { console.error('[skills-refresh] Startup refresh failed:', error); }); - }, 30000); + }, 30000)); } stop(): void { @@ -774,6 +779,8 @@ class SchedulerService { job.stop(); } }); + this.startupTimers.forEach((timer) => clearTimeout(timer)); + this.startupTimers = []; this.jobs = []; this.isRunning = false; activeScheduler = null;