Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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();
});
});
39 changes: 23 additions & 16 deletions backend/services/schedulerService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -697,58 +702,58 @@ 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',
restart: true,
}).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(
Expand All @@ -759,7 +764,7 @@ class SchedulerService {
.catch((error: unknown) => {
console.error('[skills-refresh] Startup refresh failed:', error);
});
}, 30000);
}, 30000));
}

stop(): void {
Expand All @@ -774,6 +779,8 @@ class SchedulerService {
job.stop();
}
});
this.startupTimers.forEach((timer) => clearTimeout(timer));
this.startupTimers = [];
this.jobs = [];
this.isRunning = false;
activeScheduler = null;
Expand Down
Loading