mirror of
https://github.com/cline/cline.git
synced 2026-09-01 15:11:04 +08:00
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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user