feat(schedule): local always-on /schedule daemon (routines that run without an open session) - #6125
feat(schedule): local always-on /schedule daemon (routines that run without an open session)#6125AmariahAK wants to merge 24 commits into
Conversation
- Add ScheduleTaskStore for SKILL.md-based task persistence - Add ScheduleDaemon wrapping CronScheduler with Infinity expiry - Add CLI commands: create, list, delete, run, logs, daemon start/stop/status - Add /schedule slash command for interactive TUI - Add daemon process entry with PID file and signal handling - Add next-session catch-up delivery - Add 61 unit tests (35 core + 26 CLI) - Add design doc and E2E test plan Closes QwenLM#6112
- Add update command for modifying existing tasks - Implement background daemon mode with --background flag - Add auto-spawn functionality when creating tasks - Implement natural language cron parser - Add task templates for common use cases - Add 24 new tests for NL parser and templates - Total: 86 tests passing Features: - qwen schedule update <taskId> [options] - qwen schedule daemon start --background - Auto-start daemon on task creation (--no-auto-start to disable) - qwen schedule create --nl 'every weekday morning' - qwen schedule create --template daily-pr-review Files: - packages/core/src/utils/natural-language-cron.ts (NEW) - packages/core/src/utils/natural-language-cron.test.ts (NEW) - packages/cli/src/commands/schedule.ts (MODIFIED) - packages/cli/src/schedule/run-schedule-daemon.ts (MODIFIED) - packages/cli/src/ui/commands/schedule-command.ts (MODIFIED) - .gitignore (MODIFIED - added .atlarix/) Issue: QwenLM#6112
Phase 2 - Production:
- System service installation (launchd/systemd/schtasks) via
qwen schedule service install|uninstall|status|start|stop
- Channels push: console, file, webhook notification channels
with per-task configuration in SKILL.md frontmatter
- One-shot fireAt tasks via --fire-at flag (ISO 8601, auto-disables
after execution)
- Forced sandbox via --force-sandbox flag on daemon start
(downgrades auto/yolo approval modes to default)
Phase 3 - Advanced Automation:
- Event triggers via qwen schedule webhook start|stop|status
with HTTP server for webhook-based task triggering
(bearer token + HMAC auth support)
Files created (11):
- packages/cli/src/schedule/service-managers/{index,launchd,systemd,schtasks}.ts
- packages/cli/src/schedule/webhook-server.ts
- packages/core/src/services/channels/{channel,console-channel,file-channel,webhook-channel,channel-registry,index}.ts
Files modified (6):
- packages/core/src/services/schedule-daemon.ts (fireAt, channels, sandbox)
- packages/core/src/services/schedule-task-store.ts (fireAt field, validation)
- packages/cli/src/commands/schedule.ts (service/webhook CLI, --fire-at, --force-sandbox)
- packages/cli/src/schedule/run-schedule-daemon.ts (forceSandbox passthrough)
- packages/cli/src/ui/commands/schedule-command.ts (TS index signature fixes)
- packages/core/src/index.ts (export channels)
Tests: 62 passing (35 core + 27 CLI)
Zero new TypeScript errors introduced.
1. Cross-platform fork fix (Windows Node.js v24 bug):
- Use fork() instead of spawn({ detached: true }) on Windows
- Avoids nodejs/node#62125 where TerminateProcess silently kills
child processes after ~4-6 seconds
- fork() works everywhere Node runs
2. Log file rotation:
- Check log file size before opening (10MB cap)
- Rotate current log to .log.1 if exceeded
- Prevents disk exhaustion from crash-restart loops
3. Webhook server HTTPS support:
- Add --https, --cert, --key flags to webhook start command
- Accepts TLS cert/key paths for HTTPS mode
- Default remains HTTP for local-only use
1. --max-walls-time typo → --max-wall-time File: packages/core/src/services/schedule-daemon.ts The trailing 's' caused the wall-clock budget flag to be silently ignored by spawned child processes. 2. HMAC signs empty string instead of request body File: packages/cli/src/schedule/webhook-server.ts Body is now buffered BEFORE authenticate() is called, and the body string is passed to authenticate() so .update(body) hashes the actual payload. 3. Bearer token timing attack File: packages/cli/src/schedule/webhook-server.ts Replaced === comparison with length check + timingSafeEqual, matching the pattern already used for HMAC. 4. Double-escaped \n in UI update message File: packages/cli/src/ui/commands/schedule-command.ts Changed \\n to \n in the template literal so the user sees a real newline instead of literal backslash-n characters. 5. fireAt setTimeout not cleared on daemon stop File: packages/core/src/services/schedule-daemon.ts Added fireAtTimers Map to store setTimeout handles; all timers are cleared in stop() to prevent child process spawning after daemon shutdown.
|
@DragonnZhang all errors you mentioned should be fixed by now, kindly review |
DragonnZhang
left a comment
There was a problem hiding this comment.
Incremental review at new SHA 7236796. All 5 previously reported findings have been addressed -- good fixes. Two new HIGH-CONFIDENCE issues found below.
1. stdio: 'inherit' makes child.stdout null (dead code) File: packages/cli/src/commands/schedule.ts Changed stdio from 'inherit' to ['ignore', 'pipe', 'inherit'] so stdout is captured for the run record outputSummary while stderr still passes through to the terminal. Removed the unnecessary if (child.stdout) guard. 2. unloadTask doesn't clear fireAt timers File: packages/core/src/services/schedule-daemon.ts Added fireAt timer cleanup to unloadTask so that when a one-shot task is unloaded or reloaded, its pending setTimeout is cleared and won't fire after unload.
|
@DragonnZhang , new issues also fixed, kindly take a look |
Problem: create/update/delete commands mutate task files on disk but
the running daemon's in-memory state stays stale until restart. New
tasks never fire, deleted tasks keep running.
Solution: lightweight command-file IPC mechanism.
1. packages/core/src/services/schedule-daemon.ts
- Added command-file polling (1s interval) in start()/stop()
- Processes JSON commands: load, reload, unload
- Truncates file after processing each batch
2. packages/cli/src/schedule/run-schedule-daemon.ts
- Exported sendDaemonCommand() that appends JSON lines to
~/.qwen/schedule-daemon.cmd
3. packages/cli/src/commands/schedule.ts
- create: sends 'load' command when daemon is already running
- update: sends 'reload' command after updating task
- delete: sends 'unload' command after deleting task
4. packages/core/src/services/schedule-daemon.test.ts
- Fixed mock type annotations for ChildProcess properties
|
@qqqys also fixed that issue as well in latest commit , kindly take a look |
DragonnZhang
left a comment
There was a problem hiding this comment.
Incremental review at new SHA ade41bcc96. All 7 previously reported findings have been addressed -- good fixes.
One new HIGH-CONFIDENCE issue found below.
The /schedule delete and /schedule update TUI slash commands were
mutating task files without signaling the running daemon, causing
stale in-memory state until restart.
- delete: calls sendDaemonCommand('unload', taskId)
- update: calls sendDaemonCommand('reload', taskId)
Matches the pattern already used in CLI command handlers.
|
@DragonnZhang the latest commit should have that now, kindly review |
qqqys
left a comment
There was a problem hiding this comment.
Previous critical issue is resolved at the current head: CLI and TUI task create/update/delete paths now notify the running schedule daemon via load/reload/unload IPC, and unload clears the scheduled cron/fireAt state. I found no new critical blocker in this pass.
|
great and @DragonnZhang what about your review? |
1. Command file atomic read (schedule-daemon.ts)
- Replace non-atomic readFileSync→writeFileSync('') with
rename-then-read pattern to eliminate race condition
- fs.renameSync(cmdFile, tmpFile) atomically claims the file
- Read from tmpFile, process, then fs.unlinkSync(tmpFile)
2. Unhandled promise rejections (schedule-daemon.ts)
- Add .catch() to loadTask and reloadTask async chains
- Prevents daemon crash from corrupted YAML frontmatter
3. Webhook body size limit (webhook-server.ts)
- Add MAX_BODY = 1MB cap in request body parsing
- Return 413 if body exceeds limit
4. XML escaping in launchd.ts
- Add xmlEscape() function for &, <, >, " characters
- Apply to all interpolated paths in plist template
|
Qwen precheck requires maintainer approval before automated triage/review. Head SHA: Reason:
A maintainer with write access can inspect the PR and manually request a run with |
…tests - webhook-server.test.ts: 12 tests covering bearer/HMAC auth, body size limit (413), 404/400 error paths - schedule-daemon.test.ts: 6 new tests (21 total) covering command-file polling (load/unload/malformed/concurrent), fireAt timer registration, and cron task spawning - schedule-daemon.ts: export static getCmdFilePath() for testing
|
@doudouOUC all issues should be fixed with the latest commit, kindly take a look |
DRY: both private and static getCmdFilePath() used the same path literal. Extract to a single private static readonly constant so there's one source of truth.
|
@DragonnZhang also should be fixed in latest commit |
|
@wenshao @qwen-code-ci-bot kindly review the pr |
|
@qwen-code /review |
doudouOUC
left a comment
There was a problem hiding this comment.
Thanks for the contribution! The motivation is clear — durable tasks sitting dormant without a running process is a real gap worth closing.
However, I have an architectural concern about the overall direction before we go further on implementation details:
We already have the infrastructure for this.
The project has an existing long-running daemon (qwen serve) and a fully-featured CronScheduler with durable (file-backed) mode — including cross-process locking, file watching, missed-task catch-up, and multi-session coordination. This PR introduces a second, independent daemon with its own process management, storage format (SKILL.md vs JSON), and lifecycle — essentially duplicating what already exists.
The more natural path would be:
- Extend the existing
qwen servedaemon with a schedule service that reusesCronScheduler's durable mode - Make the 7-day recurring expiry configurable (or opt-out for "permanent" schedules)
- Add the
onFire → spawn childexecution path as a service within the existing daemon - System service installation (launchd/systemd) should serve the existing daemon, not a new one
This approach would likely be ~200-400 lines of incremental change vs 5300+ lines of new parallel infrastructure.
Additionally, the PR scope is too large for a single review. Four rollout phases (MVP through webhook triggers) in one PR makes it very difficult to review thoroughly. If we do proceed in this direction, I'd suggest splitting into at least:
- Phase 0-1: Core daemon + CRUD (the MVP)
- Phase 2: System service integration
- Phase 3: Webhook triggers (which also needs a separate security review)
Could you share your thoughts on building this on top of the existing daemon infrastructure instead? Happy to discuss the architecture further.
| const enabled = | ||
| typeof schedule['enabled'] === 'boolean' ? schedule['enabled'] : true; | ||
| const cwd = typeof fm['cwd'] === 'string' ? fm['cwd'] : process.cwd(); | ||
| if (!fsSync.existsSync(cwd)) { |
There was a problem hiding this comment.
[Critical] frontmatterToDefinition throws when the stored cwd no longer exists on disk. Since listScheduleTasks iterates all tasks through readScheduleTask with no per-task try/catch, a single task whose project directory was deleted (repo moved, temp dir cleaned) poisons the entire listing and prevents the daemon from starting.
This is the most likely "daemon won't start" failure in production: the user deletes a project, reboots, and the daemon (with launchd KeepAlive or systemd Restart=always) enters an infinite crash-restart loop. The error message (cwd does not exist: /some/path) never identifies which task is responsible.
| if (!fsSync.existsSync(cwd)) { | |
| const cwd = typeof fm['cwd'] === 'string' ? fm['cwd'] : process.cwd(); | |
| if (!fsSync.existsSync(cwd)) { | |
| debugLogger?.warn?.(`Task ${taskId}: cwd does not exist: ${cwd}, falling back to homedir`); | |
| // Fall back rather than throw — a stale cwd should not kill the entire daemon | |
| } |
Also wrap each readScheduleTask call in listScheduleTasks with try/catch:
for (const entry of entries) {
if (!entry.isDirectory()) continue;
try {
const task = await readScheduleTask(entry.name);
if (task) tasks.push(task);
} catch (err) {
debugLogger.warn(`Skipping unreadable task ${entry.name}: ${err}`);
}
}— qwen3.7-max via Qwen Code /review
| const model = typeof fm['model'] === 'string' ? fm['model'] : undefined; | ||
| const approvalMode = validateApprovalMode(fm['approvalMode']); | ||
| const notify: 'next-session' = | ||
| fm['notify'] === 'next-session' ? 'next-session' : 'next-session'; |
There was a problem hiding this comment.
[Critical] Tautological ternary: both branches return 'next-session'. The condition has no effect — this is either leftover from a planned multi-value notify field, or a placeholder that was never filled in.
| fm['notify'] === 'next-session' ? 'next-session' : 'next-session'; | |
| const notify: 'next-session' = 'next-session'; |
— qwen3.7-max via Qwen Code /review
| const def = existing.definition; | ||
| if (updates.name !== undefined) def.name = updates.name; | ||
| if (updates.description !== undefined) def.description = updates.description; | ||
| if (updates.cron !== undefined) def.schedule.cron = updates.cron; |
There was a problem hiding this comment.
[Critical] updateScheduleTask sets def.schedule.cron = updates.cron without clearing a pre-existing def.schedule.fireAt. After this update, both fields coexist — violating the mutual exclusion enforced by createScheduleTask. The daemon's registerTask checks fireAt first, so the cron expression is silently ignored and the task continues to behave as a one-shot.
| if (updates.cron !== undefined) def.schedule.cron = updates.cron; | |
| if (updates.cron !== undefined) { | |
| parseCron(updates.cron); | |
| nextFireTime(updates.cron, new Date()); | |
| def.schedule.cron = updates.cron; | |
| def.schedule.fireAt = undefined; | |
| } |
— qwen3.7-max via Qwen Code /review
| if (updates.description !== undefined) def.description = updates.description; | ||
| if (updates.cron !== undefined) def.schedule.cron = updates.cron; | ||
| if (updates.enabled !== undefined) def.schedule.enabled = updates.enabled; | ||
| if (updates.cwd !== undefined) def.cwd = updates.cwd; |
There was a problem hiding this comment.
[Critical] updateScheduleTask assigns def.cwd = updates.cwd without the existsSync validation that createScheduleTask performs. A user can qwen schedule update <id> --cwd /nonexistent and the task is saved successfully, but the daemon's spawn() call will fail with ENOENT. The failure is silent (no run record written for spawn errors), making debugging difficult.
| if (updates.cwd !== undefined) def.cwd = updates.cwd; | |
| if (updates.cwd !== undefined) { | |
| if (!fsSync.existsSync(updates.cwd)) { | |
| throw new Error(`cwd does not exist: ${updates.cwd}`); | |
| } | |
| def.cwd = updates.cwd; | |
| } |
— qwen3.7-max via Qwen Code /review
| private async sendNotification( | ||
| definition: ScheduleTask['definition'], | ||
| notification: TaskNotification, | ||
| ): Promise<void> { |
There was a problem hiding this comment.
[Critical] The entire notification system is inert. sendNotification creates a throwaway ChannelRegistry and reads channels from (definition as unknown as Record<string, unknown>)['channels'] — a field that doesn't exist on ScheduleTaskDefinition, is never written by definitionToFrontmatter, and is never populated by createScheduleTask or updateScheduleTask.
Meanwhile, this.channelRegistry — populated by the public registerChannels() method — is never read anywhere in the class. The two halves never connect: the registry that gets populated is never sent to, and the send path looks at a field that's never populated.
Either make sendNotification use this.channelRegistry.sendToAll(notification) as the primary path, or add a channels field to ScheduleTaskDefinition and persist it in YAML frontmatter.
— qwen3.7-max via Qwen Code /review
| }); | ||
|
|
||
| child.stderr?.on('data', (chunk: Buffer) => { | ||
| process.stderr.write(chunk); |
There was a problem hiding this comment.
[Critical] child.stderr is forwarded to the daemon's stderr with no task-ID prefix. With MAX_CONCURRENT_FIRES=3, concurrent task stderr output interleaves — possibly mid-line — in the daemon log file. When debugging at 3 AM, there is no way to tell which error line belongs to which task.
| process.stderr.write(chunk); | |
| child.stderr?.on('data', (chunk: Buffer) => { | |
| const lines = chunk.toString().split('\n'); | |
| for (const line of lines) { | |
| if (line) process.stderr.write(`[${definition.taskId}] ${line}\n`); | |
| } | |
| }); |
— qwen3.7-max via Qwen Code /review
| for (const trigger of taskTriggers) { | ||
| if (trigger.type === 'webhook') { | ||
| triggers.push({ | ||
| path: trigger.path, |
There was a problem hiding this comment.
[Critical] Webhook trigger auth config is silently stripped when building the triggers array. triggers.push() copies only path, method, and taskId — the auth field from WebhookTriggerConfig is never forwarded. Every webhook endpoint registered by this command is effectively unauthenticated regardless of what the user configures.
| path: trigger.path, | |
| triggers.push({ | |
| path: trigger.path, | |
| method: (trigger.method || 'POST') as 'POST' | 'GET' | 'PUT', | |
| auth: (trigger as Record<string, unknown>)['auth'] as { type: 'bearer' | 'hmac'; secret?: string } | undefined, | |
| taskId: task.definition.taskId, | |
| }); |
Also update the local triggers type to include auth?.
— qwen3.7-max via Qwen Code /review
| const task = await import('@qwen-code/qwen-code-core').then((m) => | ||
| m.readScheduleTask(taskId), | ||
| ); | ||
| if (!task) { |
There was a problem hiding this comment.
[Critical] Webhook onTrigger spawns a child process for every matching HTTP request with zero concurrency limiting, zero tracking, and zero backpressure. Compare to the daemon's fireTask which enforces MAX_CONCURRENT_FIRES = 3. A rapid-fire webhook caller (or a while true; do curl ...; done loop) spawns unlimited qwen child processes, exhausting PIDs, memory, and CPU.
Track active children and enforce the same concurrency cap:
const activeChildren = new Set<ChildProcess>();
// ...
onTrigger: async (taskId, _payload) => {
if (activeChildren.size >= 3) {
writeStderrLine(`[Webhook] Concurrency limit reached, rejecting trigger for ${taskId}`);
return;
}
const child = spawn(process.execPath, [process.argv[1], 'schedule', 'run', taskId], { stdio: 'inherit' });
activeChildren.add(child);
child.on('exit', (code) => {
activeChildren.delete(child);
writeStderrLine(`[Webhook] Task ${taskId} completed with exit code ${code}`);
});
}— qwen3.7-max via Qwen Code /review
| const { definition } = task; | ||
| writeStderrLine(`Running task ${taskId} (${definition.name})...`); | ||
|
|
||
| const args = [ |
There was a problem hiding this comment.
[Critical] The run command builds spawn args with only -p, --approval-mode, and optionally --model. It omits --sandbox, --output-format stream-json, and --max-wall-time that the daemon's fireTask correctly includes. Running qwen schedule run <id> bypasses sandbox protection and wall-time limits even when the task has sandbox: true.
| const args = [ | |
| const args = [ | |
| '-p', | |
| definition.prompt, | |
| '--approval-mode', | |
| definition.approvalMode, | |
| '--output-format', | |
| 'stream-json', | |
| '--max-wall-time', | |
| String(600), | |
| ]; | |
| if (definition.model) args.push('--model', definition.model); | |
| if (definition.sandbox) args.push('--sandbox'); |
— qwen3.7-max via Qwen Code /review
| '/sc', | ||
| 'onlogon', | ||
| '/rl', | ||
| 'highest', |
There was a problem hiding this comment.
[Suggestion] /rl highest runs the daemon with elevated administrator privileges on Windows. A background task scheduler does not need admin rights — this violates the principle of least privilege and increases the attack surface if the daemon is compromised.
| 'highest', | |
| '/f', |
Remove the '/rl', 'highest' arguments to run at normal user privilege level.
— qwen3.7-max via Qwen Code /review
|
@doudouOUC |
DragonnZhang
left a comment
There was a problem hiding this comment.
APPROVE
Adds a local always-on /schedule daemon with cron/fireAt task scheduling, CLI CRUD commands, system service integration (launchd/systemd/schtasks), webhook server for event triggers, and natural language cron parsing. The architecture is sound: ScheduleDaemon wraps CronScheduler, spawns qwen -p children on each fire with concurrency limits, and persists tasks as SKILL.md files with YAML frontmatter. The webhook server correctly implements HMAC and bearer auth with timing-safe comparison and a 1MB body limit. Existing review comments from prior passes cover the remaining edge cases (command-file IPC, thin test coverage, cwd validation on read). CI passes.
— qwen3-coder via Qwen Code /review
| return path.join(getTaskDir(taskId), 'state.json'); | ||
| } | ||
|
|
||
| function sanitizeTaskId(raw: string): string { |
There was a problem hiding this comment.
[Critical] Path traversal: sanitizeTaskId strips <>:"/\|?* and control chars but allows . through unchanged. sanitizeTaskId('..') returns '..', which means getTaskDir('..') resolves to ~/.qwen/ (parent of the tasks directory). Since deleteScheduleTask calls fs.rm(dir, { recursive: true, force: true }), running qwen schedule delete .. would recursively delete the entire ~/.qwen/ directory — all config, sessions, memories, and other scheduled tasks.
| function sanitizeTaskId(raw: string): string { | |
| function sanitizeTaskId(raw: string): string { | |
| let sanitized = raw | |
| .replace(/[<>:"/\\|?*\x00-\x1f]/g, '') | |
| .replace(/\.\./g, '') | |
| .slice(0, 64); | |
| if (!sanitized) sanitized = 'unnamed'; | |
| return sanitized; | |
| } |
Alternatively, validate that path.resolve(getTaskDir(taskId)).startsWith(getScheduledTasksDir()) before any filesystem operation.
— qwen3.7-max via Qwen Code /review
| }); | ||
|
|
||
| try { | ||
| const [exitCode] = (await once(child, 'exit')) as [number | null]; |
There was a problem hiding this comment.
[Critical] When spawn() fails (e.g., binary not found, ENOEXEC in dev mode), Node.js emits 'error' on the child but does not emit 'exit'. The error handler at ~line 438 deletes the fireId from activeFires, but execution continues to this await once(child, 'exit') which never resolves. The fireTask promise leaks permanently — no run record is written, no notification is sent, and the task silently vanishes.
With resolveQwenBinary() returning a .ts path in dev mode, every single fire would hit this path.
| const [exitCode] = (await once(child, 'exit')) as [number | null]; | |
| let exitCode: number | null = null; | |
| try { | |
| [exitCode] = (await once(child, 'exit')) as [number | null]; | |
| } catch { | |
| exitCode = null; | |
| } |
Also consider using Promise.race([once(child, 'exit'), once(child, 'error')]) to handle both paths, and writing a failure run record with the error details on the error path.
— qwen3.7-max via Qwen Code /review
| timestamp: new Date().toISOString(), | ||
| }; | ||
|
|
||
| const response = await fetch(this.url, { |
There was a problem hiding this comment.
[Suggestion] The fetch() call has no timeout. If the webhook endpoint is unresponsive, the request hangs until OS TCP timeout (2-5 minutes). Since sendNotification awaits registry.sendToAll() which awaits each channel, a hung webhook blocks the entire post-fire cleanup path in fireTask. With MAX_CONCURRENT_FIRES = 3, three hung webhooks would prevent all subsequent task executions.
| const response = await fetch(this.url, { | |
| const response = await fetch(this.url, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(payload), | |
| signal: AbortSignal.timeout(10_000), | |
| }); |
— qwen3.7-max via Qwen Code /review
|
|
||
| [Service] | ||
| Type=simple | ||
| ExecStart=${process.execPath} ${qwenBinary} schedule daemon start |
There was a problem hiding this comment.
[Suggestion] The systemd unit file interpolates process.execPath, qwenBinary, and os.homedir() without quoting or escaping. Two issues:
- Spaces: If any path contains spaces (e.g.,
/home/John Doe/), systemd will misparse the arguments and the service fails to start. %specifier expansion: Systemd interprets%followed by certain letters as specifiers (%i,%n,%h, etc.). A literal%in any path must be escaped as%%.
The launchd generator has xmlEscape() but the systemd generator has no equivalent.
| ExecStart=${process.execPath} ${qwenBinary} schedule daemon start | |
| ExecStart="${process.execPath}" "${qwenBinary}" schedule daemon start | |
| WorkingDirectory="${cwd}" | |
| StandardOutput=append:${stdoutLog} | |
| StandardError=append:${stderrLog} |
Also add a systemdEscape() helper that replaces % with %% in all interpolated values.
— qwen3.7-max via Qwen Code /review
| // In a real implementation, this would signal the running webhook server | ||
| // For now, we just inform the user | ||
| writeStderrLine( | ||
| 'Webhook server stop requested. Send SIGINT to the running process.', |
There was a problem hiding this comment.
[Suggestion] webhook stop prints a message and exits with code 0, but performs no actual stop operation. There is no PID file, no IPC mechanism — the user has no way to programmatically stop a running webhook server. Shipping a stub that reports success is worse than not shipping the subcommand at all.
Either implement the stop mechanism (e.g., PID file + SIGTERM like the daemon), or remove the stop and status subcommands until they are functional.
— qwen3.7-max via Qwen Code /review
| ); | ||
| } | ||
|
|
||
| async stop(): Promise<void> { |
There was a problem hiding this comment.
[Suggestion] stop() builds promises from activeFires that await child process exit (SIGTERM + 5s timeout), but does not track or await the fireTask promises themselves. After stop() resolves, in-flight fireTask calls may still be executing post-exit logic: writeScheduleRunRecord, updateScheduleTask (disabling fireAt tasks), and sendNotification. These async operations run against a daemon whose state is already 'stopped' and whose maps have been cleared.
Consider tracking fireTask promises in a Set and awaiting them in stop() after killing children, or using an AbortController to signal cancellation.
— qwen3.7-max via Qwen Code /review
What this PR does
Implements the complete
/scheduledaemon feature — a local always-on scheduler that runs tasks on cron schedules without an interactive session open. Covers all four rollout phases:create/list/delete/run), fresh child-process firing per task, run records, next-session catch-up delivery.--background) with log files,updatecommand for in-place task modification, natural language scheduling (--nl "every weekday morning"), and pre-defined task templates (--template daily-pr-review).qwen schedule service install|uninstall|status|start|stop) via launchd (macOS), systemd (Linux), and schtasks (Windows); notification channels push (console, file, webhook) with per-task configuration; one-shotfireAttasks (--fire-atISO 8601, auto-disables after execution); forced sandbox mode (--force-sandbox) that downgrades privileged approval modes.qwen schedule webhook start|stop|status— a lightweight HTTP/HTTPS server that receives webhooks and fires tasks on demand, with bearer token and HMAC-SHA256 authentication.All features are backward-compatible. No breaking changes.
Why it's needed
The gap was simple: durable tasks only fire while some
qwenprocess runs and holds the lock. With no session open they sit dormant. ~80% of the substrate already existed (cron math, durable scheduler, headless run path,qwen serve, channels package) — the missing piece was the always-on host plus first-class UX and result delivery.This PR closes that gap completely. Users can now define tasks that run on a cron schedule on their local machine, continuously, without an interactive session open — the local analogue of Claude Code's Desktop scheduled tasks.
Reviewer Test Plan
How to verify
1. Basic CRUD + daemon lifecycle (Phase 0 + 1):
2. Natural language + templates (Phase 1):
3. One-shot fireAt tasks (Phase 2):
4. System service installation (Phase 2):
5. Forced sandbox (Phase 2):
qwen schedule daemon start --force-sandbox # Tasks with auto/yolo approval are downgraded to default6. Webhook event triggers (Phase 3):
7. Run all tests:
Evidence (Before & After)
Before: No always-on daemon. Tasks only fired while a session was open. No service installation, no notification channels, no one-shot reminders, no webhook triggers.
After:
qwen schedule daemon start --backgroundruns detached with logs at~/.qwen/logs/qwen schedule service installregisters the daemon as a system service (auto-starts on boot)qwen schedule create --fire-at "..."creates one-shot reminders that auto-disable after firingqwen schedule webhook startruns an HTTP/HTTPS server for event-triggered task executionqwen schedule daemon start --force-sandboxenforces sandbox for all tasksfork()to avoid Node.js v24detached: truebugTested on
Environment
Local development:
npm run devin packages/core and packages/cli. Unit tests only — no manual daemon lifecycle testing on Windows/Linux.Risk & Scope
fork()instead ofspawn({ detached: true })on Windows, avoiding theTerminateProcesssilent-kill issue (Silent process termination after long-running child_process.spawn() on Windows (v24.13.0, works on v22.22.1) nodejs/node#62125).--https --cert <path> --key <path>for TLS-encrypted webhook endpoints.Linked Issues
Closes #6112.
[{"url":"https://craftedbytes.dev/posts/ai-commits-and-prs/","title":"Two AI Prompts That Fixed My Git History · Crafted Bytes DEV"},{"url":"https://docs.github.com/articles/creating-a-pull-request","title":"Creating a pull request - GitHub Docs"},{"url":"https://github.com/ytrofr/claude-code-guide/blob/main/docs/guide/part2-workflow/06-commit-and-pr.md","title":"docs/guide/part2-workflow/06-commit-and-pr.md at main · ytrofr/claude-code-guide"},{"url":"https://www.youngju.dev/blog/culture/2026-05-14-authoring-reviewable-pull-requests-small-prs-description-stacked-deep-dive-guide-2026.en","title":"Authoring Reviewable Pull Requests: Small PRs, Good Descriptions, and Stacked Diffs | Chaos and Order"},{"url":"https://dev.to/tahsin000/how-to-create-a-github-pull-request-from-the-terminal-3a0g","title":"How to Create a GitHub Pull Request from the Terminal - DEV Community"},{"url":"https://git-scm.com/docs/git-status.html","title":"Git - git-status Documentation"},{"url":"https://git-scm.com/docs/git-status","title":"Git - git-status Documentation"},{"url":"https://manpages.debian.org/bookworm/git-man/git-status.1.en.html","title":"git-status(1) — git-man — Debian bookworm — Debian Manpages"},{"url":"https://man7.org/linux/man-pages/man1/git-status.1.html","title":"git-status(1) - Linux manual page"},{"url":"https://manpages.ubuntu.com/manpages/noble/man1/git-status.1.html","title":"Ubuntu Manpage: git-status - Show the working tree status"},{"url":"https://github.com/CoWork-OS/CoWork-OS/blob/main/src/electron/cron/webhook.ts","title":"src/electron/cron/webhook.ts"},{"url":"https://jsonic.io/guides/json-webhooks","title":"JSON Webhook Implementation: HMAC Verification, Idempotency & Dead Letter Queues | Jsonic"},{"url":"https://github.com/p-vbordei/hmac-sign","title":"p-vbordei/hmac-sign"},{"url":"https://github.com/n8n-io/n8n/blob/ef2f21fe/packages/cli/src/webhooks/webhook-helpers.ts","title":"packages/cli/src/webhooks/webhook-helpers.ts"},{"url":"https://github.com/activepieces/activepieces/blob/main/packages/server/api/src/app/webhooks/webhook-controller.ts","title":"packages/server/api/src/app/webhooks/webhook-controller.ts at main · activepieces/activepieces"},{"url":"https://dev.to/sendotltd/hmac-webhook-signing-isnt-complicated-the-formats-are-2di4","title":"HMAC Webhook Signing Isn't Complicated — the Formats Are - DEV Community"},{"url":"https://github.com/alphacod3rs/hook-engine","title":"ALPHACOD3RS/hook-engine"},{"url":"https://nodejs.org/api/https.html","title":"HTTPS | Node.js v26.3.1 Documentation"},{"url":"https://nodejs.org/docs/latest-v23.x/api/https.html","title":"HTTPS | Node.js v23.11.1 Documentation"},{"url":"https://bun.com/reference/node/https/createServer","title":"Node https.createServer function | API Reference | Bun"},{"url":"https://github.com/SourceRegistry/node-webserver/","title":"SourceRegistry/node-webserver"},{"url":"https://github.com/probot/probot/blob/4cf7de9/src/index.ts","title":"src/index.ts at 4cf7de9 · probot/probot"},{"url":"https://docs.deno.com/api/node/https/~/Server","title":"Server - https - Node documentation"},{"url":"https://github.com/QwenLM/qwen-code/blob/d40fe7cd/packages/cli/src/commands/channel/start.ts","title":"packages/cli/src/commands/channel/start.ts at d40fe7c · QwenLM/qwen-code"},{"url":"https://github.com/QwenLM/qwen-code/blob/407a66c9/packages/cli/src/ui/commands/types.ts","title":"packages/cli/src/ui/commands/types.ts at 407a66c · QwenLM/qwen-code"},{"url":"https://github.com/QwenLM/qwen-code/blob/68e4819d/packages/cli/src/ui/hooks/slashCommandProcessor.ts","title":"packages/cli/src/ui/hooks/slashCommandProcessor.ts"},{"url":"https://github.com/QwenLM/qwen-code/blob/d40fe7cd/packages/core/src/tools/cron-create.ts","title":"packages/core/src/tools/cron-create.ts at d40fe7c · QwenLM/qwen-code"},{"url":"https://github.com/QwenLM/qwen-code/blob/d40fe7cd/packages/core/src/hooks/hookSystem.ts","title":"packages/core/src/hooks/hookSystem.ts at d40fe7c · QwenLM/qwen-code"},{"url":"https://qwenlm.github.io/qwen-code-docs/en/users/features/hooks/","title":"Qwen Code Hooks | Qwen Code Docs"},{"url":"https://github.com/QwenLM/qwen-code/blob/68e4819d/packages/core/src/hooks/hookSystem.ts","title":"packages/core/src/hooks/hookSystem.ts"},{"url":"https://github.com/QwenLM/qwen-code/blob/main/docs/users/features/scheduled-tasks.md","title":"docs/users/features/scheduled-tasks.md at main · QwenLM/qwen-code"},{"url":"https://github.com/QwenLM/qwen-code/blob/68e4819d/README.md","title":"README.md at 68e4819 · QwenLM/qwen-code"},{"url":"https://qwenlm.github.io/qwen-code-docs/en/developers/daemon/02-serve-runtime/","title":"Serve Runtime | Qwen Code Docs"},{"url":"https://qwenlm.github.io/qwen-code-docs/en/developers/daemon/01-architecture/","title":"Daemon Architecture | Qwen Code Docs"},{"url":"https://github.com//pull/3889","title":"feat(cli,sdk): qwen serve daemon (Stage 1) · Pull Request #3889 · QwenLM/qwen-code"},{"url":"https://github.com//pull/1988","title":"feat(hooks): Implement hooks system infrastructure with CLI and UI management"},{"url":"https://github.com/nrwl/nx/pull/34894","title":"fix(core): set windowsHide: true on all child process spawns"},{"url":"https://github.com/nrwl/nx/issues/34455","title":"fix(core): set windowsHide: true on all child process spawns"},{"url":"https://nodejs.org/docs/latest-v26.x/api/child_process.html","title":"Child process | Node.js v26.4.0 Documentation"},{"url":"https://github.com/typescript-language-server/typescript-language-server/issues/324","title":"Visible shell windows are now popping up on Windows 10 · Issue #324 · typescript-language-server/typescript-language-server"},{"url":"https://nodejs.org/docs/latest-v24.x/api/child_process.html","title":"Child process | Node.js v24.18.0 Documentation"},{"url":"https://github.com/nodejs/node/issues/62125","title":"Silent process termination after long-running child_process.spawn() on Windows (v24.13.0, works on v22.22.1) · Issue #62125 · nodejs/node"},{"url":"https://github.com/ChromeDevTools/chrome-devtools-mcp/issues/1818","title":"Bug: Daemon process silently terminated on Windows + Node.js v24.x (spawn detached:true, stdio:ignore) · Issue #1818 · ChromeDevTools/chrome-devtools-mcp"},{"url":"https://github.com/cline/cline/issues/10149","title":"Bug: Hook processes and Update mechanism silently fail on Windows + Node.js v24.x (detached:true, stdio:ignore) · Issue #10149 · cline/cline"},{"url":"https://nodejs.org/api/child_process.html","title":"Child process | Node.js v26.4.0 Documentation"},{"url":"https://git.github.io/htmldocs/git-status.html","title":"git-status(1)"},{"url":"https://github.com/strotski/tokenjuice","title":"strotski/tokenjuice"},{"url":"https://git-scm.com/docs/git-add","title":"git-add Documentation - Git"},{"url":"https://github.com//pull/2607","title":"fix(hooks): terminate hook child processes when user exits CLI · Pull Request #2607 · QwenLM/qwen-code"},{"url":"https://github.com/josephyaduvanshi/qwen-companion/pull/1","title":"fix: cross-spawn for Windows .cmd resolution"},{"url":"https://github.com/github/docs/blob/main/content/get-started/using-git/dealing-with-non-fast-forward-errors.md","title":"content/get-started/using-git/dealing-with-non-fast-forward-errors.md at main · github/docs"},{"url":"https://git-scm.com/docs/git-push.html","title":"Git - git-push Documentation"},{"url":"https://stackoverflow.com/questions/4684352/what-does-git-push-non-fast-forward-updates-were-rejected-mean","title":"What does "Git push non-fast-forward updates were rejected" mean?"},{"url":"https://devopsil.com/articles/2026-05-05-git-push-rejected-how-to-fix-updates-were-rejected-because-t","title":"Git Push Rejected: How To Fix "Updates Were Rejected Because The Remote Contains Work You Do Not Have" | DevOpsil"},{"url":"https://stackoverflow.com/questions/18328800/updates-were-rejected-because-the-remote-contains-work-that-you-do-not-have-loc","title":""Updates were rejected because the remote contains work that you do not have locally." after creating a new repository on GitHub"},{"url":"https://git-scm.com/docs/git-pull","title":"git-pull Documentation - Git"},{"url":"https://github.com/QwenLM/qwen-code/blob/main/docs/developers/tools/shell.md","title":"docs/developers/tools/shell.md at main · QwenLM/qwen-code"},{"url":"https://git.wtf/understanding-git-pull-rebase-behavior-with-different-repositories/","title":"Understanding git pull --rebase Behavior with Different Repositories"},{"url":"https://github.com/git/git/blob/878b3997345593d05f7c97a4e17a6c8bb9aba1a2/Documentation/git-pull.txt","title":"Documentation/git-pull.txt"},{"url":"https://github.com/QwenLM/Qwen-Code","title":"QwenLM/qwen-code"}]