Merge pull request #2731 from QwenLM/feat/in-session-cron-loops

feat(cron): add in-session loop scheduling with cron tools
This commit is contained in:
tanzhenxin 2026-04-01 16:18:46 +08:00 committed by GitHub
commit 76d64c9464
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
60 changed files with 3110 additions and 41 deletions

View file

@ -58,6 +58,7 @@ import type {
import { type AgentEventEmitter, AgentEventType } from './agent-events.js';
import { AgentStatistics, type AgentStatsSummary } from './agent-statistics.js';
import { AgentTool } from '../../tools/agent.js';
import { ToolNames } from '../../tools/tool-names.js';
import { DEFAULT_QWEN_MODEL } from '../../config/models.js';
import { type ContextState, templateString } from './agent-headless.js';
@ -273,6 +274,15 @@ export class AgentCore {
const toolRegistry = this.runtimeContext.getToolRegistry();
const toolsList: FunctionDeclaration[] = [];
// Tools excluded from subagents: AgentTool (prevent recursion) and
// cron tools (session-scoped, should only be used by the main session).
const excludedFromSubagents = new Set<string>([
AgentTool.Name,
ToolNames.CRON_CREATE,
ToolNames.CRON_LIST,
ToolNames.CRON_DELETE,
]);
if (this.toolConfig) {
const asStrings = this.toolConfig.tools.filter(
(t): t is string => typeof t === 'string',
@ -286,11 +296,13 @@ export class AgentCore {
toolsList.push(
...toolRegistry
.getFunctionDeclarations()
.filter((t) => t.name !== AgentTool.Name),
.filter((t) => !(t.name && excludedFromSubagents.has(t.name))),
);
} else {
toolsList.push(
...toolRegistry.getFunctionDeclarationsFiltered(asStrings),
...toolRegistry.getFunctionDeclarationsFiltered(
asStrings.filter((name) => !excludedFromSubagents.has(name)),
),
);
}
toolsList.push(...onlyInlineDecls);
@ -299,7 +311,7 @@ export class AgentCore {
toolsList.push(
...toolRegistry
.getFunctionDeclarations()
.filter((t) => t.name !== AgentTool.Name),
.filter((t) => !(t.name && excludedFromSubagents.has(t.name))),
);
}

View file

@ -41,6 +41,7 @@ import {
type FileEncodingType,
} from '../services/fileSystemService.js';
import { GitService } from '../services/gitService.js';
import { CronScheduler } from '../services/cronScheduler.js';
// Tools
import { AskUserQuestionTool } from '../tools/askUserQuestion.js';
@ -63,6 +64,9 @@ import { WebFetchTool } from '../tools/web-fetch.js';
import { WebSearchTool } from '../tools/web-search/index.js';
import { WriteFileTool } from '../tools/write-file.js';
import { LspTool } from '../tools/lsp.js';
import { CronCreateTool } from '../tools/cron-create.js';
import { CronListTool } from '../tools/cron-list.js';
import { CronDeleteTool } from '../tools/cron-delete.js';
import type { LspClient } from '../lsp/types.js';
// Other modules
@ -368,6 +372,7 @@ export interface ConfigParameters {
maxSessionTurns?: number;
sessionTokenLimit?: number;
experimentalZedIntegration?: boolean;
cronEnabled?: boolean;
listExtensions?: boolean;
overrideExtensions?: string[];
allowedMcpServers?: string[];
@ -530,6 +535,7 @@ export class Config {
private readonly usageStatisticsEnabled: boolean;
private geminiClient!: GeminiClient;
private baseLlmClient!: BaseLlmClient;
private cronScheduler: CronScheduler | null = null;
private readonly fileFiltering: {
respectGitIgnore: boolean;
respectQwenIgnore: boolean;
@ -557,6 +563,7 @@ export class Config {
private readonly cliVersion?: string;
private readonly experimentalZedIntegration: boolean = false;
private readonly cronEnabled: boolean = false;
private readonly chatRecordingEnabled: boolean;
private readonly loadMemoryFromIncludeDirectories: boolean = false;
private readonly importFormat: 'tree' | 'flat';
@ -679,6 +686,7 @@ export class Config {
this.sessionTokenLimit = params.sessionTokenLimit ?? -1;
this.experimentalZedIntegration =
params.experimentalZedIntegration ?? false;
this.cronEnabled = params.cronEnabled ?? false;
this.listExtensions = params.listExtensions ?? false;
this.overrideExtensions = params.overrideExtensions;
this.noBrowser = params.noBrowser ?? false;
@ -1687,6 +1695,19 @@ export class Config {
return this.geminiClient;
}
getCronScheduler(): CronScheduler {
if (!this.cronScheduler) {
this.cronScheduler = new CronScheduler();
}
return this.cronScheduler;
}
isCronEnabled(): boolean {
// Cron is experimental and opt-in: enabled via settings or env var
if (process.env['QWEN_CODE_ENABLE_CRON'] === '1') return true;
return this.cronEnabled;
}
getEnableRecursiveFileSearch(): boolean {
return this.fileFiltering.enableRecursiveFileSearch;
}
@ -2204,6 +2225,13 @@ export class Config {
await registerCoreTool(LspTool, this);
}
// Register cron tools unless disabled
if (this.isCronEnabled()) {
await registerCoreTool(CronCreateTool, this);
await registerCoreTool(CronListTool, this);
await registerCoreTool(CronDeleteTool, this);
}
if (!options?.skipDiscovery) {
await registry.discoverAllTools();
}

View file

@ -92,6 +92,8 @@ export enum SendMessageType {
ToolResult = 'toolResult',
Retry = 'retry',
Hook = 'hook',
/** Cron-fired prompt. Behaves like UserQuery but skips UserPromptSubmit hook. */
Cron = 'cron',
}
export interface SendMessageOptions {
@ -472,6 +474,7 @@ export class GeminiClient {
const messageBus = this.config.getMessageBus();
if (
messageType !== SendMessageType.Retry &&
messageType !== SendMessageType.Cron &&
hooksEnabled &&
messageBus &&
this.config.hasHooksForEvent('UserPromptSubmit')
@ -516,7 +519,10 @@ export class GeminiClient {
}
}
if (messageType === SendMessageType.UserQuery) {
if (
messageType === SendMessageType.UserQuery ||
messageType === SendMessageType.Cron
) {
this.loopDetector.reset(prompt_id);
this.lastPromptId = prompt_id;
@ -614,7 +620,10 @@ export class GeminiClient {
// append system reminders to the request
let requestToSent = await flatMapTextParts(request, async (text) => [text]);
if (messageType === SendMessageType.UserQuery) {
if (
messageType === SendMessageType.UserQuery ||
messageType === SendMessageType.Cron
) {
const systemReminders = [];
// add subagent system reminder if there are subagents

View file

@ -97,12 +97,16 @@ export * from './tools/tool-registry.js';
export * from './tools/web-fetch.js';
export * from './tools/web-search/index.js';
export * from './tools/write-file.js';
export * from './tools/cron-create.js';
export * from './tools/cron-list.js';
export * from './tools/cron-delete.js';
// ============================================================================
// Services
// ============================================================================
export * from './services/chatRecordingService.js';
export * from './services/cronScheduler.js';
export * from './services/fileDiscoveryService.js';
export * from './services/fileSystemService.js';
export * from './services/gitService.js';

View file

@ -0,0 +1,286 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { CronScheduler, type CronJob } from './cronScheduler.js';
describe('CronScheduler', () => {
let scheduler: CronScheduler;
beforeEach(() => {
scheduler = new CronScheduler();
});
afterEach(() => {
scheduler.destroy();
});
describe('create', () => {
it('creates a job with valid fields', () => {
const job = scheduler.create('*/5 * * * *', 'test prompt', true);
expect(job.id).toHaveLength(8);
expect(job.cronExpr).toBe('*/5 * * * *');
expect(job.prompt).toBe('test prompt');
expect(job.recurring).toBe(true);
expect(job.createdAt).toBeGreaterThan(0);
expect(job.expiresAt).toBeGreaterThan(job.createdAt);
});
it('creates one-shot jobs with zero jitter', () => {
const job = scheduler.create('*/1 * * * *', 'once', false);
expect(job.jitterMs).toBe(0);
});
it('enforces max 50 jobs', () => {
for (let i = 0; i < 50; i++) {
scheduler.create('*/1 * * * *', `job-${i}`, true);
}
expect(() => scheduler.create('*/1 * * * *', 'job-51', true)).toThrow(
'Maximum number of cron jobs (50) reached',
);
});
it('generates unique IDs', () => {
const ids = new Set<string>();
for (let i = 0; i < 20; i++) {
const job = scheduler.create('*/1 * * * *', `job-${i}`, true);
ids.add(job.id);
}
expect(ids.size).toBe(20);
});
});
describe('delete', () => {
it('removes an existing job', () => {
const job = scheduler.create('*/1 * * * *', 'test', true);
expect(scheduler.delete(job.id)).toBe(true);
expect(scheduler.list()).toHaveLength(0);
});
it('returns false for non-existent job', () => {
expect(scheduler.delete('nonexistent')).toBe(false);
});
});
describe('list', () => {
it('returns empty array when no jobs', () => {
expect(scheduler.list()).toEqual([]);
});
it('returns all jobs', () => {
scheduler.create('*/1 * * * *', 'a', true);
scheduler.create('*/2 * * * *', 'b', false);
const jobs = scheduler.list();
expect(jobs).toHaveLength(2);
expect(jobs.map((j) => j.prompt).sort()).toEqual(['a', 'b']);
});
});
describe('size', () => {
it('tracks job count', () => {
expect(scheduler.size).toBe(0);
const job = scheduler.create('*/1 * * * *', 'a', true);
expect(scheduler.size).toBe(1);
scheduler.delete(job.id);
expect(scheduler.size).toBe(0);
});
});
describe('tick', () => {
it('fires callback when a job matches', () => {
const fired: CronJob[] = [];
scheduler.start((job) => fired.push(job));
// Use every-minute cron so jitter is tiny (max ~6s for 1-min period)
scheduler.create('*/1 * * * *', 'match', true);
// Tick at 10:30:59 — past any jitter for a 1-min period job
const date = new Date(2025, 0, 15, 10, 30, 59);
scheduler.tick(date);
expect(fired).toHaveLength(1);
expect(fired[0]!.prompt).toBe('match');
});
it('does not fire when no match', () => {
const fired: CronJob[] = [];
scheduler.start((job) => fired.push(job));
scheduler.create('30 10 * * *', 'no match', true);
// Tick at 10:31 — should not fire
scheduler.tick(new Date(2025, 0, 15, 10, 31, 0));
expect(fired).toHaveLength(0);
});
it('does not double-fire in same minute', () => {
const fired: CronJob[] = [];
scheduler.start((job) => fired.push(job));
scheduler.create('*/1 * * * *', 'once per minute', true);
// Both ticks in second 59 — past jitter for a 1-min period job
const date1 = new Date(2025, 0, 15, 10, 30, 59);
const date2 = new Date(2025, 0, 15, 10, 30, 59, 500);
scheduler.tick(date1);
scheduler.tick(date2);
expect(fired).toHaveLength(1);
});
it('removes one-shot jobs after firing', () => {
const fired: CronJob[] = [];
scheduler.start((job) => fired.push(job));
// One-shot: jitter is 0, so second 1 is fine
scheduler.create('30 10 * * *', 'one-shot', false);
scheduler.tick(new Date(2025, 0, 15, 10, 30, 1));
expect(fired).toHaveLength(1);
expect(scheduler.list()).toHaveLength(0);
});
it('keeps recurring jobs after firing', () => {
const fired: CronJob[] = [];
scheduler.start((job) => fired.push(job));
scheduler.create('*/1 * * * *', 'recurring', true);
// Tick at second 59 — past any jitter for a 1-min period job
scheduler.tick(new Date(2025, 0, 15, 10, 30, 59));
expect(fired).toHaveLength(1);
expect(scheduler.list()).toHaveLength(1);
});
it('removes expired jobs', () => {
scheduler.start(() => {});
const job = scheduler.create('*/1 * * * *', 'expire me', true);
// Tick far in the future (past expiry)
const farFuture = new Date(job.expiresAt + 1000);
scheduler.tick(farFuture);
expect(scheduler.list()).toHaveLength(0);
});
it('fires in next minute after first fire', () => {
const fired: CronJob[] = [];
scheduler.start((job) => fired.push(job));
// Every minute
scheduler.create('* * * * *', 'every minute', true);
scheduler.tick(new Date(2025, 0, 15, 10, 30, 59));
expect(fired).toHaveLength(1);
// Next minute
scheduler.tick(new Date(2025, 0, 15, 10, 31, 59));
expect(fired).toHaveLength(2);
});
it('fires recurring jobs after the matching minute when positive jitter delays them', () => {
const fired: CronJob[] = [];
scheduler.start((job) => fired.push(job));
const job = scheduler.create('0 * * * *', 'hourly delayed', true);
job.jitterMs = 6 * 60 * 1000;
scheduler.tick(new Date(2025, 0, 15, 10, 5, 59));
expect(fired).toHaveLength(0);
scheduler.tick(new Date(2025, 0, 15, 10, 6, 0));
expect(fired).toHaveLength(1);
expect(fired[0]!.prompt).toBe('hourly delayed');
});
it('fires one-shot jobs before the matching minute when negative jitter advances them', () => {
const fired: CronJob[] = [];
scheduler.start((job) => fired.push(job));
const job = scheduler.create('30 10 * * *', 'oneshot early', false);
job.jitterMs = -30 * 1000;
scheduler.tick(new Date(2025, 0, 15, 10, 29, 29));
expect(fired).toHaveLength(0);
scheduler.tick(new Date(2025, 0, 15, 10, 29, 30));
expect(fired).toHaveLength(1);
expect(fired[0]!.prompt).toBe('oneshot early');
});
});
describe('start/stop', () => {
it('starts and stops without error', () => {
scheduler.start(() => {});
expect(scheduler.running).toBe(true);
scheduler.stop();
expect(scheduler.running).toBe(false);
});
it('does not fire after stop', () => {
const fired: CronJob[] = [];
scheduler.start((job) => fired.push(job));
scheduler.stop();
scheduler.create('30 10 * * *', 'no fire', true);
scheduler.tick(new Date(2025, 0, 15, 10, 30, 1));
// tick still works manually, but onFire is cleared
expect(fired).toHaveLength(0);
});
it('start is idempotent', () => {
scheduler.start(() => {});
scheduler.start(() => {}); // should not throw or create duplicate timers
expect(scheduler.running).toBe(true);
});
});
describe('getExitSummary', () => {
it('returns null when no jobs', () => {
expect(scheduler.getExitSummary()).toBeNull();
});
it('returns summary with single job', () => {
scheduler.create('*/5 * * * *', 'check the build', true);
const summary = scheduler.getExitSummary()!;
expect(summary).toContain('1 active loop cancelled:');
expect(summary).toContain('Every 5 minutes');
expect(summary).toContain('check the build');
});
it('returns summary with multiple jobs', () => {
scheduler.create('*/5 * * * *', 'check the build', true);
scheduler.create('*/30 * * * *', 'check PR reviews', true);
const summary = scheduler.getExitSummary()!;
expect(summary).toContain('2 active loops cancelled:');
expect(summary).toContain('check the build');
expect(summary).toContain('check PR reviews');
});
it('truncates long prompts', () => {
const longPrompt = 'a'.repeat(100);
scheduler.create('*/1 * * * *', longPrompt, true);
const summary = scheduler.getExitSummary()!;
expect(summary).toContain('...');
// Should not contain the full 100-char prompt
expect(summary).not.toContain(longPrompt);
});
it('returns null after all jobs are deleted', () => {
const job = scheduler.create('*/1 * * * *', 'temp', true);
scheduler.delete(job.id);
expect(scheduler.getExitSummary()).toBeNull();
});
});
describe('destroy', () => {
it('stops and clears all jobs', () => {
scheduler.create('*/1 * * * *', 'a', true);
scheduler.create('*/2 * * * *', 'b', true);
scheduler.start(() => {});
scheduler.destroy();
expect(scheduler.running).toBe(false);
expect(scheduler.list()).toHaveLength(0);
});
});
});

View file

@ -0,0 +1,282 @@
/**
* In-session cron scheduler. Jobs live in memory and are gone when the
* process exits. Ticks every second, fires callbacks when jobs are due.
*/
import { matches, nextFireTime } from '../utils/cronParser.js';
import { humanReadableCron } from '../utils/cronDisplay.js';
const MAX_JOBS = 50;
const THREE_DAYS_MS = 3 * 24 * 60 * 60 * 1000;
// Recurring: up to 10% of period, capped at 15 minutes.
const MAX_RECURRING_JITTER_MS = 15 * 60 * 1000;
// One-shot: up to 90s early for jobs landing on :00 or :30.
const MAX_ONESHOT_JITTER_MS = 90 * 1000;
export interface CronJob {
id: string;
cronExpr: string;
prompt: string;
recurring: boolean;
createdAt: number;
expiresAt: number;
lastFiredAt?: number;
jitterMs: number;
}
/**
* Deterministic hash from a string ID, returned as a positive integer.
*/
function hashId(id: string): number {
let hash = 0;
for (let i = 0; i < id.length; i++) {
hash = (hash * 31 + id.charCodeAt(i)) | 0;
}
return Math.abs(hash);
}
/**
* Derives a deterministic jitter offset from a job ID and its cron period.
* Recurring jobs: up to 10% of period, capped at 15 minutes (added after fire time).
* One-shot jobs landing on :00 or :30: up to 90s early (subtracted before fire time).
* Other one-shot jobs: 0 jitter.
*/
function computeJitter(
id: string,
cronExpr: string,
recurring: boolean,
): number {
const hash = hashId(id);
if (recurring) {
// Estimate period by computing two consecutive fire times
const now = new Date();
try {
const first = nextFireTime(cronExpr, now);
const second = nextFireTime(cronExpr, first);
const periodMs = second.getTime() - first.getTime();
const tenPercent = periodMs * 0.1;
const maxJitter = Math.min(tenPercent, MAX_RECURRING_JITTER_MS);
return hash % Math.max(1, Math.floor(maxJitter));
} catch {
return 0;
}
}
// One-shot: apply up to 90s early jitter only when minute is :00 or :30
try {
const fields = cronExpr.trim().split(/\s+/);
const minuteField = fields[0] ?? '';
const minuteVal = parseInt(minuteField, 10);
if (!isNaN(minuteVal) && (minuteVal === 0 || minuteVal === 30)) {
// Negative jitter = fire early
return -(hash % MAX_ONESHOT_JITTER_MS);
}
} catch {
// fall through
}
return 0;
}
function generateId(): string {
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
let id = '';
for (let i = 0; i < 8; i++) {
id += chars[Math.floor(Math.random() * chars.length)];
}
return id;
}
export class CronScheduler {
private jobs = new Map<string, CronJob>();
private timer: ReturnType<typeof setInterval> | null = null;
private onFire: ((job: CronJob) => void) | null = null;
/**
* Creates a new cron job. Returns the created job.
* Throws if the max job limit is reached.
*/
create(cronExpr: string, prompt: string, recurring: boolean): CronJob {
if (this.jobs.size >= MAX_JOBS) {
throw new Error(
`Maximum number of cron jobs (${MAX_JOBS}) reached. Delete some jobs first.`,
);
}
const id = generateId();
const now = Date.now();
const jitterMs = computeJitter(id, cronExpr, recurring);
const job: CronJob = {
id,
cronExpr,
prompt,
recurring,
createdAt: now,
expiresAt: recurring ? now + THREE_DAYS_MS : Infinity,
jitterMs,
};
this.jobs.set(id, job);
return job;
}
/**
* Deletes a job by ID. Returns true if the job existed.
*/
delete(id: string): boolean {
return this.jobs.delete(id);
}
/**
* Returns all active jobs.
*/
list(): CronJob[] {
return [...this.jobs.values()];
}
/**
* Returns the number of active jobs.
*/
get size(): number {
return this.jobs.size;
}
/**
* Starts the scheduler tick. Calls `onFire` when a job is due.
* Only fires when called does not auto-fire missed intervals.
*/
start(onFire: (job: CronJob) => void): void {
this.onFire = onFire;
if (this.timer) return; // already running
this.timer = setInterval(() => {
this.tick();
}, 1000);
}
/**
* Stops the scheduler. Does not clear jobs they remain queryable.
*/
stop(): void {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
this.onFire = null;
}
/**
* Returns true if the scheduler is running.
*/
get running(): boolean {
return this.timer !== null;
}
/**
* Manual tick checks all jobs against the current time and fires those
* that are due. Exported for testing.
*/
tick(now?: Date): void {
const currentDate = now ?? new Date();
const currentMs = currentDate.getTime();
for (const job of this.jobs.values()) {
// Check expiry
if (currentMs >= job.expiresAt) {
this.jobs.delete(job.id);
continue;
}
// Find the cron minute whose jittered fire time we might be in.
// For positive jitter (recurring) the fire time is *after* the matching
// minute, so we look backwards. For negative jitter (one-shot :00/:30)
// the fire time is *before* the matching minute, so we look at the
// current minute.
//
// We scan a window of minutes around `now` equal to the absolute jitter
// so that a +6 min jitter on an hourly job still finds the :00 match.
const absJitter = Math.abs(job.jitterMs);
const windowMinutes = Math.ceil(absJitter / 60_000);
// Build the candidate minute-start at the beginning of the current minute
const nowMinuteStart = new Date(currentDate);
nowMinuteStart.setSeconds(0, 0);
const nowMinuteMs = nowMinuteStart.getTime();
let matchedMinuteMs: number | null = null;
// Scan from (now - windowMinutes) to (now) for positive jitter,
// or (now) to (now + windowMinutes) for negative jitter.
// In practice we scan both directions to keep the code simple.
for (let offset = -windowMinutes; offset <= windowMinutes; offset++) {
const candidateMs = nowMinuteMs + offset * 60_000;
const candidateDate = new Date(candidateMs);
if (!matches(job.cronExpr, candidateDate)) continue;
const fireTimeMs = candidateMs + job.jitterMs;
if (currentMs >= fireTimeMs) {
// This candidate's jittered fire time has passed — it's a match.
// Pick the latest matching minute to avoid re-triggering old ones.
if (matchedMinuteMs === null || candidateMs > matchedMinuteMs) {
matchedMinuteMs = candidateMs;
}
}
}
if (matchedMinuteMs === null) {
continue; // No matching minute whose jittered time has arrived
}
// Prevent double-firing: compare against the cron minute we last fired for
if (
job.lastFiredAt !== undefined &&
job.lastFiredAt === matchedMinuteMs
) {
continue; // Already fired for this cron minute
}
// Fire! Record the matched cron minute (not wall-clock time) so the
// double-fire guard works when jitter pushes the fire into a later minute.
job.lastFiredAt = matchedMinuteMs;
if (!job.recurring) {
this.jobs.delete(job.id);
}
if (this.onFire) {
this.onFire(job);
}
}
}
/**
* Returns a human-readable summary of active jobs for display on session
* exit. Returns null if there are no active jobs.
*/
getExitSummary(): string | null {
if (this.jobs.size === 0) return null;
const count = this.jobs.size;
const lines = [
`Session ending. ${count} active loop${count === 1 ? '' : 's'} cancelled:`,
];
for (const job of this.jobs.values()) {
const schedule = humanReadableCron(job.cronExpr);
// Truncate long prompts
const prompt =
job.prompt.length > 60 ? job.prompt.slice(0, 57) + '...' : job.prompt;
lines.push(` - [${job.id}] ${schedule}: ${prompt}`);
}
return lines.join('\n');
}
/**
* Clears all jobs and stops the scheduler.
*/
destroy(): void {
this.stop();
this.jobs.clear();
}
}

View file

@ -0,0 +1,61 @@
---
name: loop
description: Create a recurring loop that runs a prompt on a schedule. Usage - /loop 5m check the build, /loop check the PR every 30m, /loop run tests (defaults to 10m). /loop list to show jobs, /loop clear to cancel all.
allowedTools:
- cron_create
- cron_list
- cron_delete
---
# /loop — schedule a recurring prompt
## Subcommands
If the input (after stripping the `/loop` prefix) is exactly one of these keywords, run the subcommand instead of scheduling:
- **`list`** — call CronList and display the results. Done.
- **`clear`** — call CronList, then call CronDelete for every job returned. Confirm how many were cancelled. Done.
Otherwise, parse the input below into `[interval] <prompt…>` and schedule it with CronCreate.
## Parsing (in priority order)
1. **Leading token**: if the first whitespace-delimited token matches `^\d+[smhd]$` (e.g. `5m`, `2h`), that's the interval; the rest is the prompt.
2. **Trailing "every" clause**: otherwise, if the input ends with `every <N><unit>` or `every <N> <unit-word>` (e.g. `every 20m`, `every 5 minutes`, `every 2 hours`), extract that as the interval and strip it from the prompt. Only match when what follows "every" is a time expression — `check every PR` has no interval.
3. **Default**: otherwise, interval is `10m` and the entire input is the prompt.
If the resulting prompt is empty, show usage `/loop [interval] <prompt>` and stop — do not call CronCreate.
Examples:
- `5m /babysit-prs` → interval `5m`, prompt `/babysit-prs` (rule 1)
- `check the deploy every 20m` → interval `20m`, prompt `check the deploy` (rule 2)
- `run tests every 5 minutes` → interval `5m`, prompt `run tests` (rule 2)
- `check the deploy` → interval `10m`, prompt `check the deploy` (rule 3)
- `check every PR` → interval `10m`, prompt `check every PR` (rule 3 — "every" not followed by time)
- `5m` → empty prompt → show usage
## Interval → cron
Supported suffixes: `s` (seconds, rounded up to nearest minute, min 1), `m` (minutes), `h` (hours), `d` (days). Convert:
| Interval pattern | Cron expression | Notes |
| ----------------- | ---------------------- | ----------------------------------------- |
| `Nm` where N ≤ 59 | `*/N * * * *` | every N minutes |
| `Nm` where N ≥ 60 | `0 */H * * *` | round to hours (H = N/60, must divide 24) |
| `Nh` where N ≤ 23 | `0 */N * * *` | every N hours |
| `Nd` | `0 0 */N * *` | every N days at midnight local |
| `Ns` | treat as `ceil(N/60)m` | cron minimum granularity is 1 minute |
**If the interval doesn't cleanly divide its unit** (e.g. `7m``*/7 * * * *` gives uneven gaps at :56→:00; `90m` → 1.5h which cron can't express), pick the nearest clean interval and tell the user what you rounded to before scheduling.
## Action
1. Call CronCreate with:
- `cron`: the expression from the table above
- `prompt`: the parsed prompt from above, verbatim (slash commands are passed through unchanged)
- `recurring`: `true`
2. Briefly confirm: what's scheduled, the cron expression, the human-readable cadence, that recurring tasks auto-expire after 3 days, and that they can cancel sooner with CronDelete (include the job ID).
3. **Then immediately execute the parsed prompt now** — don't wait for the first cron fire. If it's a slash command, invoke it via the Skill tool; otherwise act on it directly.
## Input

View file

@ -0,0 +1,68 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { CronCreateTool } from './cron-create.js';
import { CronScheduler } from '../services/cronScheduler.js';
function makeConfig() {
const scheduler = new CronScheduler();
return {
getCronScheduler: () => scheduler,
_scheduler: scheduler,
} as unknown as import('../config/config.js').Config & {
_scheduler: CronScheduler;
};
}
describe('CronCreateTool', () => {
let config: ReturnType<typeof makeConfig>;
let tool: CronCreateTool;
beforeEach(() => {
config = makeConfig();
tool = new CronCreateTool(config);
});
it('has the correct name', () => {
expect(tool.name).toBe('cron_create');
});
it('creates a recurring job by default', async () => {
const invocation = tool.build({
cron: '*/5 * * * *',
prompt: 'check status',
});
const result = await invocation.execute(new AbortController().signal);
expect(result.error).toBeUndefined();
expect(result.llmContent).toContain('Scheduled recurring job');
expect(result.llmContent).toContain('Auto-expires after 3 days');
expect(config._scheduler.list()).toHaveLength(1);
});
it('creates a one-shot job when recurring=false', async () => {
const invocation = tool.build({
cron: '*/1 * * * *',
prompt: 'once',
recurring: false,
});
const result = await invocation.execute(new AbortController().signal);
expect(result.error).toBeUndefined();
expect(result.llmContent).toContain('Scheduled one-shot task');
expect(result.llmContent).toContain('fire once then auto-delete');
const jobs = config._scheduler.list();
expect(jobs).toHaveLength(1);
expect(jobs[0]!.recurring).toBe(false);
});
it('returns error for invalid cron expression', async () => {
const invocation = tool.build({
cron: 'bad cron',
prompt: 'fail',
});
const result = await invocation.execute(new AbortController().signal);
expect(result.error).toBeDefined();
});
it('validates required params', () => {
expect(() => tool.build({ cron: '*/1 * * * *' } as never)).toThrow();
expect(() => tool.build({ prompt: 'test' } as never)).toThrow();
});
});

View file

@ -0,0 +1,137 @@
/**
* cron_create tool creates a new in-session cron job.
*/
import type { ToolInvocation, ToolResult } from './tools.js';
import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js';
import { ToolNames, ToolDisplayNames } from './tool-names.js';
import type { Config } from '../config/config.js';
import { parseCron } from '../utils/cronParser.js';
import { humanReadableCron } from '../utils/cronDisplay.js';
export interface CronCreateParams {
cron: string;
prompt: string;
recurring?: boolean;
}
class CronCreateInvocation extends BaseToolInvocation<
CronCreateParams,
ToolResult
> {
constructor(
private config: Config,
params: CronCreateParams,
) {
super(params);
}
getDescription(): string {
return `${this.params.cron}: ${this.params.prompt}`;
}
async execute(): Promise<ToolResult> {
const scheduler = this.config.getCronScheduler();
const recurring = this.params.recurring !== false;
try {
// Validate cron expression before creating the job
parseCron(this.params.cron);
const job = scheduler.create(
this.params.cron,
this.params.prompt,
recurring,
);
const display = humanReadableCron(job.cronExpr);
const returnDisplay = `Scheduled ${job.id} (${display})`;
let llmContent: string;
if (recurring) {
llmContent =
`Scheduled recurring job ${job.id} (${job.cronExpr}). ` +
'Session-only (not written to disk, dies when Qwen Code exits). ' +
'Auto-expires after 3 days. Use CronDelete to cancel sooner.';
} else {
llmContent =
`Scheduled one-shot task ${job.id} (${job.cronExpr}). ` +
'Session-only (not written to disk, dies when Qwen Code exits). ' +
'It will fire once then auto-delete.';
}
return { llmContent, returnDisplay };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
llmContent: `Error creating cron job: ${message}`,
returnDisplay: message,
error: { message },
};
}
}
}
export class CronCreateTool extends BaseDeclarativeTool<
CronCreateParams,
ToolResult
> {
static readonly Name = ToolNames.CRON_CREATE;
constructor(private config: Config) {
super(
CronCreateTool.Name,
ToolDisplayNames.CRON_CREATE,
'Schedule a prompt to be enqueued at a future time. Use for both recurring schedules and one-shot reminders.\n\n' +
'Uses standard 5-field cron in the user\'s local timezone: minute hour day-of-month month day-of-week. "0 9 * * *" means 9am local — no timezone conversion needed.\n\n' +
'## One-shot tasks (recurring: false)\n\n' +
'For "remind me at X" or "at <time>, do Y" requests — fire once then auto-delete.\n' +
'Pin minute/hour/day-of-month/month to specific values:\n' +
' "remind me at 2:30pm today to check the deploy" → cron: "30 14 <today_dom> <today_month> *", recurring: false\n' +
' "tomorrow morning, run the smoke test" → cron: "57 8 <tomorrow_dom> <tomorrow_month> *", recurring: false\n\n' +
'## Recurring jobs (recurring: true, the default)\n\n' +
'For "every N minutes" / "every hour" / "weekdays at 9am" requests:\n' +
' "*/5 * * * *" (every 5 min), "0 * * * *" (hourly), "0 9 * * 1-5" (weekdays at 9am local)\n\n' +
'## Avoid the :00 and :30 minute marks when the task allows it\n\n' +
'Every user who asks for "9am" gets `0 9`, and every user who asks for "hourly" gets `0 *` — which means requests from across the planet land on the API at the same instant. When the user\'s request is approximate, pick a minute that is NOT 0 or 30:\n' +
' "every morning around 9" → "57 8 * * *" or "3 9 * * *" (not "0 9 * * *")\n' +
' "hourly" → "7 * * * *" (not "0 * * * *")\n' +
' "in an hour or so, remind me to..." → pick whatever minute you land on, don\'t round\n\n' +
'Only use minute 0 or 30 when the user names that exact time and clearly means it ("at 9:00 sharp", "at half past", coordinating with a meeting). When in doubt, nudge a few minutes early or late — the user will not notice, and the fleet will.\n\n' +
'## Session-only\n\n' +
'Jobs live only in this Qwen Code session — nothing is written to disk, and the job is gone when Qwen Code exits.\n\n' +
'## Runtime behavior\n\n' +
'Jobs only fire while the REPL is idle (not mid-query). The scheduler adds a small deterministic jitter on top of whatever you pick: recurring tasks fire up to 10% of their period late (max 15 min); one-shot tasks landing on :00 or :30 fire up to 90 s early. Picking an off-minute is still the bigger lever.\n\n' +
'Recurring tasks auto-expire after 3 days — they fire one final time, then are deleted. This bounds session lifetime. Tell the user about the 3-day limit when scheduling recurring jobs.\n\n' +
'Returns a job ID you can pass to CronDelete.',
Kind.Other,
{
type: 'object',
properties: {
cron: {
type: 'string',
description:
'Standard 5-field cron expression in local time: "M H DoM Mon DoW" (e.g. "*/5 * * * *" = every 5 minutes, "30 14 28 2 *" = Feb 28 at 2:30pm local once).',
},
prompt: {
type: 'string',
description: 'The prompt to enqueue at each fire time.',
},
recurring: {
type: 'boolean',
description:
'true (default) = fire on every cron match until deleted or auto-expired after 3 days. false = fire once at the next match, then auto-delete. Use false for "remind me at X" one-shot requests with pinned minute/hour/dom/month.',
},
},
required: ['cron', 'prompt'],
additionalProperties: false,
},
);
}
protected createInvocation(
params: CronCreateParams,
): ToolInvocation<CronCreateParams, ToolResult> {
return new CronCreateInvocation(this.config, params);
}
}

View file

@ -0,0 +1,48 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { CronDeleteTool } from './cron-delete.js';
import { CronScheduler } from '../services/cronScheduler.js';
function makeConfig() {
const scheduler = new CronScheduler();
return {
getCronScheduler: () => scheduler,
_scheduler: scheduler,
} as unknown as import('../config/config.js').Config & {
_scheduler: CronScheduler;
};
}
describe('CronDeleteTool', () => {
let config: ReturnType<typeof makeConfig>;
let tool: CronDeleteTool;
beforeEach(() => {
config = makeConfig();
tool = new CronDeleteTool(config);
});
it('has the correct name', () => {
expect(tool.name).toBe('cron_delete');
});
it('deletes an existing job', async () => {
const job = config._scheduler.create('*/1 * * * *', 'test', true);
const invocation = tool.build({ id: job.id });
const result = await invocation.execute(new AbortController().signal);
expect(result.error).toBeUndefined();
expect(result.llmContent).toContain('Cancelled job');
expect(config._scheduler.list()).toHaveLength(0);
});
it('returns error for non-existent job', async () => {
const invocation = tool.build({ id: 'nonexist' });
const result = await invocation.execute(new AbortController().signal);
expect(result.error).toBeDefined();
expect(result.llmContent).toContain('not found');
});
it('validates required params', () => {
expect(() => tool.build({} as never)).toThrow();
});
});

View file

@ -0,0 +1,79 @@
/**
* cron_delete tool deletes an in-session cron job by ID.
*/
import type { ToolInvocation, ToolResult } from './tools.js';
import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js';
import { ToolNames, ToolDisplayNames } from './tool-names.js';
import type { Config } from '../config/config.js';
export interface CronDeleteParams {
id: string;
}
class CronDeleteInvocation extends BaseToolInvocation<
CronDeleteParams,
ToolResult
> {
constructor(
private config: Config,
params: CronDeleteParams,
) {
super(params);
}
getDescription(): string {
return this.params.id;
}
async execute(): Promise<ToolResult> {
const scheduler = this.config.getCronScheduler();
const deleted = scheduler.delete(this.params.id);
if (deleted) {
const llmContent = `Cancelled job ${this.params.id}.`;
const returnDisplay = `Cancelled ${this.params.id}`;
return { llmContent, returnDisplay };
} else {
const result = `Job ${this.params.id} not found.`;
return {
llmContent: result,
returnDisplay: result,
error: { message: result },
};
}
}
}
export class CronDeleteTool extends BaseDeclarativeTool<
CronDeleteParams,
ToolResult
> {
static readonly Name = ToolNames.CRON_DELETE;
constructor(private config: Config) {
super(
CronDeleteTool.Name,
ToolDisplayNames.CRON_DELETE,
'Cancel a cron job previously scheduled with CronCreate. Removes it from the in-memory session store.',
Kind.Other,
{
type: 'object',
properties: {
id: {
type: 'string',
description: 'Job ID returned by CronCreate.',
},
},
required: ['id'],
additionalProperties: false,
},
);
}
protected createInvocation(
params: CronDeleteParams,
): ToolInvocation<CronDeleteParams, ToolResult> {
return new CronDeleteInvocation(this.config, params);
}
}

View file

@ -0,0 +1,49 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { CronListTool } from './cron-list.js';
import { CronScheduler } from '../services/cronScheduler.js';
function makeConfig() {
const scheduler = new CronScheduler();
return {
getCronScheduler: () => scheduler,
_scheduler: scheduler,
} as unknown as import('../config/config.js').Config & {
_scheduler: CronScheduler;
};
}
describe('CronListTool', () => {
let config: ReturnType<typeof makeConfig>;
let tool: CronListTool;
beforeEach(() => {
config = makeConfig();
tool = new CronListTool(config);
});
it('has the correct name', () => {
expect(tool.name).toBe('cron_list');
});
it('returns empty message when no jobs', async () => {
const invocation = tool.build({});
const result = await invocation.execute(new AbortController().signal);
expect(result.error).toBeUndefined();
expect(result.llmContent).toContain('No active cron jobs');
});
it('lists created jobs', async () => {
config._scheduler.create('*/5 * * * *', 'check build', true);
config._scheduler.create('*/1 * * * *', 'ping', false);
const invocation = tool.build({});
const result = await invocation.execute(new AbortController().signal);
expect(result.error).toBeUndefined();
expect(result.llmContent).toContain(
'(recurring) [session-only]: check build',
);
expect(result.llmContent).toContain('(one-shot) [session-only]: ping');
// Two lines, one per job
expect(String(result.llmContent).split('\n')).toHaveLength(2);
});
});

View file

@ -0,0 +1,75 @@
/**
* cron_list tool lists all active in-session cron jobs.
*/
import type { ToolInvocation, ToolResult } from './tools.js';
import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js';
import { ToolNames, ToolDisplayNames } from './tool-names.js';
import type { Config } from '../config/config.js';
import { humanReadableCron } from '../utils/cronDisplay.js';
export type CronListParams = Record<string, never>;
class CronListInvocation extends BaseToolInvocation<
CronListParams,
ToolResult
> {
constructor(
private config: Config,
params: CronListParams,
) {
super(params);
}
getDescription(): string {
return '';
}
async execute(): Promise<ToolResult> {
const scheduler = this.config.getCronScheduler();
const jobs = scheduler.list();
if (jobs.length === 0) {
const result = 'No active cron jobs.';
return { llmContent: result, returnDisplay: result };
}
const llmLines = jobs.map((job) => {
const type = job.recurring ? 'recurring' : 'one-shot';
return `${job.id}${job.cronExpr} (${type}) [session-only]: ${job.prompt}`;
});
const llmContent = llmLines.join('\n');
const displayLines = jobs.map((job) => `${job.id} ${humanReadableCron(job.cronExpr)}`);
const returnDisplay = displayLines.join('\n');
return { llmContent, returnDisplay };
}
}
export class CronListTool extends BaseDeclarativeTool<
CronListParams,
ToolResult
> {
static readonly Name = ToolNames.CRON_LIST;
constructor(private config: Config) {
super(
CronListTool.Name,
ToolDisplayNames.CRON_LIST,
'List all cron jobs scheduled via CronCreate in this session.',
Kind.Other,
{
type: 'object',
properties: {},
additionalProperties: false,
},
);
}
protected createInvocation(
params: CronListParams,
): ToolInvocation<CronListParams, ToolResult> {
return new CronListInvocation(this.config, params);
}
}

View file

@ -26,6 +26,9 @@ export const ToolNames = {
LS: 'list_directory',
LSP: 'lsp',
ASK_USER_QUESTION: 'ask_user_question',
CRON_CREATE: 'cron_create',
CRON_LIST: 'cron_list',
CRON_DELETE: 'cron_delete',
} as const;
/**
@ -50,6 +53,9 @@ export const ToolDisplayNames = {
LS: 'ListFiles',
LSP: 'Lsp',
ASK_USER_QUESTION: 'AskUserQuestion',
CRON_CREATE: 'CronCreate',
CRON_LIST: 'CronList',
CRON_DELETE: 'CronDelete',
} as const;
// Migration from old tool names to new tool names

View file

@ -0,0 +1,54 @@
/**
* Human-readable cron display for common recurring patterns.
* Falls back to the raw expression for anything non-trivial.
*/
export function humanReadableCron(cronExpr: string): string {
const parts = cronExpr.trim().split(/\s+/);
if (parts.length !== 5) return cronExpr;
const [min, hour, dom, mon, dow] = parts;
// */N * * * * → Every N minutes
if (
min!.startsWith('*/') &&
hour === '*' &&
dom === '*' &&
mon === '*' &&
dow === '*'
) {
const n = parseInt(min!.slice(2), 10);
if (!isNaN(n)) {
return n === 1 ? 'Every minute' : `Every ${n} minutes`;
}
}
// 0 */N * * * → Every N hours (or single minute with */N hours)
if (
/^\d+$/.test(min!) &&
hour!.startsWith('*/') &&
dom === '*' &&
mon === '*' &&
dow === '*'
) {
const n = parseInt(hour!.slice(2), 10);
if (!isNaN(n)) {
return n === 1 ? 'Every hour' : `Every ${n} hours`;
}
}
// M H */N * * → Every N days
if (
/^\d+$/.test(min!) &&
/^\d+$/.test(hour!) &&
dom!.startsWith('*/') &&
mon === '*' &&
dow === '*'
) {
const n = parseInt(dom!.slice(2), 10);
if (!isNaN(n)) {
return n === 1 ? 'Every day' : `Every ${n} days`;
}
}
return cronExpr;
}

View file

@ -0,0 +1,174 @@
import { describe, it, expect } from 'vitest';
import { matches, nextFireTime, parseCron } from './cronParser.js';
describe('parseCron', () => {
it('parses wildcard fields', () => {
const fields = parseCron('* * * * *');
expect(fields.minute.size).toBe(60);
expect(fields.hour.size).toBe(24);
expect(fields.dayOfMonth.size).toBe(31);
expect(fields.month.size).toBe(12);
expect(fields.dayOfWeek.size).toBe(7);
});
it('parses single values', () => {
const fields = parseCron('5 14 1 6 3');
expect([...fields.minute]).toEqual([5]);
expect([...fields.hour]).toEqual([14]);
expect([...fields.dayOfMonth]).toEqual([1]);
expect([...fields.month]).toEqual([6]);
expect([...fields.dayOfWeek]).toEqual([3]);
});
it('parses ranges', () => {
const fields = parseCron('1-5 * * * *');
expect([...fields.minute].sort((a, b) => a - b)).toEqual([1, 2, 3, 4, 5]);
});
it('parses comma lists', () => {
const fields = parseCron('0,15,30,45 * * * *');
expect([...fields.minute].sort((a, b) => a - b)).toEqual([0, 15, 30, 45]);
});
it('parses steps', () => {
const fields = parseCron('*/15 * * * *');
expect([...fields.minute].sort((a, b) => a - b)).toEqual([0, 15, 30, 45]);
});
it('parses range with step', () => {
const fields = parseCron('1-10/3 * * * *');
expect([...fields.minute].sort((a, b) => a - b)).toEqual([1, 4, 7, 10]);
});
it('throws on wrong number of fields', () => {
expect(() => parseCron('* * *')).toThrow('must have exactly 5 fields');
expect(() => parseCron('* * * * * *')).toThrow(
'must have exactly 5 fields',
);
});
it('throws on out-of-range values', () => {
expect(() => parseCron('60 * * * *')).toThrow('out of bounds');
expect(() => parseCron('* 25 * * *')).toThrow('out of bounds');
expect(() => parseCron('* * 0 * *')).toThrow('out of bounds');
expect(() => parseCron('* * * 13 *')).toThrow('out of bounds');
expect(() => parseCron('* * * * 8')).toThrow('out of bounds');
});
it('accepts 7 as Sunday and normalizes to 0', () => {
const fields = parseCron('* * * * 7');
expect(fields.dayOfWeek.has(0)).toBe(true);
expect(fields.dayOfWeek.has(7)).toBe(false);
});
it('throws on invalid step', () => {
expect(() => parseCron('*/0 * * * *')).toThrow('Invalid step');
});
});
describe('matches', () => {
it('matches every-minute cron', () => {
const date = new Date(2025, 0, 15, 10, 30); // Jan 15 2025, 10:30
expect(matches('* * * * *', date)).toBe(true);
});
it('matches specific minute', () => {
const date = new Date(2025, 0, 15, 10, 30);
expect(matches('30 * * * *', date)).toBe(true);
expect(matches('31 * * * *', date)).toBe(false);
});
it('matches specific hour and minute', () => {
const date = new Date(2025, 0, 15, 14, 0);
expect(matches('0 14 * * *', date)).toBe(true);
expect(matches('0 13 * * *', date)).toBe(false);
});
it('matches day of week', () => {
// Jan 15 2025 is a Wednesday (day 3)
const date = new Date(2025, 0, 15, 10, 0);
expect(matches('* * * * 3', date)).toBe(true);
expect(matches('* * * * 1', date)).toBe(false);
});
it('uses OR logic when both day-of-month and day-of-week are constrained', () => {
// Jan 15 2025 is a Wednesday (day 3), day-of-month 15
const date = new Date(2025, 0, 15, 10, 0);
// dom=1 (no match), dow=3 (match) → should match via OR
expect(matches('0 10 1 * 3', date)).toBe(true);
// dom=15 (match), dow=1 (no match) → should match via OR
expect(matches('0 10 15 * 1', date)).toBe(true);
// dom=1 (no match), dow=1 (no match) → no match
expect(matches('0 10 1 * 1', date)).toBe(false);
});
it('uses AND logic when only one day field is constrained', () => {
// Jan 15 2025 is a Wednesday (day 3)
const date = new Date(2025, 0, 15, 10, 0);
// dom=1, dow=* → AND, dom doesn't match
expect(matches('0 10 1 * *', date)).toBe(false);
// dom=*, dow=1 → AND, dow doesn't match
expect(matches('0 10 * * 1', date)).toBe(false);
});
it('matches every-N-minutes pattern', () => {
const date0 = new Date(2025, 0, 15, 10, 0);
const date5 = new Date(2025, 0, 15, 10, 5);
const date3 = new Date(2025, 0, 15, 10, 3);
expect(matches('*/5 * * * *', date0)).toBe(true);
expect(matches('*/5 * * * *', date5)).toBe(true);
expect(matches('*/5 * * * *', date3)).toBe(false);
});
});
describe('nextFireTime', () => {
it('finds next minute for * * * * *', () => {
const now = new Date(2025, 0, 15, 10, 30, 15); // 10:30:15
const next = nextFireTime('* * * * *', now);
expect(next.getHours()).toBe(10);
expect(next.getMinutes()).toBe(31);
expect(next.getSeconds()).toBe(0);
});
it('finds next match for specific minute', () => {
const now = new Date(2025, 0, 15, 10, 30, 0);
const next = nextFireTime('45 * * * *', now);
expect(next.getHours()).toBe(10);
expect(next.getMinutes()).toBe(45);
});
it('rolls to next hour when no match in current hour', () => {
const now = new Date(2025, 0, 15, 10, 50, 0);
const next = nextFireTime('15 * * * *', now);
expect(next.getHours()).toBe(11);
expect(next.getMinutes()).toBe(15);
});
it('finds next match for every-5-minutes', () => {
const now = new Date(2025, 0, 15, 10, 31, 0);
const next = nextFireTime('*/5 * * * *', now);
expect(next.getMinutes()).toBe(35);
});
it('finds next match for specific hour', () => {
const now = new Date(2025, 0, 15, 10, 30, 0);
const next = nextFireTime('0 14 * * *', now);
expect(next.getHours()).toBe(14);
expect(next.getMinutes()).toBe(0);
expect(next.getDate()).toBe(15);
});
it('rolls to next day for past time', () => {
const now = new Date(2025, 0, 15, 15, 0, 0); // 3pm
const next = nextFireTime('0 9 * * *', now); // 9am daily
expect(next.getDate()).toBe(16);
expect(next.getHours()).toBe(9);
});
it('returns time strictly after the input', () => {
// Even if `after` is exactly on a match minute, next should be the following match
const now = new Date(2025, 0, 15, 10, 0, 0); // exactly 10:00:00
const next = nextFireTime('*/5 * * * *', now);
expect(next.getTime()).toBeGreaterThan(now.getTime());
});
});

View file

@ -0,0 +1,186 @@
/**
* Minimal 5-field cron expression parser.
*
* Fields: minute (0-59), hour (0-23), day-of-month (1-31), month (1-12), day-of-week (0-7, 0 and 7=Sun)
* Supports: *, single values, steps (asterisk/N), ranges (a-b), comma lists (a,b,c)
* No extended syntax (L, W, ?, name aliases).
*/
interface CronFields {
minute: Set<number>;
hour: Set<number>;
dayOfMonth: Set<number>;
month: Set<number>;
dayOfWeek: Set<number>;
/** True when the day-of-month field was literally '*' (unrestricted). */
domIsWild: boolean;
/** True when the day-of-week field was literally '*' (unrestricted). */
dowIsWild: boolean;
}
const FIELD_RANGES: Array<[number, number]> = [
[0, 59], // minute
[0, 23], // hour
[1, 31], // day of month
[1, 12], // month
[0, 7], // day of week (0 and 7 both mean Sunday)
];
/**
* Parses a single cron field into a set of matching values.
* Supports: star, single values, steps (star/N), ranges (a-b), comma lists.
*/
function parseField(field: string, min: number, max: number): Set<number> {
const values = new Set<number>();
for (const part of field.split(',')) {
const trimmed = part.trim();
if (!trimmed) {
throw new Error(`Empty field segment in "${field}"`);
}
// Handle step: */N or range/N or value/N
const stepParts = trimmed.split('/');
if (stepParts.length > 2) {
throw new Error(`Invalid step expression: "${trimmed}"`);
}
let rangeStart: number;
let rangeEnd: number;
const base = stepParts[0]!;
if (base === '*') {
rangeStart = min;
rangeEnd = max;
} else if (base.includes('-')) {
const [startStr, endStr] = base.split('-');
rangeStart = parseInt(startStr!, 10);
rangeEnd = parseInt(endStr!, 10);
if (isNaN(rangeStart) || isNaN(rangeEnd)) {
throw new Error(`Invalid range: "${base}"`);
}
if (rangeStart < min || rangeEnd > max || rangeStart > rangeEnd) {
throw new Error(`Range ${base} out of bounds [${min}-${max}]`);
}
} else {
const val = parseInt(base, 10);
if (isNaN(val) || val < min || val > max) {
throw new Error(`Value "${base}" out of bounds [${min}-${max}]`);
}
rangeStart = val;
rangeEnd = val;
}
const step = stepParts.length === 2 ? parseInt(stepParts[1]!, 10) : 1;
if (isNaN(step) || step <= 0) {
throw new Error(`Invalid step: "${stepParts[1]}"`);
}
for (let i = rangeStart; i <= rangeEnd; i += step) {
values.add(i);
}
}
return values;
}
/**
* Parses a 5-field cron expression into structured fields.
* Throws on invalid expressions.
*/
export function parseCron(cronExpr: string): CronFields {
const parts = cronExpr.trim().split(/\s+/);
if (parts.length !== 5) {
throw new Error(
`Cron expression must have exactly 5 fields, got ${parts.length}: "${cronExpr}"`,
);
}
// Parse day-of-week with range 0-7, then normalize 7 → 0 (both mean Sunday)
const dayOfWeek = parseField(
parts[4]!,
FIELD_RANGES[4]![0],
FIELD_RANGES[4]![1],
);
if (dayOfWeek.has(7)) {
dayOfWeek.delete(7);
dayOfWeek.add(0);
}
return {
minute: parseField(parts[0]!, FIELD_RANGES[0]![0], FIELD_RANGES[0]![1]),
hour: parseField(parts[1]!, FIELD_RANGES[1]![0], FIELD_RANGES[1]![1]),
dayOfMonth: parseField(parts[2]!, FIELD_RANGES[2]![0], FIELD_RANGES[2]![1]),
month: parseField(parts[3]!, FIELD_RANGES[3]![0], FIELD_RANGES[3]![1]),
dayOfWeek,
domIsWild: parts[2]!.trim() === '*',
dowIsWild: parts[4]!.trim() === '*',
};
}
/**
* Returns true if the given date matches the cron expression.
*
* Follows vixie-cron day semantics: when both day-of-month and day-of-week
* are constrained (neither is `*`), the date matches if EITHER field matches.
* When only one is constrained, it must match.
*/
export function matches(cronExpr: string, date: Date): boolean {
const fields = parseCron(cronExpr);
if (
!fields.minute.has(date.getMinutes()) ||
!fields.hour.has(date.getHours()) ||
!fields.month.has(date.getMonth() + 1)
) {
return false;
}
const domMatch = fields.dayOfMonth.has(date.getDate());
const dowMatch = fields.dayOfWeek.has(date.getDay());
// Vixie-cron: if both day-of-month and day-of-week are restricted,
// match if EITHER is satisfied. Otherwise use AND.
if (!fields.domIsWild && !fields.dowIsWild) {
return domMatch || dowMatch;
}
return domMatch && dowMatch;
}
/**
* Returns the next fire time after `after` for the given cron expression.
* Scans forward minute-by-minute (up to ~4 years) to find the next match.
*/
export function nextFireTime(cronExpr: string, after: Date): Date {
const fields = parseCron(cronExpr);
// Start at the next whole minute after `after`
const candidate = new Date(after.getTime());
candidate.setSeconds(0, 0);
candidate.setMinutes(candidate.getMinutes() + 1);
// Scan up to 4 years (~2.1M minutes) to avoid infinite loops
const maxIterations = 4 * 366 * 24 * 60;
for (let i = 0; i < maxIterations; i++) {
const minuteOk = fields.minute.has(candidate.getMinutes());
const hourOk = fields.hour.has(candidate.getHours());
const monthOk = fields.month.has(candidate.getMonth() + 1);
const domOk = fields.dayOfMonth.has(candidate.getDate());
const dowOk = fields.dayOfWeek.has(candidate.getDay());
// Vixie-cron day semantics: OR when both constrained, AND otherwise
const dayOk =
!fields.domIsWild && !fields.dowIsWild ? domOk || dowOk : domOk && dowOk;
if (minuteOk && hourOk && monthOk && dayOk) {
return candidate;
}
candidate.setMinutes(candidate.getMinutes() + 1);
}
throw new Error(
`No matching fire time found within 4 years for: "${cronExpr}"`,
);
}