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 <saoudrizwan@users.noreply.github.com>

* Require the virtual hub/schedules path when exempting specs from removal reconciliation

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

* Treat recorded source mtime as proof a spec is file-backed, closing the hub/schedules spoof gap

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
This commit is contained in:
Saoud Rizwan
2026-08-27 13:59:08 -07:00
committed by GitHub
parent 889010b0b9
commit c97e4af8fa
3 changed files with 83 additions and 5 deletions
@@ -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();
@@ -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/<id>.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;
@@ -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<CronSpecRecord, "source" | "sourcePath" | "sourceMtimeMs">,
): boolean {
return (
spec.source === "hub-schedule" &&
spec.sourcePath.startsWith(HUB_SCHEDULE_SOURCE_PATH_PREFIX) &&
spec.sourceMtimeMs === undefined
);
}
function hubScheduleMetadata(