From c97e4af8faa2f3be8d8328eb118265f1c39b834c Mon Sep 17 00:00:00 2001 From: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com> Date: Thu, 27 Aug 2026 13:59:08 -0700 Subject: [PATCH] Fix scheduled tasks disappearing after desktop app updates (#13627) * Fix hub-managed schedules being wiped by cron reconciliation on hub restart Co-authored-by: Saoud Rizwan * Require the virtual hub/schedules path when exempting specs from removal reconciliation Co-authored-by: Saoud Rizwan * Treat recorded source mtime as proof a spec is file-backed, closing the hub/schedules spoof gap Co-authored-by: Saoud Rizwan --------- Co-authored-by: Saoud Rizwan --- .../src/cron/specs/cron-reconciler.test.ts | 53 +++++++++++++++++++ .../core/src/cron/specs/cron-reconciler.ts | 13 +++-- .../core/src/cron/store/sqlite-cron-store.ts | 22 +++++++- 3 files changed, 83 insertions(+), 5 deletions(-) diff --git a/sdk/packages/core/src/cron/specs/cron-reconciler.test.ts b/sdk/packages/core/src/cron/specs/cron-reconciler.test.ts index a8c34926a3..50eac1a625 100644 --- a/sdk/packages/core/src/cron/specs/cron-reconciler.test.ts +++ b/sdk/packages/core/src/cron/specs/cron-reconciler.test.ts @@ -103,6 +103,59 @@ describe("CronReconciler", () => { expect(all[0]?.removed).toBe(true); }); + it("does not remove hub-managed schedules with virtual source paths", async () => { + const created = store.createHubSchedule({ + name: "Daily digest", + cronPattern: "0 9 * * *", + prompt: "Summarize the day", + workspaceRoot: "/ws", + }); + + // Simulates hub startup (e.g. after an app update) with no cron spec + // files on disk: DB-native hub schedules must survive reconciliation. + const summary = await reconciler.reconcileAll(); + expect(summary.removed).toBe(0); + + const schedule = requireValue(store.getSpec(created.specId)); + expect(schedule.removed).toBe(false); + expect(schedule.enabled).toBe(true); + expect(store.listHubSchedules().map((s) => s.specId)).toContain( + created.specId, + ); + }); + + it("still removes deleted file specs that spoof the hub-schedule source", async () => { + writeSpec( + "impostor.cron.md", + `---\nid: impostor\nworkspaceRoot: /ws\nschedule: "0 9 * * *"\nsource: hub-schedule\n---\nBody`, + ); + await reconciler.reconcileAll(); + const spec = requireValue(store.getSpecBySourcePath("impostor.cron.md")); + expect(spec.source).toBe("hub-schedule"); + + rmSync(join(cronDir, "impostor.cron.md")); + const summary = await reconciler.reconcileAll(); + expect(summary.removed).toBe(1); + expect(requireValue(store.getSpec(spec.specId)).removed).toBe(true); + }); + + it("still removes deleted spoofing files inside a physical hub/schedules directory", async () => { + writeSpec( + "hub/schedules/nested-impostor.cron.md", + `---\nid: nested-impostor\nworkspaceRoot: /ws\nschedule: "0 9 * * *"\nsource: hub-schedule\n---\nBody`, + ); + await reconciler.reconcileAll(); + const spec = requireValue( + store.getSpecBySourcePath("hub/schedules/nested-impostor.cron.md"), + ); + expect(spec.source).toBe("hub-schedule"); + + rmSync(join(cronDir, "hub/schedules/nested-impostor.cron.md")); + const summary = await reconciler.reconcileAll(); + expect(summary.removed).toBe(1); + expect(requireValue(store.getSpec(spec.specId)).removed).toBe(true); + }); + it("cancels queued runs when spec is removed", async () => { writeSpec("cleanup.md", `---\nid: cleanup\nworkspaceRoot: /ws\n---\nBody`); await reconciler.reconcileAll(); diff --git a/sdk/packages/core/src/cron/specs/cron-reconciler.ts b/sdk/packages/core/src/cron/specs/cron-reconciler.ts index 3cf811a71b..ed9c69aa53 100644 --- a/sdk/packages/core/src/cron/specs/cron-reconciler.ts +++ b/sdk/packages/core/src/cron/specs/cron-reconciler.ts @@ -6,10 +6,11 @@ import { resolveCronSpecsDir, } from "@cline/shared/storage"; import { getNextCronTime } from "../schedule/scheduler"; -import type { - CronSpecRecord, - SqliteCronStore, - UpsertSpecResult, +import { + type CronSpecRecord, + isHubManagedSpec, + type SqliteCronStore, + type UpsertSpecResult, } from "../store/sqlite-cron-store"; import { type ParseCronSpecInput, parseCronSpecFile } from "./cron-spec-parser"; @@ -130,6 +131,10 @@ export class CronReconciler { limit: 10_000, }); for (const spec of existing) { + // Hub-managed schedules live only in cron.db with a virtual + // sourcePath (`hub/schedules/.cron.md`) that never exists on + // disk — they must not be treated as deleted spec files. + if (isHubManagedSpec(spec)) continue; if (!seenPaths.has(spec.sourcePath)) { this.handleFileDeleted(spec); summary.removed += 1; diff --git a/sdk/packages/core/src/cron/store/sqlite-cron-store.ts b/sdk/packages/core/src/cron/store/sqlite-cron-store.ts index 98def89e90..4baeffffd1 100644 --- a/sdk/packages/core/src/cron/store/sqlite-cron-store.ts +++ b/sdk/packages/core/src/cron/store/sqlite-cron-store.ts @@ -364,8 +364,28 @@ function filenameStemFromPath(sourcePath: string): string { .replace(/\.md$/, ""); } +const HUB_SCHEDULE_SOURCE_PATH_PREFIX = "hub/schedules/"; + function hubScheduleSourcePath(scheduleId: string): string { - return `hub/schedules/${scheduleId}.cron.md`; + return `${HUB_SCHEDULE_SOURCE_PATH_PREFIX}${scheduleId}.cron.md`; +} + +/** + * DB-native hub schedules (created via the schedule tools/UI) live only in + * cron.db under a virtual sourcePath that never exists on disk. File-backed + * specs can spoof `source: hub-schedule` in frontmatter — even inside a + * physical `hub/schedules/` directory — but reconciliation always records + * their source file's mtime, which DB-native rows never have. All three + * markers are required to identify a DB-native hub schedule. + */ +export function isHubManagedSpec( + spec: Pick, +): boolean { + return ( + spec.source === "hub-schedule" && + spec.sourcePath.startsWith(HUB_SCHEDULE_SOURCE_PATH_PREFIX) && + spec.sourceMtimeMs === undefined + ); } function hubScheduleMetadata(