Skip to content

feat(schedule): local always-on /schedule daemon (routines that run without an open session) - #6125

Closed
AmariahAK wants to merge 24 commits into
QwenLM:mainfrom
AmariahAK:main
Closed

feat(schedule): local always-on /schedule daemon (routines that run without an open session)#6125
AmariahAK wants to merge 24 commits into
QwenLM:mainfrom
AmariahAK:main

Conversation

@AmariahAK

@AmariahAK AmariahAK commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

What this PR does

Implements the complete /schedule daemon feature — a local always-on scheduler that runs tasks on cron schedules without an interactive session open. Covers all four rollout phases:

  • Phase 0 (MVP): SKILL.md-backed task store, foreground daemon, CRUD commands (create/list/delete/run), fresh child-process firing per task, run records, next-session catch-up delivery.
  • Phase 1: Daemon auto-spawn on task creation, background daemon mode (--background) with log files, update command for in-place task modification, natural language scheduling (--nl "every weekday morning"), and pre-defined task templates (--template daily-pr-review).
  • Phase 2 (Production): System service installation (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-shot fireAt tasks (--fire-at ISO 8601, auto-disables after execution); forced sandbox mode (--force-sandbox) that downgrades privileged approval modes.
  • Phase 3 (Advanced Automation): Event triggers via 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 qwen process 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):

# Create a task
qwen schedule create --name "Test" --cron "*/5 * * * *" --prompt "echo hello"

# List tasks
qwen schedule list

# Update a task in place
qwen schedule update <taskId> --name "Updated" --cron "0 10 * * *"

# Start daemon in background
qwen schedule daemon start --background
qwen schedule daemon status
tail -f ~/.qwen/logs/schedule-daemon.log
qwen schedule daemon stop

2. Natural language + templates (Phase 1):

qwen schedule create --name "NL" --nl "every weekday morning" --prompt "echo nl"
qwen schedule create --template daily-pr-review --cwd /path/to/repo
qwen schedule list

3. One-shot fireAt tasks (Phase 2):

qwen schedule create --name "Reminder" --fire-at "2026-07-01T15:00:00Z" --prompt "echo reminder"
qwen schedule list  # shows fireAt: ... (one-shot)

4. System service installation (Phase 2):

qwen schedule service install   # installs as launchd/systemd/schtasks service
qwen schedule service status    # shows installed/running/enabled
qwen schedule service uninstall # removes the service

5. Forced sandbox (Phase 2):

qwen schedule daemon start --force-sandbox
# Tasks with auto/yolo approval are downgraded to default

6. Webhook event triggers (Phase 3):

# Add a webhook trigger to a task's SKILL.md, then:
qwen schedule webhook start --port 8080
curl -X POST http://127.0.0.1:8080/<trigger-path>

# With HTTPS:
qwen schedule webhook start --https --cert ./cert.pem --key ./key.pem
curl -X POST https://127.0.0.1:8080/<trigger-path> -k

7. Run all tests:

cd packages/core && npx vitest run src/services/schedule-task-store.test.ts src/services/schedule-daemon.test.ts
cd packages/cli && npx vitest run src/commands/schedule.test.ts src/ui/commands/schedule-command.test.ts

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 --background runs detached with logs at ~/.qwen/logs/
  • qwen schedule service install registers the daemon as a system service (auto-starts on boot)
  • qwen schedule create --fire-at "..." creates one-shot reminders that auto-disable after firing
  • Tasks can send completion notifications via console, file, or webhook channels
  • qwen schedule webhook start runs an HTTP/HTTPS server for event-triggered task execution
  • qwen schedule daemon start --force-sandbox enforces sandbox for all tasks
  • Log files auto-rotate at 10MB to prevent disk exhaustion
  • Windows daemon uses fork() to avoid Node.js v24 detached: true bug

Tested on

OS Status
🍏 macOS
🪟 Windows ⚠️
🐧 Linux ⚠️

Environment

Local development: npm run dev in packages/core and packages/cli. Unit tests only — no manual daemon lifecycle testing on Windows/Linux.

Risk & Scope

  • Main risk or tradeoff: None identified. Three previously-known risks have been addressed:
    1. Windows Node.js v24 bug: Daemon uses fork() instead of spawn({ detached: true }) on Windows, avoiding the TerminateProcess silent-kill issue (Silent process termination after long-running child_process.spawn() on Windows (v24.13.0, works on v22.22.1) nodejs/node#62125).
    2. Log file rotation: Logs auto-rotate at 10MB (keeps 1 backup file) to prevent disk exhaustion from crash-restart loops.
    3. Webhook HTTPS: Server now supports --https --cert <path> --key <path> for TLS-encrypted webhook endpoints.
  • Not validated / out of scope: No manual testing on Windows or Linux. Telegram/Discord/Slack/Email channels, git hook integration, multi-user support, and web UI for task management are deferred.
  • Breaking changes / migration notes: None. All changes are additive and backward-compatible.

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"}]

atlarix-agent and others added 6 commits July 1, 2026 13:17
- 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
Comment thread packages/core/src/services/schedule-daemon.ts Outdated
Comment thread packages/cli/src/schedule/webhook-server.ts Outdated
Comment thread packages/cli/src/schedule/webhook-server.ts Outdated
Comment thread packages/cli/src/ui/commands/schedule-command.ts Outdated
Comment thread packages/core/src/services/schedule-daemon.ts Outdated
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.
@AmariahAK

AmariahAK commented Jul 1, 2026

Copy link
Copy Markdown
Contributor Author

@DragonnZhang all errors you mentioned should be fixed by now, kindly review

@DragonnZhang DragonnZhang left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Incremental review at new SHA 7236796. All 5 previously reported findings have been addressed -- good fixes. Two new HIGH-CONFIDENCE issues found below.

Comment thread packages/cli/src/commands/schedule.ts Outdated
Comment thread packages/core/src/services/schedule-daemon.ts
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.
@AmariahAK

Copy link
Copy Markdown
Contributor Author

@DragonnZhang , new issues also fixed, kindly take a look

Comment thread packages/cli/src/commands/schedule.ts
AmariahAK and others added 2 commits July 1, 2026 17:15
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
@AmariahAK

Copy link
Copy Markdown
Contributor Author

@qqqys also fixed that issue as well in latest commit , kindly take a look

@AmariahAK
AmariahAK requested a review from qqqys July 1, 2026 14:29

@DragonnZhang DragonnZhang left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Incremental review at new SHA ade41bcc96. All 7 previously reported findings have been addressed -- good fixes.

One new HIGH-CONFIDENCE issue found below.

Comment thread packages/cli/src/ui/commands/schedule-command.ts
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.
@AmariahAK

AmariahAK commented Jul 1, 2026

Copy link
Copy Markdown
Contributor Author

@DragonnZhang the latest commit should have that now, kindly review

@AmariahAK
AmariahAK requested a review from DragonnZhang July 1, 2026 15:19
qqqys
qqqys previously approved these changes Jul 1, 2026

@qqqys qqqys left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@AmariahAK

Copy link
Copy Markdown
Contributor Author

great and @DragonnZhang what about your review?

Comment thread packages/core/src/services/schedule-daemon.ts
Comment thread packages/cli/src/schedule/webhook-server.ts
Comment thread packages/core/src/services/schedule-daemon.ts
Comment thread packages/cli/src/schedule/service-managers/launchd.ts
Comment thread packages/cli/src/commands/schedule.test.ts
Comment thread packages/core/src/services/schedule-daemon.ts
Comment thread packages/cli/src/schedule/webhook-server.ts
Comment thread packages/core/src/services/schedule-daemon.ts
Comment thread packages/cli/src/schedule/service-managers/launchd.ts
Comment thread packages/cli/src/commands/schedule.test.ts
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-code-ci-bot

qwen-code-ci-bot commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator

Qwen precheck requires maintainer approval before automated triage/review.

Head SHA: 85cf9aebbe8734a76e2f5c7de35a7b0b4707027c

Reason:

  • input:diff_too_large

A maintainer with write access can inspect the PR and manually request a run with @qwen-code /triage or @qwen-code /review. A new push requires a fresh precheck.

…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
@AmariahAK

Copy link
Copy Markdown
Contributor Author

@doudouOUC all issues should be fixed with the latest commit, kindly take a look

@AmariahAK
AmariahAK requested a review from doudouOUC July 2, 2026 04:21
Comment thread packages/core/src/services/schedule-daemon.ts Outdated
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.
@AmariahAK
AmariahAK requested a review from DragonnZhang July 2, 2026 04:31
@AmariahAK

Copy link
Copy Markdown
Contributor Author

@DragonnZhang also should be fixed in latest commit

@AmariahAK

Copy link
Copy Markdown
Contributor Author

@wenshao @qwen-code-ci-bot kindly review the pr

@wenshao

wenshao commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /review

@doudouOUC doudouOUC left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. Extend the existing qwen serve daemon with a schedule service that reuses CronScheduler's durable mode
  2. Make the 7-day recurring expiry configurable (or opt-out for "permanent" schedules)
  3. Add the onFire → spawn child execution path as a service within the existing daemon
  4. 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)) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Suggested change
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';

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Suggested change
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;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Suggested change
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;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Suggested change
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> {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Suggested change
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,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Suggested change
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) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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 = [

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Suggested change
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',

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Suggested change
'highest',
'/f',

Remove the '/rl', 'highest' arguments to run at normal user privilege level.

— qwen3.7-max via Qwen Code /review

@AmariahAK

Copy link
Copy Markdown
Contributor Author

@doudouOUC
Thanks and this is a fair call, and re-reading it you're right that a second parallel daemon duplicates infrastructure that already exists. I'd rather build this the correct way on top of qwen serve + CronScheduler's durable mode than ship parallel machinery.
Happy to take the incremental path. I'll build on the existing daemon , reusing CronScheduler's durable mode, making the recurring expiry configurable/opt-out rather than hardcoded, adding the onFire → spawn child path as a service inside qwen serve, and pointing service installation at the existing daemon rather than standing up a new one. I'll match whatever conventions are already in place as I go.
On scope: fully agree the four-phase PR is too large. I'll restructure into Phase 0-1 first (schedule service on qwen serve + CRUD) as the reviewable MVP, then Phase 2 (service integration) and Phase 3 (webhook triggers, with the separate security review you flagged) as follow-ups once the foundation lands. Does that sequencing work for you?

@DragonnZhang DragonnZhang left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@AmariahAK
AmariahAK requested a review from doudouOUC July 2, 2026 10:21
return path.join(getTaskDir(taskId), 'state.json');
}

function sanitizeTaskId(raw: string): string {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Suggested change
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];

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Suggested change
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, {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Suggested change
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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The systemd unit file interpolates process.execPath, qwenBinary, and os.homedir() without quoting or escaping. Two issues:

  1. Spaces: If any path contains spaces (e.g., /home/John Doe/), systemd will misparse the arguments and the service fails to start.
  2. % 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.

Suggested change
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.',

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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> {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(schedule): local always-on /schedule daemon (routines that run without an open session)

7 participants