Skip to content

fix(core): reject invalid cron task entries#5309

Merged
wenshao merged 1 commit into
QwenLM:mainfrom
tt-a1i:fix/cron-invalid-entry-preserve
Jun 18, 2026
Merged

fix(core): reject invalid cron task entries#5309
wenshao merged 1 commit into
QwenLM:mainfrom
tt-a1i:fix/cron-invalid-entry-preserve

Conversation

@tt-a1i

@tt-a1i tt-a1i commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

What this PR does

Makes durable cron task reads fail closed when scheduled_tasks.json contains an invalid task entry inside an otherwise-valid array. Previously readCronTasks() ran parsed.filter(isValidTask), silently dropping malformed entries and only emitting a debug warning; a later updateCronTasks() would then persist the filtered list and permanently delete the dropped entry. The function now iterates over every parsed entry and throws an error (Invalid task entry at index <n> in <file> — fix or delete the entry; refusing to drop it from the schedule.) on the first invalid one, returning the array unchanged when all entries are valid. This aligns invalid-entry handling with the existing behavior for malformed JSON and non-array JSON: surface the corruption and refuse to write through it. The now-unused debug logger is removed.

Why it's needed

readCronTasks() filtering invalid entries out of scheduled_tasks.json meant a subsequent updateCronTasks() call could write the filtered list back and permanently delete a malformed (but recoverable, user-hand-edited) entry. Malformed JSON and non-array files already fail closed to avoid clobbering recoverable user data; arrays with invalid task entries should behave the same so users do not silently lose data on the next write.

Reviewer Test Plan

How to verify

Repro: seed scheduled_tasks.json with an array containing one invalid task entry alongside valid ones, then call readCronTasks() / updateCronTasks().

  • Expected before: the invalid entry is silently filtered out, and the next updateCronTasks() persists the truncated list, losing the entry.
  • Observed after: readCronTasks() rejects with /Invalid task entry/, and updateCronTasks() refuses to write — the on-disk file is left byte-for-byte unchanged.

Verification commands (run from packages/core unless noted):

  • npx vitest run src/services/cronTasksFile.test.ts
  • npm run typecheck --workspace=packages/core
  • npm run build --workspace=packages/core
  • npm run lint -- --fix packages/core/src/services/cronTasksFile.ts packages/core/src/services/cronTasksFile.test.ts
  • npx prettier --check packages/core/src/services/cronTasksFile.ts packages/core/src/services/cronTasksFile.test.ts
  • git diff --check

Evidence (Before & After)

N/A — non-visible storage-validation logic fix; covered by the unit tests above (a read that now throws on invalid entries, plus an update that refuses to clobber the file and asserts the on-disk bytes are unchanged).

Tested on

OS Status
🍏 macOS ✅ CI
🪟 Windows ✅ CI
🐧 Linux ✅ CI

✅ tested · ⚠️ not tested · N/A

Environment (optional)

Unit tests only (npm workspaces); no special environment required.

Risk & Scope

  • Main risk or tradeoff: Low; scoped to packages/core/src/services/cronTasksFile.ts. Behavior change is limited to reads of scheduled_tasks.json that contain invalid task entries — these now throw instead of being silently filtered. Files with malformed JSON or non-array JSON already threw, and fully-valid arrays are unaffected.
  • Not validated / out of scope: No unrelated refactors, no public API changes, no UI changes. Carrying unknown/invalid task shapes through the typed pipeline (auto-recovery) remains a follow-up and is not attempted here.
  • Breaking changes / migration notes: None. (A caller that previously relied on silent filtering of corrupt entries will now see an error surfaced instead, by design.)

Linked Issues

Fixes #5308

中文说明

这个 PR 做了什么

让持久化的 cron 任务读取在 scheduled_tasks.json 的合法数组中包含非法任务条目时「快速失败、拒绝继续」。此前 readCronTasks() 执行 parsed.filter(isValidTask),会静默丢弃非法条目,仅打印一条 debug 警告;随后的 updateCronTasks() 会把过滤后的列表写回磁盘,从而永久删除被丢弃的条目。现在该函数会遍历每一个解析出的条目,遇到第一个非法条目就抛出错误(Invalid task entry at index <n> in <file> — fix or delete the entry; refusing to drop it from the schedule.),当所有条目都合法时则原样返回数组。这样就让「非法条目」的处理与现有的「非法 JSON」「非数组 JSON」处理保持一致:暴露损坏、拒绝写穿。同时移除了已不再使用的 debug logger。

为什么需要它

readCronTasks() 过滤掉 scheduled_tasks.json 中的非法条目,意味着随后的一次 updateCronTasks() 调用会把过滤后的列表写回,从而永久删除一个非法(但可恢复、由用户手工编辑的)条目。非法 JSON 和非数组文件已经会快速失败以避免覆盖可恢复的用户数据;包含非法任务条目的数组也应当采取相同行为,使用户不会在下一次写入时悄无声息地丢失数据。

评审者测试计划

如何验证

复现:在 scheduled_tasks.json 中写入一个数组,包含一个非法任务条目以及若干合法条目,然后调用 readCronTasks() / updateCronTasks()

  • 修复前的预期:非法条目被静默过滤掉,下一次 updateCronTasks() 会持久化被截断的列表,导致该条目丢失。
  • 修复后的观察:readCronTasks()/Invalid task entry/ 拒绝,updateCronTasks() 拒绝写入——磁盘上的文件逐字节保持不变。

验证命令(除特别说明外,均在 packages/core 目录下执行):

  • npx vitest run src/services/cronTasksFile.test.ts
  • npm run typecheck --workspace=packages/core
  • npm run build --workspace=packages/core
  • npm run lint -- --fix packages/core/src/services/cronTasksFile.ts packages/core/src/services/cronTasksFile.test.ts
  • npx prettier --check packages/core/src/services/cronTasksFile.ts packages/core/src/services/cronTasksFile.test.ts
  • git diff --check

证据(前后对比)

N/A —— 非用户可见的存储校验逻辑修复;已由上述单元测试覆盖(一个在遇到非法条目时会抛错的读取测试,外加一个拒绝覆盖文件并断言磁盘字节保持不变的更新测试)。

测试平台

操作系统 状态
🍏 macOS ✅ CI
🪟 Windows ✅ CI
🐧 Linux ✅ CI

✅ 已测试 · ⚠️ 未测试 · N/A

环境(可选)

仅单元测试(npm workspaces);无需特殊环境。

风险与范围

  • 主要风险或取舍:低;范围限定在 packages/core/src/services/cronTasksFile.ts。行为变化仅限于读取包含非法任务条目的 scheduled_tasks.json——这些情况现在会抛错,而非被静默过滤。非法 JSON 或非数组 JSON 的文件本来就会抛错,完全合法的数组不受影响。
  • 未验证 / 范围之外:没有无关重构,没有公共 API 变更,没有 UI 变更。把未知/非法的任务形状贯穿类型化管线(自动恢复)仍是后续工作,本 PR 不做尝试。
  • 破坏性变更 / 迁移说明:无。(此前依赖静默过滤损坏条目的调用方,现在会按设计看到抛出的错误。)

关联 Issue

Fixes #5308

AI Assistance Disclosure

I used Codex to review the changes, sanity-check the implementation against existing patterns, and help spot potential edge cases.

@tt-a1i
tt-a1i force-pushed the fix/cron-invalid-entry-preserve branch from 37ca6c4 to da603b3 Compare June 18, 2026 10:50
@tt-a1i
tt-a1i marked this pull request as ready for review June 18, 2026 17:00
@wenshao

wenshao commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@qwen-code-ci-bot qwen-code-ci-bot 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.

Hi @tt-a1i — thanks for the fix!

The PR body doesn't follow the PR template. The template requires these sections:

  • ## What this PR does — describe the change in prose
  • ## Why it's needed — motivation / user-facing benefit
  • ## Reviewer Test Plan — with ### How to verify, ### Evidence (Before & After), ### Tested on sub-sections
  • ## Risk & Scope — risks, out-of-scope items, breaking changes
  • ## Linked IssuesFixes #N / Closes #N
  • <details>中文说明</details> — full Chinese translation

Your PR currently uses ## Summary, ## Test plan, ## Demo, and ## AI Assistance Disclosure instead. Please restructure the body to match the template so reviewers can evaluate it consistently.

中文说明

你好 @tt-a1i — 感谢你的修复!

PR 正文没有按照 PR 模板 填写。模板要求以下部分:

  • ## What this PR does — 用文字描述变更
  • ## Why it's needed — 动机 / 用户可见的收益
  • ## Reviewer Test Plan — 包含 ### How to verify### Evidence (Before & After)### Tested on 子部分
  • ## Risk & Scope — 风险、不在范围内的内容、破坏性变更
  • ## Linked IssuesFixes #N / Closes #N
  • <details>中文说明</details> — 完整中文翻译

当前 PR 使用的是 ## Summary## Test plan## Demo## AI Assistance Disclosure。请按照模板重新组织内容,以便审查者能够一致地评估。

Qwen Code · qwen3.7-max

@wenshao

wenshao commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks @tt-a1i for the fix!

Template looks good now ✓ — all required sections present.

On direction: this solves a real data-loss bug. readCronTasks() silently dropping invalid entries, then updateCronTasks() persisting the filtered list, permanently loses user data. Aligning invalid-entry handling with the existing malformed-JSON and non-array behavior (fail closed, refuse to write through corruption) is clearly the right call. category/core and scope/session-management territory.

On approach: the scope is tight — one function changed from filter + warn to iterate + throw, unused import cleaned up, two targeted tests. No scope creep, no drive-by refactors. Exactly the minimal change the bug demands.

Moving on to code review. 🔍

中文说明

感谢 @tt-a1i 的修复!

模板现已完整 ✓ — 所有必需章节均已填写。

方向:这解决了一个真实的数据丢失 bug。readCronTasks() 静默丢弃非法条目,然后 updateCronTasks() 把过滤后的列表写回磁盘,导致用户数据永久丢失。将非法条目的处理与现有的非法 JSON 和非数组处理对齐(快速失败、拒绝写穿)显然是正确的做法。属于 category/corescope/session-management 范畴。

方案:范围很紧凑 — 一个函数从 filter + warn 改为 iterate + throw,清理了不再使用的导入,两个有针对性的测试。没有范围蔓延,没有顺手重构。正是这个 bug 所需的最小改动。

进入代码审查 🔍

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Code Review

Independent proposal: Same approach — replace the silent filter(isValidTask) + debugLogger.warn() with a loop that throws on the first invalid entry, aligning with the existing fail-closed behavior for malformed JSON and non-array files. Remove the now-unused createDebugLogger import.

Comparison with the PR: The implementation matches exactly. The for...of parsed.entries() loop is clean, the error message includes the index and file path for debuggability, and the debugLogger removal is correct (it was the only call site in the file). No simpler path was missed.

Findings: No correctness bugs, security issues, or AGENTS.md violations. The diff is focused — every line changed serves the stated goal. The type narrowing works because every entry must pass the isValidTask type guard or the function throws before returning.

Test Results

22 tests pass (up from 21 on main — one new test added, one modified):

 RUN  v3.2.4 packages/core
      Coverage enabled with v8

 ✓ src/services/cronTasksFile.test.ts (22 tests) 201ms

 Test Files  1 passed (1)
      Tests  22 passed (22)
   Start at  20:02:58
   Duration  4.25s

New testrefuses to clobber a file with invalid task entries: seeds a file with [validTask, {id: 'bad'}], calls updateCronTasks with a mutation that adds a task, asserts the read throws /Invalid task entry/ and the on-disk bytes are unchanged. This directly validates the data-loss prevention the PR claims to fix.

Modified testthrows for invalid task entries (was filters out invalid entries): same seed, but now asserts readCronTasks rejects instead of returning a truncated array.

Evidence (Before & After): N/A — non-visible storage-validation logic, covered by unit tests above.

中文说明

代码审查

独立方案: 相同的方法 — 将静默的 filter(isValidTask) + debugLogger.warn() 替换为一个循环,在遇到第一个非法条目时抛出错误,与现有的非法 JSON 和非数组文件的快速失败行为保持一致。移除不再使用的 createDebugLogger 导入。

与 PR 的对比: 实现完全匹配。for...of parsed.entries() 循环简洁明了,错误消息包含索引和文件路径便于调试,debugLogger 的移除是正确的(这是文件中唯一的调用点。没有遗漏更简单的路径。

发现: 没有正确性 bug、安全问题或违反 AGENTS.md 的情况。diff 很聚焦 — 每一行改动都服务于声明的目标。类型收窄有效,因为每个条目必须通过 isValidTask 类型守卫,否则函数会在返回前抛出。

测试结果

22 个测试全部通过(main 上是 21 个 — 新增一个,修改一个):

 RUN  v3.2.4 packages/core
      Coverage enabled with v8

 ✓ src/services/cronTasksFile.test.ts (22 tests) 201ms

 Test Files  1 passed (1)
      Tests  22 passed (22)
   Start at  20:02:58
   Duration  4.25s

新测试refuses to clobber a file with invalid task entries:用 [validTask, {id: 'bad'}] 填充文件,调用带有一个添加任务变更的 updateCronTasks,断言读取抛出 /Invalid task entry/ 且磁盘上的字节保持不变。这直接验证了 PR 声称修复的数据丢失防护。

修改的测试throws for invalid task entries(原 filters out invalid entries):相同的数据,但现在断言 readCronTasks 拒绝而非返回截断的数组。

证据(前后对比):N/A — 非用户可见的存储校验逻辑,由上述单元测试覆盖。

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Stepping back: this is a clean, well-scoped fix for a real data-loss bug. The motivation is solid — silently dropping entries and then persisting the truncated list is the kind of corruption that's painful to debug when it bites. The fix aligns perfectly with the existing fail-closed philosophy already used for malformed JSON and non-array files.

The implementation matches what I'd write independently. The diff is +23/-22 — almost a wash — and every line earns its place. The for...of parsed.entries() loop is more readable than the old filter + warn combo, the error message tells the user exactly which entry is bad and where to find it, and the debugLogger cleanup is the right housekeeping.

The new refuses to clobber test is the standout — it doesn't just verify that the read throws, it verifies that the on-disk file is byte-for-byte unchanged after a failed update. That's the actual property users care about: "my file won't get clobbered."

All 22 tests pass. No correctness concerns, no convention violations, no scope creep. This is the kind of PR that's easy to maintain — focused, well-tested, and does exactly what it says.

Approving. ✅

中文说明

回顾:这是一个干净、范围合理的修复,解决了一个真实的数据丢失 bug。动机很充分 — 静默丢弃条目然后持久化截断后的列表,是那种一旦出问题就很难调试的损坏类型。修复与现有的非法 JSON 和非数组文件的快速失败理念完全一致。

实现与我独立设想的方案一致。diff 是 +23/-22 — 几乎持平 — 每一行都物有所值。for...of parsed.entries() 循环比旧的 filter + warn 组合更易读,错误消息精确告诉用户哪个条目有问题、在哪里找到它,debugLogger 的清理是正确的整理工作。

新的 refuses to clobber 测试是亮点 — 它不仅验证读取会抛出错误,还验证了磁盘上的文件在失败的更新后逐字节保持不变。这才是用户真正关心的属性:"我的文件不会被覆盖。"

22 个测试全部通过。没有正确性问题、没有违反规范、没有范围蔓延。这是易于维护的 PR — 聚焦、测试充分、言行一致。

批准 ✅

Qwen Code · qwen3.7-max

@qwen-code-ci-bot qwen-code-ci-bot 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.

LGTM, looks ready to ship. ✅

@qwen-code-ci-bot qwen-code-ci-bot added category/core Core engine and logic scope/session-management Session state and persistence type/bug Something isn't working as expected labels Jun 18, 2026
@wenshao

wenshao commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator

✅ Local build & verification — LGTM, ready to merge

Built the real artifacts from this PR's HEAD and verified the fail-closed behavior end-to-end on a real filesystem, including how the existing callers handle the new throw. Correct, well-scoped, and well-guarded by tests. Recommending merge.

What this PR does

readCronTasks() now throws on the first invalid task entry in an otherwise-valid array (Invalid task entry at index <n> in <file> …) instead of silently filter()-ing it out. This stops a later updateCronTasks() from persisting the filtered list and permanently deleting a recoverable, hand-edited entry — aligning invalid-entry handling with the existing malformed-JSON / non-array behavior (fail closed, refuse to write through corruption). The now-unused debug logger is removed. Fixes #5308.

Environment

  • macOS (Darwin 25.5.0), Node v22.22.2, npm 10.9.7
  • Worktree on PR HEAD da603b3d08; base origin/main (31 commits behind but MERGEABLE; the 2 changed files are clean against origin/main).

Real-filesystem behavior (actual source, isolated via Storage.setRuntimeBaseDir, no ~/.qwen touch)

Scenario Observed
[valid, invalid]readCronTasks() throws Invalid task entry at index 1 in …/scheduled_tasks.json — fix or delete the entry; refusing to drop it from the schedule.
[valid, invalid]updateCronTasks(append) throws, and on-disk file is byte-for-byte unchanged ✅ (refuse to clobber)
[valid, valid]readCronTasks() returns both, unchanged ✅
[] (empty) → readCronTasks() returns [], no false throw
[null, valid]readCronTasks() throws at index 0 (null/non-object rejected) ✅

Verification matrix

Check Command / method Result
Unit tests vitest run …/cronTasksFile.test.ts ✅ 22 passed (incl. the 2 new)
Real-fs harness exercise real readCronTasks/updateCronTasks ✅ table above
Mutation A/B revert throw → parsed.filter(isValidTask), re-run ✅ exactly the 2 new tests fail (throws for invalid task entries, refuses to clobber …); other 20 pass → genuine guards
Blast radius inspect callers of readCronTasks ✅ both already fail-closed — see below
Debug-logger removal grep source + shipped artifact ✅ no dangling reference; old Dropped … invalid task entr… string gone from the bundle
Typecheck tsc --noEmit (packages/core) ✅ clean
Lint eslint (2 changed files) ✅ clean
Format prettier --check (2 files) ✅ clean
Whitespace git diff --check ✅ clean
Real build npm run build + npm run bundle ✅ both exit 0
Fix in shipped artifacts grep core dist + CLI chunk ✅ marker present in packages/core/dist/...cronTasksFile.js and dist/chunks/chunk-*.js
Built binary boots node dist/cli.js --version 0.18.3

Callers handle the new throw gracefully (the key safety check)

readCronTasks already threw for malformed-JSON / non-array; this PR extends the same contract to invalid entries. Both production callers were already designed for it:

  • cron-list.ts wraps the read in try/catch and surfaces a clean error result to the user (Error listing cron jobs: …) — no crash.
  • cronScheduler.ts loadFileTasks() catches and keeps its current in-memory view, retrying on a later reload (explicitly avoids wiping loaded durable jobs). updateCronTasks() reads before writing, so a throw means the write never happens — the file is preserved for recovery.

Behavioral tradeoff (deliberate, non-blocking)

With this change, a single invalid entry now blocks the whole schedule from loading (and surfaces an error in cron list) until the user fixes/deletes it, rather than silently loading the valid subset. That is the intended fail-closed tradeoff — identical to how malformed-JSON / non-array files already behave — and is preferable to silently and permanently losing a recoverable entry on the next write. The error names the exact index to fix.

Verdict: LGTM ✅ — a correct, consistent data-safety fix; callers degrade gracefully and the two new tests genuinely guard the behavior.

🇨🇳 中文版(点击展开)

✅ 本地构建验证 — LGTM,可以合并

我从该 PR 的 HEAD 在本地构建了真实产物,并在真实文件系统上端到端验证了「快速失败」行为,包括现有调用方如何处理这个新抛出的错误。改动正确、范围收敛、且有测试守护。建议合并。

这个 PR 做了什么

readCronTasks() 现在在「合法数组里出现第一个非法任务条目」时抛错(Invalid task entry at index <n> in <file> …),而不再静默 filter() 掉它。这避免了随后的 updateCronTasks() 把过滤后的列表写回、从而永久删除一个可恢复的、用户手工编辑的条目 —— 让非法条目的处理与现有的「非法 JSON / 非数组」行为保持一致(快速失败、拒绝写穿损坏数据)。同时移除了已不再使用的 debug logger。修复 #5308

环境

  • macOS(Darwin 25.5.0)、Node v22.22.2、npm 10.9.7
  • worktree 检出在 PR HEAD da603b3d08;base 为 origin/main(落后 31 个提交,但 MERGEABLE;2 个改动文件相对 origin/main 干净)。

真实文件系统行为(真实源码,通过 Storage.setRuntimeBaseDir 隔离,不碰真实 ~/.qwen)

场景 观察结果
[合法, 非法]readCronTasks() 抛错 Invalid task entry at index 1 in …/scheduled_tasks.json — fix or delete the entry; refusing to drop it from the schedule.
[合法, 非法]updateCronTasks(追加) 抛错,且磁盘文件逐字节不变 ✅(拒绝覆盖)
[合法, 合法]readCronTasks() 原样返回两条 ✅
[](空数组) → readCronTasks() 返回 [],不会误抛
[null, 合法]readCronTasks() index 0 抛错(null/非对象被拒) ✅

验证矩阵

检查项 命令 / 方法 结果
单元测试 vitest run …/cronTasksFile.test.ts ✅ 22 通过(含 2 个新增)
真实文件系统 harness 跑真实的 readCronTasks/updateCronTasks ✅ 见上表
变异 A/B 把抛错改回 parsed.filter(isValidTask) 后重跑 ✅ 恰好那 2 个新测试失败(throws for invalid task entriesrefuses to clobber …),其余 20 个通过 → 是有效守卫
影响范围 检查 readCronTasks 的调用方 ✅ 两个调用方本来就快速失败 —— 见下
debug logger 移除 grep 源码 + 发布产物 ✅ 无悬挂引用;旧的 Dropped … invalid task entr… 字符串已从 bundle 中消失
类型检查 tsc --noEmit(packages/core) ✅ 干净
Lint eslint(2 个改动文件) ✅ 干净
格式 prettier --check(2 个文件) ✅ 干净
空白字符 git diff --check ✅ 干净
真实构建 npm run build + npm run bundle ✅ 均 exit 0
修复进入发布产物 grep core dist + CLI chunk ✅ 标记出现在 packages/core/dist/...cronTasksFile.jsdist/chunks/chunk-*.js
构建出的二进制可启动 node dist/cli.js --version 0.18.3

调用方能优雅处理新抛出的错误(关键安全检查)

readCronTasks 本来就会对「非法 JSON / 非数组」抛错,这个 PR 只是把同样的契约扩展到非法条目。两个生产调用方本来就为此设计:

  • cron-list.tstry/catch 包住读取,并向用户返回一个干净的错误结果(Error listing cron jobs: …)—— 不会崩溃。
  • cronScheduler.tsloadFileTasks() 捕获后保留当前内存视图,等下一次 reload 重试(明确避免清空已加载的持久任务)。updateCronTasks() 先读后写,所以抛错就意味着写入根本不会发生 —— 文件被保留下来以便恢复。

行为取舍(有意为之,不阻塞)

此改动后,单个非法条目现在会阻止整个计划表加载(并在 cron list 里暴露错误),直到用户修复/删除它,而不再静默加载合法子集。这是有意的「快速失败」取舍 —— 与「非法 JSON / 非数组」文件现有行为完全一致 —— 比起在下一次写入时静默且永久丢失一个可恢复条目要更好。错误信息会指明具体要修复的 index。

结论:LGTM ✅ —— 一个正确、一致的数据安全修复;调用方能优雅降级,两个新测试能真正守住该行为。

@wenshao
wenshao merged commit 7541ddb into QwenLM:main Jun 18, 2026
35 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

category/core Core engine and logic scope/session-management Session state and persistence type/bug Something isn't working as expected

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug(core): invalid cron task entries can be lost on update

3 participants