mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
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:
parent
9605bcc25f
commit
0633b8a985
14 changed files with 474 additions and 35 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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]));
|
||||
|
|
|
|||
|
|
@ -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']))
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 * * * *',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue