feat(scheduler): make recurring cron/loop job expiration configurable (#6173)

* feat(scheduler): make recurring cron/loop job expiration configurable

Recurring cron/loop jobs previously auto-expired after a hardcoded 7 days with no way to extend or disable the limit, forcing long-running daemon deployments to recreate jobs weekly.

Add an experimental.cronRecurringMaxAgeDays setting (default 7) and a QWEN_CODE_CRON_MAX_AGE_DAYS environment-variable override (takes precedence, for cloud/container deployments). A value of 0 disables expiry so jobs run until deleted; negative or unparseable values fall back to the 7-day default. The configured limit also applies to durable tasks restored from disk, and the CronCreate tool description now reflects the effective limit instead of a hardcoded '7 days'.

Closes #6167

* refactor(scheduler): drop unused DEFAULT_RECURRING_MAX_AGE_MS export

Review feedback on #6173 — the ms constant is only used inside the module as the constructor default; keep only the days constant public.

* fix(scheduler): align zero max-age semantics and warn on cron expiry misconfiguration

Review feedback on #6173:

- CronScheduler constructor now maps 0 to Infinity (never expire), matching the config layer instead of silently substituting the 7-day default for direct callers.
- Invalid QWEN_CODE_CRON_MAX_AGE_DAYS / cronRecurringMaxAgeDays values now log a warning before falling back to the default, leaving a breadcrumb for misconfigured deployments.
- Durable tasks found past the recurring max age at load now log a warning before their final fire + delete, since a lowered max age retroactively expires long-lived tasks and deletion is unrecoverable.
- New durable-restore tests: a custom max age applies to tasks reloaded from disk, and a disabled max age (Infinity) restores a 30-day-old task as live instead of aging it out.

* fix(scheduler): surface cron expiry warnings on the console

Review feedback on #6173:

- The invalid-config and retroactive-expiry warnings moved from debugLogger (file-only, off unless QWEN_DEBUG_LOG_FILE is set) to console.warn so they reach container/daemon logs where this knob matters; the config warning is latched to fire once per Config instance.
- CronCreateTool's constructor now resolves the max age once instead of three times, so an invalid env var can't emit duplicate warnings during tool registration.

* fix(scheduler): reject non-finite timestamps in durable task validation

Review feedback on #6173: JSON like -1e999 parses to -Infinity, which passes the typeof-number check and then poisons date math — with a finite lastFiredAt the entry reads as an overdue aged task and the retroactive-expiry warning's toISOString() throws RangeError mid-load, so one malformed persisted entry blocks durable cron startup/takeover. Validation now requires finite createdAt (and lastFiredAt when non-null), routing such entries through the existing fix-or-delete contract for corrupt files. Verified the new end-to-end test reproduces the RangeError without the fix.

* refactor(scheduler): single-source the max-age contract and freeze it at Config construction

Review follow-ups on #6173:
- Extract normalizeRecurringMaxAge as the single owner of the
  0/Infinity no-expiry contract, used by both the Config layer and the
  CronScheduler constructor, so the constructor's 0 handling can no
  longer be removed as apparent dead code.
- Resolve QWEN_CODE_CRON_MAX_AGE_DAYS once at Config construction into
  a readonly field, honoring the setting's requiresRestart contract;
  mid-session env changes can no longer make the tool description,
  tool output, and scheduler report different expiries. The warn
  once-latch is now unnecessary (construction warns at most once).
This commit is contained in:
Tianyuan 2026-07-03 14:34:37 +08:00 committed by GitHub
parent 9605bcc25f
commit 0633b8a985
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 474 additions and 35 deletions

View file

@ -498,12 +498,13 @@ LSP server configuration is done through `.lsp.json` files in your project root
>
> **Experimental features.** These toggles gate in-development capabilities and may change or be removed in future releases.
| Setting | Type | Description | Default |
| ----------------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
| `experimental.cron` | boolean | Enable in-session cron/loop tools (`cron_create`, `cron_list`, `cron_delete`) so the model can create recurring prompts. Can be disabled via the `QWEN_CODE_DISABLE_CRON=1` environment variable. Requires restart. | `true` |
| `experimental.agentTeam` | boolean | Enable agent-team collaboration tools (`team_create`, `task_create`, `task_update`, `send_message`, etc.) for multi-agent coordination. Can also be enabled via `QWEN_CODE_ENABLE_AGENT_TEAM=1`. Requires restart. | `false` |
| `experimental.artifact` | boolean | Enable the Artifact tool, letting the model publish a self-contained HTML page and open it in the browser. Interactive, non-SDK sessions only. Toggle via `QWEN_CODE_ENABLE_ARTIFACT=1` / `QWEN_CODE_DISABLE_ARTIFACT=1`. Requires restart. | `false` |
| `experimental.emitToolUseSummaries` | boolean | Generate a short LLM-based label after each tool-call batch completes. See [Tool-Use Summaries](../features/tool-use-summaries). Requires a fast model to be configured (`fastModel`); silently skipped otherwise. Can be overridden per-session with `QWEN_CODE_EMIT_TOOL_USE_SUMMARIES=0` or `=1`. | `true` |
| Setting | Type | Description | Default |
| -------------------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
| `experimental.cron` | boolean | Enable in-session cron/loop tools (`cron_create`, `cron_list`, `cron_delete`) so the model can create recurring prompts. Can be disabled via the `QWEN_CODE_DISABLE_CRON=1` environment variable. Requires restart. | `true` |
| `experimental.cronRecurringMaxAgeDays` | number | Days a recurring cron/loop job lives before auto-expiring (it fires one final time, then is deleted). Set to `0` to disable expiry so jobs run until deleted — useful for long-running daemon deployments. Can be overridden via the `QWEN_CODE_CRON_MAX_AGE_DAYS` environment variable. Requires restart. | `7` |
| `experimental.agentTeam` | boolean | Enable agent-team collaboration tools (`team_create`, `task_create`, `task_update`, `send_message`, etc.) for multi-agent coordination. Can also be enabled via `QWEN_CODE_ENABLE_AGENT_TEAM=1`. Requires restart. | `false` |
| `experimental.artifact` | boolean | Enable the Artifact tool, letting the model publish a self-contained HTML page and open it in the browser. Interactive, non-SDK sessions only. Toggle via `QWEN_CODE_ENABLE_ARTIFACT=1` / `QWEN_CODE_DISABLE_ARTIFACT=1`. Requires restart. | `false` |
| `experimental.emitToolUseSummaries` | boolean | Generate a short LLM-based label after each tool-call batch completes. See [Tool-Use Summaries](../features/tool-use-summaries). Requires a fast model to be configured (`fastModel`); silently skipped otherwise. Can be overridden per-session with `QWEN_CODE_EMIT_TOOL_USE_SUMMARIES=0` or `=1`. | `true` |
#### mcpServers

View file

@ -117,9 +117,11 @@ To avoid every session hitting the API at the same wall-clock moment, the schedu
The offset is derived from the task ID, so the same task always gets the same offset. If exact timing matters, pick a minute that is not `:00` or `:30`, for example `3 9 * * *` instead of `0 9 * * *`, and the one-shot jitter will not apply.
### Three-day expiry
### Recurring-task expiry
Recurring tasks automatically expire 3 days after creation. The task fires one final time, then deletes itself. This bounds how long a forgotten loop can run. If you need a recurring task to last longer, cancel and recreate it before it expires.
Recurring tasks automatically expire 7 days after creation by default. The task fires one final time, then deletes itself. This bounds how long a forgotten loop can run.
To change the limit, set `experimental.cronRecurringMaxAgeDays` in your [settings](../configuration/settings.md), or set the `QWEN_CODE_CRON_MAX_AGE_DAYS` environment variable (the environment variable wins — convenient for cloud or container deployments where editing `settings.json` is impractical). A value of `0` disables expiry entirely, so tasks run until you delete them — useful for long-running daemon deployments that host daily reports, digests, or ongoing monitoring. The configured limit also applies to durable tasks restored from disk after a restart.
One-shot tasks do not expire on a timer — they simply delete themselves after firing once.

View file

@ -2037,6 +2037,7 @@ export async function loadCliConfig(
maxSubagentDepth: resolveMaxSubagentDepth(argv, settings),
experimentalZedIntegration: argv.acp || argv.experimentalAcp || false,
cronEnabled: settings.experimental?.cron ?? true,
cronRecurringMaxAgeDays: settings.experimental?.cronRecurringMaxAgeDays,
agentTeamEnabled: settings.experimental?.agentTeam ?? false,
artifactEnabled: settings.experimental?.artifact ?? false,
artifactAutoOpen: settings.artifact?.autoOpen ?? true,

View file

@ -2988,6 +2988,23 @@ const SETTINGS_SCHEMA = {
'Enable in-session cron/loop tools. When enabled, the model can create recurring prompts using cron_create, cron_list, and cron_delete tools. Can be disabled via QWEN_CODE_DISABLE_CRON=1 environment variable.',
showInDialog: true,
},
cronRecurringMaxAgeDays: {
type: 'number',
label: 'Recurring Cron Max Age (Days)',
category: 'Experimental',
requiresRestart: true,
default: 7,
description:
'Days a recurring cron/loop job lives before auto-expiring (it fires one final time, then is deleted). Set to 0 to disable expiry so jobs run until deleted — useful for long-running daemon deployments. Can be overridden via the QWEN_CODE_CRON_MAX_AGE_DAYS environment variable.',
// A deployment-time knob (cloud daemons, containers), not a common
// interactive preference.
showInDialog: false,
jsonSchemaOverride: {
type: 'number',
minimum: 0,
default: 7,
},
},
agentTeam: {
type: 'boolean',
label: 'Enable Agent Team',

View file

@ -610,6 +610,91 @@ describe('Server Config (config.ts)', () => {
});
});
describe('getCronRecurringMaxAgeDays', () => {
const prevEnv = process.env['QWEN_CODE_CRON_MAX_AGE_DAYS'];
afterEach(() => {
if (prevEnv === undefined) {
delete process.env['QWEN_CODE_CRON_MAX_AGE_DAYS'];
} else {
process.env['QWEN_CODE_CRON_MAX_AGE_DAYS'] = prevEnv;
}
});
it('defaults to 7 days and follows the setting', () => {
delete process.env['QWEN_CODE_CRON_MAX_AGE_DAYS'];
expect(new Config(baseParams).getCronRecurringMaxAgeDays()).toBe(7);
expect(
new Config({
...baseParams,
cronRecurringMaxAgeDays: 30,
}).getCronRecurringMaxAgeDays(),
).toBe(30);
});
it('maps 0 to Infinity (no expiry)', () => {
delete process.env['QWEN_CODE_CRON_MAX_AGE_DAYS'];
expect(
new Config({
...baseParams,
cronRecurringMaxAgeDays: 0,
}).getCronRecurringMaxAgeDays(),
).toBe(Infinity);
});
it('QWEN_CODE_CRON_MAX_AGE_DAYS overrides the setting', () => {
process.env['QWEN_CODE_CRON_MAX_AGE_DAYS'] = '90';
expect(
new Config({
...baseParams,
cronRecurringMaxAgeDays: 30,
}).getCronRecurringMaxAgeDays(),
).toBe(90);
process.env['QWEN_CODE_CRON_MAX_AGE_DAYS'] = '0';
expect(new Config(baseParams).getCronRecurringMaxAgeDays()).toBe(
Infinity,
);
});
it('falls back to the default on invalid values', () => {
process.env['QWEN_CODE_CRON_MAX_AGE_DAYS'] = 'not-a-number';
expect(new Config(baseParams).getCronRecurringMaxAgeDays()).toBe(7);
delete process.env['QWEN_CODE_CRON_MAX_AGE_DAYS'];
expect(
new Config({
...baseParams,
cronRecurringMaxAgeDays: -3,
}).getCronRecurringMaxAgeDays(),
).toBe(7);
});
it('warns on the console once at construction for an invalid value', () => {
process.env['QWEN_CODE_CRON_MAX_AGE_DAYS'] = 'not-a-number';
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
const config = new Config(baseParams);
// The warning fires during construction, before any getter call,
// and repeated getter calls do not re-emit it.
expect(config.getCronRecurringMaxAgeDays()).toBe(7);
expect(config.getCronRecurringMaxAgeDays()).toBe(7);
const cronWarnings = warnSpy.mock.calls.filter((call) =>
String(call[0]).includes('QWEN_CODE_CRON_MAX_AGE_DAYS'),
);
expect(cronWarnings).toHaveLength(1);
} finally {
warnSpy.mockRestore();
}
});
it('resolves once at construction, ignoring later env changes (requiresRestart)', () => {
process.env['QWEN_CODE_CRON_MAX_AGE_DAYS'] = '90';
const config = new Config(baseParams);
process.env['QWEN_CODE_CRON_MAX_AGE_DAYS'] = '3';
expect(config.getCronRecurringMaxAgeDays()).toBe(90);
delete process.env['QWEN_CODE_CRON_MAX_AGE_DAYS'];
expect(config.getCronRecurringMaxAgeDays()).toBe(90);
});
});
describe('getTeamMemorySyncEnabled', () => {
const prevEnv = process.env['QWEN_CODE_MEMORY_TEAM_SYNC'];
afterEach(() => {

View file

@ -53,7 +53,11 @@ import {
} from '../services/fileSystemService.js';
import { GitWorktreeService } from '../services/gitWorktreeService.js';
import { cleanupStaleAgentWorktrees } from '../services/worktreeCleanup.js';
import { CronScheduler } from '../services/cronScheduler.js';
import {
CronScheduler,
DEFAULT_RECURRING_MAX_AGE_DAYS,
normalizeRecurringMaxAge,
} from '../services/cronScheduler.js';
import {
MemoryPressureMonitor,
DEFAULT_PRESSURE_CONFIG,
@ -954,6 +958,11 @@ export interface ConfigParameters {
sessionTokenLimit?: number;
experimentalZedIntegration?: boolean;
cronEnabled?: boolean;
/**
* Days a recurring cron job lives before auto-expiring. `0` disables
* expiry. Unset or invalid falls back to the 7-day default.
*/
cronRecurringMaxAgeDays?: number;
agentTeamEnabled?: boolean;
workflowsEnabled?: boolean;
artifactEnabled?: boolean;
@ -1324,6 +1333,40 @@ function resolveSensitiveSpanAttributeMaxLength(
return value;
}
/**
* Resolves the recurring cron max age (in days) once at Config
* construction the setting declares `requiresRestart`, so re-reading
* the environment per call could let the tool description, tool output,
* and scheduler each report a different expiry if the env var changed
* mid-session. The QWEN_CODE_CRON_MAX_AGE_DAYS environment variable
* overrides the settings value (convenient for cloud/container
* deployments). `normalizeRecurringMaxAge` owns the `0 → Infinity`
* (no expiry) contract shared with the CronScheduler constructor.
* Negative or unparseable values fall back to the 7-day default with a
* console warning debug file logging is usually off in the daemon
* deployments this knob targets, and the misconfiguration would
* otherwise surface only as "jobs stopped firing after 7 days".
*/
function resolveCronRecurringMaxAgeDays(setting: number | undefined): number {
const env = process.env['QWEN_CODE_CRON_MAX_AGE_DAYS'];
const fromEnv = env !== undefined && env.trim() !== '';
const raw = fromEnv ? Number(env) : setting;
if (raw === undefined || !Number.isFinite(raw) || raw < 0) {
if (raw !== undefined) {
// eslint-disable-next-line no-console -- operator-facing misconfiguration breadcrumb; debug file logging is usually off in daemon deployments
console.warn(
(fromEnv
? `QWEN_CODE_CRON_MAX_AGE_DAYS="${env}" is invalid`
: `cronRecurringMaxAgeDays=${setting} is invalid`) +
`; recurring cron jobs will expire after the ` +
`${DEFAULT_RECURRING_MAX_AGE_DAYS}-day default.`,
);
}
return DEFAULT_RECURRING_MAX_AGE_DAYS;
}
return normalizeRecurringMaxAge(raw, DEFAULT_RECURRING_MAX_AGE_DAYS);
}
export class Config {
private sessionId: string;
private sessionData?: ResumedSessionData;
@ -1524,6 +1567,9 @@ export class Config {
private runtimeStatusEnabled = false;
private readonly experimentalZedIntegration: boolean = false;
private readonly cronEnabled: boolean = true;
/** Recurring cron max age in days, resolved once at construction
* (the setting declares `requiresRestart`); `Infinity` = no expiry. */
private readonly cronRecurringMaxAgeDays: number;
private readonly agentTeamEnabled: boolean = false;
private readonly artifactEnabled: boolean = false;
private readonly artifactAutoOpen: boolean = true;
@ -1759,6 +1805,9 @@ export class Config {
this.experimentalZedIntegration =
params.experimentalZedIntegration ?? false;
this.cronEnabled = params.cronEnabled ?? true;
this.cronRecurringMaxAgeDays = resolveCronRecurringMaxAgeDays(
params.cronRecurringMaxAgeDays,
);
this.agentTeamEnabled = params.agentTeamEnabled ?? false;
this.artifactEnabled = params.artifactEnabled ?? false;
this.artifactAutoOpen = params.artifactAutoOpen ?? true;
@ -4924,11 +4973,24 @@ export class Config {
getCronScheduler(): CronScheduler {
if (!this.cronScheduler) {
this.cronScheduler = new CronScheduler(this.getProjectRoot());
this.cronScheduler = new CronScheduler(
this.getProjectRoot(),
this.getCronRecurringMaxAgeDays() * 24 * 60 * 60 * 1000,
);
}
return this.cronScheduler;
}
/**
* Days a recurring cron job lives before auto-expiring; `Infinity`
* means no expiry. Resolved once at construction (see
* `resolveCronRecurringMaxAgeDays`) so mid-session env changes cannot
* make the tool description, tool output, and scheduler disagree.
*/
getCronRecurringMaxAgeDays(): number {
return this.cronRecurringMaxAgeDays;
}
isCronEnabled(): boolean {
if (process.env['QWEN_CODE_DISABLE_CRON'] === '1') return false;
return this.cronEnabled;

View file

@ -291,6 +291,65 @@ describe('CronScheduler', () => {
expect(fired).toHaveLength(1);
});
it('honors a configured recurring max age', () => {
const oneDayMs = 24 * 60 * 60 * 1000;
const custom = new CronScheduler(null, oneDayMs);
try {
const job = custom.create('*/1 * * * *', 'short-lived', true);
expect(job.expiresAt - job.createdAt).toBe(oneDayMs);
const fired: CronJob[] = [];
custom.start((j) => fired.push(j));
custom.tick(new Date(job.expiresAt + 1000));
expect(fired).toHaveLength(1);
expect(custom.list()).toHaveLength(0);
} finally {
custom.destroy();
}
});
it('never expires recurring jobs when max age is Infinity', () => {
const custom = new CronScheduler(null, Infinity);
try {
const job = custom.create('*/1 * * * *', 'immortal', true);
expect(job.expiresAt).toBe(Infinity);
const fired: CronJob[] = [];
custom.start((j) => fired.push(j));
// Well past the 7-day default — still recurring, not removed.
custom.tick(new Date(job.createdAt + 30 * 24 * 60 * 60 * 1000));
expect(fired).toHaveLength(1);
expect(custom.list()).toHaveLength(1);
} finally {
custom.destroy();
}
});
it('treats a zero max age as never expiring, matching the config layer', () => {
// normalizeRecurringMaxAge owns the `0 → Infinity` contract for
// both the config layer and this constructor, so a direct caller
// passing 0 gets disabled expiry, not a silent 7-day default.
const custom = new CronScheduler(null, 0);
try {
const job = custom.create('*/1 * * * *', 'zero means never', true);
expect(job.expiresAt).toBe(Infinity);
} finally {
custom.destroy();
}
});
it('falls back to the default max age on invalid input', () => {
for (const bad of [-5, NaN]) {
const custom = new CronScheduler(null, bad);
try {
const job = custom.create('*/1 * * * *', 'guarded', true);
expect(job.expiresAt - job.createdAt).toBe(7 * 24 * 60 * 60 * 1000);
} finally {
custom.destroy();
}
}
});
it('fires in next minute after first fire', () => {
const fired: CronJob[] = [];
scheduler.start((job) => fired.push(job));
@ -914,6 +973,70 @@ describe('CronScheduler', () => {
);
}
it('survives a tasks file with a non-finite createdAt', async () => {
// JSON -1e999 parses to -Infinity. With a finite lastFiredAt the
// entry reads as an overdue, already-aged recurring task — the path
// that formats createdAt into the retroactive-expiry warning. The
// read layer must reject it (fix-or-delete contract) so enableDurable
// completes instead of throwing RangeError mid-load, and the file
// must be left on disk for the user to repair.
const raw =
`[{"id":"poison","cron":"* * * * *","prompt":"p","recurring":true,` +
`"createdAt":-1e999,"lastFiredAt":${Date.now() - 120_000}}]`;
const filePath = getCronFilePath(tmpDir);
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, raw);
await expect(scheduler.enableDurable('session-1')).resolves.not.toThrow();
expect(scheduler.list()).toHaveLength(0);
expect(await fs.readFile(filePath, 'utf-8')).toBe(raw);
});
it('applies a configured max age to durable tasks restored from disk', async () => {
// The reload path is the one that matters for configurability: a
// regression that ignores the passed max age here would silently
// revert every restored task to 7-day expiry after a restart.
const twoDaysMs = 2 * 24 * 60 * 60 * 1000;
const custom = new CronScheduler(tmpDir, twoDaysMs);
try {
// lastFiredAt now: not overdue, so no catch-up/final delivery races.
await writeCronTasks(tmpDir, [
{ ...diskTask('shortlived'), lastFiredAt: Date.now() },
]);
await custom.enableDurable('session-1');
const job = custom.list().find((j) => j.id === 'shortlived');
expect(job).toBeDefined();
expect(job!.expiresAt - job!.createdAt).toBe(twoDaysMs);
} finally {
custom.destroy();
}
});
it('restores durable tasks without expiry when max age is disabled', async () => {
const custom = new CronScheduler(tmpDir, Infinity);
try {
// Created well past the 7-day default: with expiry disabled the
// task must be restored as a live job, not aged out into a final
// fire + delete.
await writeCronTasks(tmpDir, [
{
...diskTask('immortal'),
createdAt: Date.now() - 30 * 24 * 60 * 60 * 1000,
lastFiredAt: Date.now(),
},
]);
await custom.enableDurable('session-1');
const job = custom.list().find((j) => j.id === 'immortal');
expect(job).toBeDefined();
expect(job!.expiresAt).toBe(Infinity);
expect(await readCronTasks(tmpDir)).toHaveLength(1);
} finally {
custom.destroy();
}
});
it('owner fires durable tasks loaded from disk and persists lastFiredAt', async () => {
await writeCronTasks(tmpDir, [diskTask('disktask')]);
await scheduler.enableDurable('session-1');

View file

@ -26,11 +26,27 @@ import { tryAcquireLock, releaseLock } from './cronTasksLock.js';
const debugLogger = createDebugLogger('CRON_SCHEDULER');
const MAX_JOBS = 50;
// Recurring jobs auto-expire this long after creation (claw-code parity:
// covers "check my PRs every hour this week" while bounding how long a
// forgotten schedule keeps firing). Age is evaluated at fire time — an
// aged job fires one final time, then is deleted.
const RECURRING_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
export const DEFAULT_RECURRING_MAX_AGE_DAYS = 7;
// Recurring jobs auto-expire this long after creation by default (claw-code
// parity: covers "check my PRs every hour this week" while bounding how long
// a forgotten schedule keeps firing). Age is evaluated at fire time — an
// aged job fires one final time, then is deleted. Overridable per scheduler
// instance (see the constructor); Infinity disables expiry.
const DEFAULT_RECURRING_MAX_AGE_MS =
DEFAULT_RECURRING_MAX_AGE_DAYS * 24 * 60 * 60 * 1000;
/** Single owner of the recurring-expiry contract, shared with the config
* layer: `0` and `Infinity` both disable expiry, positive values pass
* through, and negative or NaN input falls back to `fallback`.
* Unit-agnostic the config layer normalizes days, the scheduler
* constructor milliseconds. */
export function normalizeRecurringMaxAge(
value: number,
fallback: number,
): number {
if (value === 0) return Infinity;
return value > 0 ? value : fallback;
}
// 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.
@ -244,10 +260,27 @@ export class CronScheduler {
// successor reading the file pre-write would re-run the same work.
private pendingPersist: Promise<void> = Promise.resolve();
/** Age after which recurring jobs expire, evaluated at fire time.
* Infinity = never expire. Guarded in the constructor. */
private readonly recurringMaxAgeMs: number;
/** `projectRoot` anchors durable storage; without it only session-only
* jobs work. Production constructs via `Config.getCronScheduler()`,
* which always supplies it. */
constructor(private readonly projectRoot: string | null = null) {}
* which always supplies it (and the configured `recurringMaxAgeMs`).
* `normalizeRecurringMaxAge` owns the expiry contract for both this
* constructor and the config layer: `0` and `Infinity` both mean
* "never expire" so a direct caller passing `0` gets disabled
* expiry, not the default while negative or NaN input falls back to
* the 7-day default rather than expiring everything at birth. */
constructor(
private readonly projectRoot: string | null = null,
recurringMaxAgeMs: number = DEFAULT_RECURRING_MAX_AGE_MS,
) {
this.recurringMaxAgeMs = normalizeRecurringMaxAge(
recurringMaxAgeMs,
DEFAULT_RECURRING_MAX_AGE_MS,
);
}
/**
* Creates a new session-only cron job. Returns the created job.
@ -270,7 +303,7 @@ export class CronScheduler {
prompt,
recurring,
createdAt: now,
expiresAt: recurring ? now + RECURRING_MAX_AGE_MS : Infinity,
expiresAt: recurring ? now + this.recurringMaxAgeMs : Infinity,
// Prevent the scheduler from firing during the creation minute
lastFiredAt: now - (now % 60_000),
jitterMs,
@ -635,9 +668,23 @@ export class CronScheduler {
// delivery covers every consumer — interactive, headless, and
// ACP enqueue whatever `prompt` holds.
missedOneShots.push(t);
} else if (now - t.createdAt >= RECURRING_MAX_AGE_MS) {
} else if (now - t.createdAt >= this.recurringMaxAgeMs) {
// Aged out while overdue — fires raw one final time, then is
// deleted (same contract as an aged fire from the tick loop).
// Warn with the creation time: a lowered recurringMaxAgeMs
// (settings change or a dropped env override) retroactively
// expires long-lived tasks here, and once deleted they cannot
// be recovered — this log is the only breadcrumb. console
// rather than debugLogger: debug file logging is usually off
// in the daemon deployments where this matters, and the
// deletion is irreversible.
// eslint-disable-next-line no-console -- operator-facing breadcrumb for an unrecoverable deletion
console.warn(
`Durable cron task ${t.id} (created ${new Date(
t.createdAt,
).toISOString()}) is past the recurring max age at load; ` +
'it will fire one final time and be deleted.',
);
finalTasks.push(t);
} else {
// Overdue recurring — fire raw once now and resume the normal
@ -691,7 +738,7 @@ export class CronScheduler {
);
continue;
}
const job = durableTaskToJob(task, existing);
const job = durableTaskToJob(task, this.recurringMaxAgeMs, existing);
if (existing?.lastFiredAt !== undefined) {
job.lastFiredAt = Math.max(existing.lastFiredAt, job.lastFiredAt ?? 0);
}
@ -720,7 +767,9 @@ export class CronScheduler {
if (finalTasks.length > 0) {
this.fireOrBuffer({
kind: 'final',
jobs: finalTasks.map((t) => durableTaskToJob(t)),
jobs: finalTasks.map((t) =>
durableTaskToJob(t, this.recurringMaxAgeMs),
),
});
}
}
@ -757,7 +806,7 @@ export class CronScheduler {
// surfaces and runs them instead of losing the task permanently.
const skipped: string[] = [];
const runnable = pending.tasks.filter((t) => {
const job = durableTaskToJob(t);
const job = durableTaskToJob(t, this.recurringMaxAgeMs);
// `job.durable &&` mirrors catch-up/final/tick — durableTaskToJob always
// sets durable, so it's a no-op today, but keeps the four skip sites
// identical so a future non-durable carrier can't be silently dropped.
@ -778,7 +827,7 @@ export class CronScheduler {
for (const id of skipped) this.pendingRemoval.delete(id);
if (runnable.length > 0) {
onFire({
...durableTaskToJob(runnable[0]!),
...durableTaskToJob(runnable[0]!, this.recurringMaxAgeMs),
prompt: buildMissedCronNotification(runnable),
missed: true,
});
@ -1268,7 +1317,11 @@ function hasParseableCron(task: DurableCronTask): boolean {
}
}
function durableTaskToJob(task: DurableCronTask, existing?: CronJob): CronJob {
function durableTaskToJob(
task: DurableCronTask,
recurringMaxAgeMs: number,
existing?: CronJob,
): CronJob {
// Jitter is deterministic per (id, cron, recurring) but costly to
// compute for sparse crons — carry it forward across reloads.
const jitterMs =
@ -1283,9 +1336,7 @@ function durableTaskToJob(task: DurableCronTask, existing?: CronJob): CronJob {
prompt: task.prompt,
recurring: task.recurring,
createdAt: task.createdAt,
expiresAt: task.recurring
? task.createdAt + RECURRING_MAX_AGE_MS
: Infinity,
expiresAt: task.recurring ? task.createdAt + recurringMaxAgeMs : Infinity,
lastFiredAt: task.lastFiredAt ?? undefined,
jitterMs,
durable: true,

View file

@ -115,6 +115,19 @@ describe('cronTasksFile', () => {
await expect(readCronTasks(tmpDir)).rejects.toThrow(/Invalid task entry/);
});
it('throws for non-finite timestamps', async () => {
// JSON.parse turns -1e999 into -Infinity — typeof number, but it
// poisons date math downstream (new Date(-Infinity).toISOString()
// throws mid-load). Must be rejected like any other corrupt field.
const raw = (createdAt: string, lastFiredAt: string) =>
`[{"id":"t1","cron":"* * * * *","prompt":"p","recurring":true,` +
`"createdAt":${createdAt},"lastFiredAt":${lastFiredAt}}]`;
await seedTasksFile(tmpDir, raw('-1e999', 'null'));
await expect(readCronTasks(tmpDir)).rejects.toThrow(/Invalid task entry/);
await seedTasksFile(tmpDir, raw(`${Date.now()}`, '1e999'));
await expect(readCronTasks(tmpDir)).rejects.toThrow(/Invalid task entry/);
});
it('reads valid tasks', async () => {
const task = makeTask();
await seedTasksFile(tmpDir, JSON.stringify([task]));

View file

@ -241,6 +241,15 @@ export async function removeCronTasks(
return removed;
}
// Finite, not just number: JSON like -1e999 parses to -Infinity, and a
// non-finite timestamp poisons downstream date math — new Date(...)
// .toISOString() throws mid-load, and age/expiry comparisons go
// degenerate. Rejecting the entry routes it through the same
// fix-or-delete contract as any other corrupt field.
function isFiniteTimestamp(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value);
}
function isValidTask(value: unknown): value is DurableCronTask {
if (typeof value !== 'object' || value === null) return false;
const obj = value as Record<string, unknown>;
@ -249,7 +258,7 @@ function isValidTask(value: unknown): value is DurableCronTask {
typeof obj['cron'] === 'string' &&
typeof obj['prompt'] === 'string' &&
typeof obj['recurring'] === 'boolean' &&
typeof obj['createdAt'] === 'number' &&
(obj['lastFiredAt'] === null || typeof obj['lastFiredAt'] === 'number')
isFiniteTimestamp(obj['createdAt']) &&
(obj['lastFiredAt'] === null || isFiniteTimestamp(obj['lastFiredAt']))
);
}

View file

@ -88,7 +88,7 @@ If the interval does not cleanly divide its unit (for example `7m` gives uneven
- `prompt`: the parsed prompt from above, verbatim (slash commands are passed through unchanged)
- `recurring`: `true`
- `durable`: `true` if the user's language implies persistence ("keep doing this", "set this up permanently", "every day even after restart"). Otherwise omit (defaults to session-only).
2. Briefly confirm: what is scheduled, the cron expression, the human-readable cadence, that recurring tasks auto-expire after 7 days, and that they can cancel sooner with CronDelete (include the job ID).
2. Briefly confirm: what is scheduled, the cron expression, the human-readable cadence, the auto-expiry (7 days after creation by default — the CronCreate tool description states the configured limit, which may differ or be disabled), and that they can cancel sooner with CronDelete (include the job ID).
3. Then immediately execute the parsed prompt now. Do not wait for the first cron fire.
- If it is a slash command, invoke it via the Skill tool.
- Otherwise, act on it directly.

View file

@ -9,10 +9,11 @@ import { Storage } from '../config/storage.js';
let tmpDir: string;
function makeConfig() {
const scheduler = new CronScheduler(tmpDir);
function makeConfig(maxAgeDays = 7) {
const scheduler = new CronScheduler(tmpDir, maxAgeDays * 24 * 60 * 60 * 1000);
return {
getCronScheduler: () => scheduler,
getCronRecurringMaxAgeDays: () => maxAgeDays,
getProjectRoot: () => tmpDir,
_scheduler: scheduler,
} as unknown as import('../config/config.js').Config & {
@ -54,6 +55,34 @@ describe('CronCreateTool', () => {
expect(config._scheduler.list()).toHaveLength(1);
});
it('reports a configured recurring max age in days', async () => {
config = makeConfig(30);
tool = new CronCreateTool(config);
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('Auto-expires after 30 days');
expect(tool.description).toContain('auto-expire after 30 days');
});
it('reports no expiry when the recurring max age is disabled', async () => {
config = makeConfig(Infinity);
tool = new CronCreateTool(config);
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('Never auto-expires');
expect(tool.description).toContain('never auto-expire');
const job = config._scheduler.list()[0]!;
expect(job.expiresAt).toBe(Infinity);
});
it('creates a one-shot job when recurring=false', async () => {
const invocation = tool.build({
cron: '*/1 * * * *',

View file

@ -11,6 +11,34 @@ import { parseCron, nextFireTime } from '../utils/cronParser.js';
import { humanReadableCron } from '../utils/cronDisplay.js';
import { CRON_TASKS_DISPLAY_PATH } from '../services/cronTasksFile.js';
/** "1 day" / "7 days" / "0.5 days". Callers handle the Infinity case. */
function formatDays(days: number): string {
return days === 1 ? '1 day' : `${days} days`;
}
/**
* Expiry paragraph for the tool description. The max age comes from config
* (settings / QWEN_CODE_CRON_MAX_AGE_DAYS) at construction time
* changing it requires a restart, so baking it into the static
* description is safe. Infinity (setting 0) disables expiry.
*/
function recurringExpiryBlurb(maxAgeDays: number): string {
if (!Number.isFinite(maxAgeDays)) {
return (
'Recurring tasks never auto-expire in this configuration — they keep ' +
'firing until deleted with CronDelete. Tell the user the job runs ' +
'until cancelled when scheduling recurring jobs.'
);
}
const span = formatDays(maxAgeDays);
return (
`Recurring tasks auto-expire after ${span} — they fire one final time, ` +
'then are deleted. This bounds how long a forgotten schedule keeps ' +
`firing. Tell the user about the ${span} limit when scheduling ` +
'recurring jobs.'
);
}
export interface CronCreateParams {
cron: string;
prompt: string;
@ -69,9 +97,13 @@ class CronCreateInvocation extends BaseToolInvocation<
const where = durable
? `Persisted to ${CRON_TASKS_DISPLAY_PATH}`
: 'Session-only (not written to disk, dies when Qwen Code exits)';
const maxAgeDays = this.config.getCronRecurringMaxAgeDays();
const expiry = Number.isFinite(maxAgeDays)
? `Auto-expires after ${formatDays(maxAgeDays)}. Use CronDelete to cancel sooner.`
: 'Never auto-expires. Use CronDelete to cancel.';
const llmContent = recurring
? `Scheduled recurring job ${job.id} (${job.cronExpr}). ${where}. ` +
'Auto-expires after 7 days. Use CronDelete to cancel sooner.'
expiry
: `Scheduled one-shot task ${job.id} (${job.cronExpr}). ${where}. ` +
'It will fire once then auto-delete.';
@ -94,6 +126,9 @@ export class CronCreateTool extends BaseDeclarativeTool<
static readonly Name = ToolNames.CRON_CREATE;
constructor(private config: Config) {
// Resolved once: each call re-reads/re-parses the env var and an
// invalid value would warn on every call site below.
const maxAgeDays = config.getCronRecurringMaxAgeDays();
super(
CronCreateTool.Name,
ToolDisplayNames.CRON_CREATE,
@ -120,7 +155,7 @@ export class CronCreateTool extends BaseDeclarativeTool<
'Most "remind me in 5 minutes" requests should stay session-only.\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 7 days — they fire one final time, then are deleted. This bounds how long a forgotten schedule keeps firing. Tell the user about the 7-day limit when scheduling recurring jobs.\n\n' +
`${recurringExpiryBlurb(maxAgeDays)}\n\n` +
'Returns a job ID you can pass to CronDelete.',
Kind.Other,
{
@ -138,7 +173,12 @@ export class CronCreateTool extends BaseDeclarativeTool<
recurring: {
type: 'boolean',
description:
'true (default) = fire on every cron match until deleted or auto-expired after 7 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.',
`true (default) = fire on every cron match until deleted${
Number.isFinite(maxAgeDays)
? ` or auto-expired after ${formatDays(maxAgeDays)}`
: ''
}. ` +
'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.',
},
durable: {
type: 'boolean',

View file

@ -2768,6 +2768,12 @@
"type": "boolean",
"default": true
},
"cronRecurringMaxAgeDays": {
"type": "number",
"minimum": 0,
"default": 7,
"description": "Days a recurring cron/loop job lives before auto-expiring (it fires one final time, then is deleted). Set to 0 to disable expiry so jobs run until deleted — useful for long-running daemon deployments. Can be overridden via the QWEN_CODE_CRON_MAX_AGE_DAYS environment variable."
},
"agentTeam": {
"description": "Enable agent team collaboration tools (experimental). When enabled, the model can create agent teams and coordinate work using team_create, team_delete, send_message, task_create, task_update, and task_list tools. Can also be enabled via QWEN_CODE_ENABLE_AGENT_TEAM=1 environment variable.",
"type": "boolean",