@crewx/cron 0.1.10-rc.144 → 0.1.10-rc.146

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -33,16 +33,21 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.DATA_PATHS = exports.getProjectRoot = exports.BUILTIN_SCHEDULES = void 0;
36
+ exports.DATA_PATHS = exports.CRON_RUNTIME_PROCESS_LEASE_FILENAME = exports.createCronRuntime = exports.getProjectRoot = exports.BUILTIN_SCHEDULES = exports.CronStoreError = void 0;
37
37
  exports.getLocalTimestamp = getLocalTimestamp;
38
38
  exports.logUsage = logUsage;
39
+ exports.acquireWorkspaceLock = acquireWorkspaceLock;
40
+ exports.releaseWorkspaceLock = releaseWorkspaceLock;
41
+ exports.migrateLegacyMemoryDebounceSchedule = migrateLegacyMemoryDebounceSchedule;
39
42
  exports.normalizeMode = normalizeMode;
43
+ exports.createScheduleStore = createScheduleStore;
40
44
  exports.loadSchedules = loadSchedules;
41
45
  exports.saveSchedules = saveSchedules;
42
46
  exports.getRemovedBuiltins = getRemovedBuiltins;
43
47
  exports.seedBuiltinSchedules = seedBuiltinSchedules;
44
48
  exports.acquireLock = acquireLock;
45
49
  exports.releaseLock = releaseLock;
50
+ exports.releaseRunLock = releaseRunLock;
46
51
  exports.shouldRunNow = shouldRunNow;
47
52
  exports.describeCron = describeCron;
48
53
  exports.calculateNextRun = calculateNextRun;
@@ -53,6 +58,8 @@ exports.startDaemonSafe = startDaemonSafe;
53
58
  exports.ensureDaemonRunning = ensureDaemonRunning;
54
59
  exports.getDaemonStatus = getDaemonStatus;
55
60
  exports.executeSchedule = executeSchedule;
61
+ exports.getLocalMinuteKey = getLocalMinuteKey;
62
+ exports.buildOccurrenceKey = buildOccurrenceKey;
56
63
  exports.daemonTick = daemonTick;
57
64
  exports.runDaemonForeground = runDaemonForeground;
58
65
  exports.addSchedule = addSchedule;
@@ -61,13 +68,17 @@ exports.enableSchedule = enableSchedule;
61
68
  exports.disableSchedule = disableSchedule;
62
69
  exports.updateSchedule = updateSchedule;
63
70
  const child_process_1 = require("child_process");
71
+ const crypto_1 = require("crypto");
64
72
  const path = __importStar(require("path"));
65
73
  const fs = __importStar(require("fs"));
74
+ const types_1 = require("./types");
75
+ Object.defineProperty(exports, "CronStoreError", { enumerable: true, get: function () { return types_1.CronStoreError; } });
66
76
  const sdk_1 = require("@crewx/sdk");
67
77
  const builtins_1 = require("./builtins");
68
78
  Object.defineProperty(exports, "BUILTIN_SCHEDULES", { enumerable: true, get: function () { return builtins_1.BUILTIN_SCHEDULES; } });
69
79
  Object.defineProperty(exports, "getProjectRoot", { enumerable: true, get: function () { return builtins_1.getProjectRoot; } });
70
- const DATA_DIR = path.join(process.cwd(), '.crewx');
80
+ const DEFAULT_WORKSPACE_PATH = path.resolve(process.cwd());
81
+ const DATA_DIR = path.join(DEFAULT_WORKSPACE_PATH, '.crewx');
71
82
  const SCHEDULES_FILE = path.join(DATA_DIR, '.cron-data.json');
72
83
  const LOG_FILE = path.join(DATA_DIR, '.cron-daemon.log');
73
84
  const LOCK_FILE = path.join(DATA_DIR, '.cron-data.json.lock');
@@ -75,21 +86,40 @@ const USAGE_LOG_FILE = path.join(DATA_DIR, 'cron-usage.log');
75
86
  const PID_FILE = path.join(DATA_DIR, '.cron-daemon.pid');
76
87
  const BUILTINS_META_FILE = path.join(DATA_DIR, '.cron-builtins.json');
77
88
  function resolveDataDir(dir) {
78
- return dir ? path.join(dir, '.crewx') : DATA_DIR;
89
+ return path.join(path.resolve(dir ?? DEFAULT_WORKSPACE_PATH), '.crewx');
79
90
  }
80
91
  function resolveSchedulesFile(dir) {
81
92
  return path.join(resolveDataDir(dir), '.cron-data.json');
82
93
  }
94
+ function resolveLockFile(dir) {
95
+ return path.join(resolveDataDir(dir), '.cron-data.json.lock');
96
+ }
97
+ function resolveUsageLogFile(dir) {
98
+ return path.join(resolveDataDir(dir), 'cron-usage.log');
99
+ }
83
100
  function resolvePidFile(dir) {
84
101
  return path.join(resolveDataDir(dir), '.cron-daemon.pid');
85
102
  }
86
103
  function resolveBuiltinsMetaFile(dir) {
87
104
  return path.join(resolveDataDir(dir), '.cron-builtins.json');
88
105
  }
106
+ function reserveAdmission(taskId, scheduleId, timeoutMs) {
107
+ const reserved = (0, sdk_1.reserveUpdateAdmission)(taskId, scheduleId, timeoutMs);
108
+ if (!reserved) {
109
+ console.error(`[${getLocalTimestamp()}] Failed to record update-admission reservation for task ${taskId}`);
110
+ }
111
+ return reserved;
112
+ }
113
+ function releaseAdmissionReservation(taskId) {
114
+ (0, sdk_1.releaseUpdateAdmissionReservation)(taskId);
115
+ }
89
116
  function resolveRunLockFile(scheduleId, dir) {
90
117
  return path.join(resolveDataDir(dir), `.cron-run-${scheduleId}.lock`);
91
118
  }
92
119
  const DAEMON_INTERVAL_MS = 60 * 1000;
120
+ const STORE_LOCK_TIMEOUT_MS = 5000;
121
+ const STORE_LOCK_POLL_MS = 25;
122
+ const STORE_LOCK_STALE_MS = 30000;
93
123
  const RUN_LOCK_STALE_GRACE_MS = 5000;
94
124
  function getLocalTimestamp() {
95
125
  const now = new Date();
@@ -101,12 +131,45 @@ function getLocalTimestamp() {
101
131
  const s = String(now.getSeconds()).padStart(2, '0');
102
132
  return `${y}-${m}-${d} ${h}:${min}:${s}`;
103
133
  }
104
- function logUsage(...args) {
105
- ensureDataDir();
134
+ function logUsage(dirOrMessage, ...args) {
135
+ const isWorkspacePath = (value) => value !== undefined && (value === '.' ||
136
+ value === '..' ||
137
+ path.isAbsolute(value) ||
138
+ value.includes(path.sep) ||
139
+ value.includes('/') ||
140
+ value.includes('\\') ||
141
+ value.endsWith('.crewx') ||
142
+ (() => {
143
+ try {
144
+ return fs.statSync(value).isDirectory();
145
+ }
146
+ catch {
147
+ return false;
148
+ }
149
+ })());
150
+ let workspacePath;
151
+ let messageParts;
152
+ if (args.length > 0 && (dirOrMessage === undefined || isWorkspacePath(dirOrMessage))) {
153
+ workspacePath = dirOrMessage;
154
+ messageParts = args;
155
+ }
156
+ else if (args.length === 0 && isWorkspacePath(dirOrMessage)) {
157
+ workspacePath = dirOrMessage;
158
+ messageParts = [];
159
+ }
160
+ else if (args.length === 1 && isWorkspacePath(args[0])) {
161
+ workspacePath = args[0];
162
+ messageParts = dirOrMessage === undefined ? [] : [dirOrMessage];
163
+ }
164
+ else {
165
+ workspacePath = undefined;
166
+ messageParts = dirOrMessage === undefined ? args : [dirOrMessage, ...args];
167
+ }
168
+ ensureDataDir(workspacePath);
106
169
  const timestamp = getLocalTimestamp();
107
- const message = args.join(' ');
170
+ const message = messageParts.join(' ');
108
171
  const logLine = `${timestamp} | [cron] ${message}\n`;
109
- fs.appendFileSync(USAGE_LOG_FILE, logLine);
172
+ fs.appendFileSync(resolveUsageLogFile(workspacePath), logLine);
110
173
  }
111
174
  function ensureDataDir(dir) {
112
175
  const dataDir = resolveDataDir(dir);
@@ -122,6 +185,76 @@ function safeReadJsonFile(filePath) {
122
185
  return null;
123
186
  }
124
187
  }
188
+ const STORE_LOCK_WAIT_BUFFER = new Int32Array(new SharedArrayBuffer(4));
189
+ function waitForStoreLock() {
190
+ Atomics.wait(STORE_LOCK_WAIT_BUFFER, 0, 0, STORE_LOCK_POLL_MS);
191
+ }
192
+ function readStoreLock(lockPath) {
193
+ try {
194
+ const parsed = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
195
+ if (typeof parsed.token !== 'string' ||
196
+ typeof parsed.pid !== 'number' ||
197
+ typeof parsed.created_at !== 'number') {
198
+ return null;
199
+ }
200
+ return parsed;
201
+ }
202
+ catch {
203
+ return null;
204
+ }
205
+ }
206
+ function acquireWorkspaceLock(workspacePath, timeout = STORE_LOCK_TIMEOUT_MS) {
207
+ const normalizedWorkspacePath = path.resolve(workspacePath);
208
+ ensureDataDir(normalizedWorkspacePath);
209
+ const lockPath = resolveLockFile(normalizedWorkspacePath);
210
+ const token = (0, crypto_1.randomUUID)();
211
+ const deadline = Date.now() + Math.max(0, timeout);
212
+ const owner = {
213
+ workspacePath: normalizedWorkspacePath,
214
+ lockPath,
215
+ token,
216
+ };
217
+ while (true) {
218
+ const payload = {
219
+ token,
220
+ pid: process.pid,
221
+ created_at: Date.now(),
222
+ };
223
+ try {
224
+ fs.writeFileSync(lockPath, JSON.stringify(payload), { flag: 'wx', mode: 0o600 });
225
+ return owner;
226
+ }
227
+ catch (error) {
228
+ const err = error;
229
+ if (err.code !== 'EEXIST')
230
+ throw error;
231
+ const existing = readStoreLock(lockPath);
232
+ if (existing && Date.now() - existing.created_at > STORE_LOCK_STALE_MS) {
233
+ releaseWorkspaceLock({ ...owner, token: existing.token });
234
+ continue;
235
+ }
236
+ if (Date.now() >= deadline) {
237
+ throw new types_1.CronStoreError('CRON_STORE_LOCK_TIMEOUT', `Timed out waiting for cron store lock: ${lockPath}`, lockPath);
238
+ }
239
+ waitForStoreLock();
240
+ }
241
+ }
242
+ }
243
+ function releaseWorkspaceLock(owner) {
244
+ const current = readStoreLock(owner.lockPath);
245
+ if (!current || current.token !== owner.token)
246
+ return false;
247
+ try {
248
+ fs.unlinkSync(owner.lockPath);
249
+ return true;
250
+ }
251
+ catch (error) {
252
+ const err = error;
253
+ if (err.code === 'ENOENT')
254
+ return false;
255
+ throw error;
256
+ }
257
+ }
125
258
  const MEMORY_DEBOUNCE_BUILTIN = (0, builtins_1.findBuiltinById)('memory-debounce');
126
259
  function maybeRewriteLegacyMemoryCommand(command) {
127
260
  if (!command.includes('/skills/memory-v2/memory-v2.js')) {
@@ -129,9 +262,9 @@ function maybeRewriteLegacyMemoryCommand(command) {
129
262
  }
130
263
  return MEMORY_DEBOUNCE_BUILTIN.buildCommand();
131
264
  }
132
- function migrateLegacyMemoryDebounceSchedule() {
133
- ensureDataDir();
134
- const legacyFile = path.join((0, builtins_1.getProjectRoot)(), 'skills', 'cron', '.cron-data.json');
265
+ function migrateLegacyMemoryDebounceSchedule(workspacePath = (0, builtins_1.getProjectRoot)()) {
266
+ const normalizedWorkspacePath = path.resolve(workspacePath);
267
+ const legacyFile = path.join(normalizedWorkspacePath, 'skills', 'cron', '.cron-data.json');
135
268
  if (!fs.existsSync(legacyFile)) {
136
269
  return;
137
270
  }
@@ -143,7 +276,8 @@ function migrateLegacyMemoryDebounceSchedule() {
143
276
  if (!legacySchedule) {
144
277
  return;
145
278
  }
146
- const targetSchedules = fs.existsSync(SCHEDULES_FILE) ? safeReadJsonFile(SCHEDULES_FILE) ?? [] : [];
279
+ const store = createScheduleStore(normalizedWorkspacePath);
280
+ const targetSchedules = store.list();
147
281
  if (targetSchedules.some(s => s.name === MEMORY_DEBOUNCE_BUILTIN.name)) {
148
282
  return;
149
283
  }
@@ -153,7 +287,7 @@ function migrateLegacyMemoryDebounceSchedule() {
153
287
  updated_at: new Date().toISOString(),
154
288
  next_run: calculateNextRun(legacySchedule.cron, legacySchedule.timezone),
155
289
  };
156
- saveSchedules([...targetSchedules, migratedSchedule]);
290
+ store.create(migratedSchedule);
157
291
  }
158
292
  function warnIfOverdriveInCommand(command) {
159
293
  if (command.includes('--overdrive')) {
@@ -167,31 +301,255 @@ function normalizeMode(mode) {
167
301
  return 'command';
168
302
  return 'execute';
169
303
  }
170
- function loadSchedules(dir) {
171
- ensureDataDir(dir);
172
- if (!dir) {
173
- migrateLegacyMemoryDebounceSchedule();
304
+ function cloneSchedule(schedule) {
305
+ return {
306
+ ...schedule,
307
+ ...(schedule.env ? { env: { ...schedule.env } } : {}),
308
+ };
309
+ }
310
+ function readSchedulesFile(schedulesFile) {
311
+ let data;
312
+ try {
313
+ data = fs.readFileSync(schedulesFile, 'utf8');
174
314
  }
175
- const schedulesFile = resolveSchedulesFile(dir);
176
- if (!fs.existsSync(schedulesFile)) {
177
- return [];
315
+ catch (error) {
316
+ const err = error;
317
+ if (err.code === 'ENOENT')
318
+ return [];
319
+ throw error;
178
320
  }
321
+ let parsed;
179
322
  try {
180
- const data = fs.readFileSync(schedulesFile, 'utf8');
181
- return JSON.parse(data);
323
+ parsed = JSON.parse(data);
182
324
  }
183
- catch (e) {
184
- const err = e;
185
- console.error('Failed to load schedules:', err.message);
186
- return [];
325
+ catch (error) {
326
+ throw new types_1.CronStoreError('CRON_STORE_CORRUPT', `Cron schedule store is not valid JSON: ${schedulesFile}`, schedulesFile);
327
+ }
328
+ if (!Array.isArray(parsed)) {
329
+ throw new types_1.CronStoreError('CRON_STORE_CORRUPT', `Cron schedule store must contain a top-level array: ${schedulesFile}`, schedulesFile);
330
+ }
331
+ return parsed;
332
+ }
333
+ function syncDirectory(directory) {
334
+ if (process.platform === 'win32')
335
+ return;
336
+ try {
337
+ const fd = fs.openSync(directory, 'r');
338
+ try {
339
+ fs.fsyncSync(fd);
340
+ }
341
+ finally {
342
+ fs.closeSync(fd);
343
+ }
344
+ }
345
+ catch {
346
+ }
347
+ }
348
+ function writeSchedulesAtomic(schedulesFile, schedules) {
349
+ const directory = path.dirname(schedulesFile);
350
+ const tempFile = path.join(directory, `.${path.basename(schedulesFile)}.${process.pid}.${(0, crypto_1.randomUUID)()}.tmp`);
351
+ const contents = JSON.stringify(schedules, null, 2);
352
+ let fd;
353
+ try {
354
+ fd = fs.openSync(tempFile, 'wx', 0o600);
355
+ fs.writeFileSync(fd, contents);
356
+ fs.fsyncSync(fd);
357
+ fs.closeSync(fd);
358
+ fd = undefined;
359
+ fs.renameSync(tempFile, schedulesFile);
360
+ syncDirectory(directory);
361
+ }
362
+ finally {
363
+ if (fd !== undefined) {
364
+ try {
365
+ fs.closeSync(fd);
366
+ }
367
+ catch {
368
+ }
369
+ }
370
+ try {
371
+ fs.unlinkSync(tempFile);
372
+ }
373
+ catch {
374
+ }
375
+ }
376
+ }
377
+ function requireSchedule(schedules, id) {
378
+ const schedule = schedules.find(item => item.id === id);
379
+ if (!schedule) {
380
+ throw new types_1.CronStoreError('CRON_SCHEDULE_NOT_FOUND', `Schedule not found: ${id}`);
381
+ }
382
+ return schedule;
383
+ }
384
+ function withStoreLock(workspacePath, operation) {
385
+ const lock = acquireWorkspaceLock(workspacePath);
386
+ try {
387
+ const schedulesFile = resolveSchedulesFile(workspacePath);
388
+ const schedules = readSchedulesFile(schedulesFile);
389
+ const result = operation(schedules);
390
+ writeSchedulesAtomic(schedulesFile, schedules);
391
+ return result;
392
+ }
393
+ finally {
394
+ releaseWorkspaceLock(lock);
187
395
  }
188
396
  }
397
+ function createScheduleStore(workspacePath = DEFAULT_WORKSPACE_PATH) {
398
+ const normalizedWorkspacePath = path.resolve(workspacePath);
399
+ const dataDir = resolveDataDir(normalizedWorkspacePath);
400
+ const schedulesFile = resolveSchedulesFile(normalizedWorkspacePath);
401
+ const lockFile = resolveLockFile(normalizedWorkspacePath);
402
+ return {
403
+ workspacePath: normalizedWorkspacePath,
404
+ dataDir,
405
+ schedulesFile,
406
+ lockFile,
407
+ list() {
408
+ return readSchedulesFile(schedulesFile).map(cloneSchedule);
409
+ },
410
+ get(id) {
411
+ const schedule = readSchedulesFile(schedulesFile).find(item => item.id === id);
412
+ return schedule ? cloneSchedule(schedule) : undefined;
413
+ },
414
+ create(schedule) {
415
+ return withStoreLock(normalizedWorkspacePath, schedules => {
416
+ if (schedules.some(item => item.id === schedule.id)) {
417
+ throw new types_1.CronStoreError('CRON_SCHEDULE_EXISTS', `Schedule already exists: ${schedule.id}`);
418
+ }
419
+ schedules.push(cloneSchedule(schedule));
420
+ return cloneSchedule(schedule);
421
+ });
422
+ },
423
+ update(id, changes) {
424
+ return withStoreLock(normalizedWorkspacePath, schedules => {
425
+ const schedule = requireSchedule(schedules, id);
426
+ if (changes.cron !== undefined)
427
+ schedule.cron = changes.cron;
428
+ if (changes.command !== undefined)
429
+ schedule.command = changes.command;
430
+ if (changes.mode !== undefined)
431
+ schedule.mode = changes.mode;
432
+ if (changes.enabled !== undefined)
433
+ schedule.enabled = changes.enabled;
434
+ if (changes.name !== undefined)
435
+ schedule.name = changes.name;
436
+ if (changes.working_directory !== undefined)
437
+ schedule.working_directory = changes.working_directory;
438
+ if (changes.timezone !== undefined)
439
+ schedule.timezone = changes.timezone;
440
+ if (changes.timeout_ms !== undefined)
441
+ schedule.timeout_ms = changes.timeout_ms;
442
+ if (changes.allow_concurrent !== undefined)
443
+ schedule.allow_concurrent = changes.allow_concurrent;
444
+ if (changes.env !== undefined) {
445
+ if (changes.env === null)
446
+ delete schedule.env;
447
+ else
448
+ schedule.env = { ...changes.env };
449
+ }
450
+ if (changes.model !== undefined) {
451
+ if (changes.model === null)
452
+ delete schedule.model;
453
+ else
454
+ schedule.model = changes.model;
455
+ }
456
+ if (changes.provider !== undefined) {
457
+ if (changes.provider === null)
458
+ delete schedule.provider;
459
+ else
460
+ schedule.provider = changes.provider;
461
+ }
462
+ schedule.updated_at = new Date().toISOString();
463
+ schedule.next_run = calculateNextRun(schedule.cron, schedule.timezone);
464
+ return cloneSchedule(schedule);
465
+ });
466
+ },
467
+ remove(id) {
468
+ return withStoreLock(normalizedWorkspacePath, schedules => {
469
+ const index = schedules.findIndex(item => item.id === id);
470
+ if (index === -1) {
471
+ throw new types_1.CronStoreError('CRON_SCHEDULE_NOT_FOUND', `Schedule not found: ${id}`);
472
+ }
473
+ const [removed] = schedules.splice(index, 1);
474
+ return cloneSchedule(removed);
475
+ });
476
+ },
477
+ toggle(id, enabled) {
478
+ return withStoreLock(normalizedWorkspacePath, schedules => {
479
+ const schedule = requireSchedule(schedules, id);
480
+ schedule.enabled = enabled ?? !schedule.enabled;
481
+ schedule.updated_at = new Date().toISOString();
482
+ if (schedule.enabled) {
483
+ schedule.next_run = calculateNextRun(schedule.cron, schedule.timezone);
484
+ }
485
+ return cloneSchedule(schedule);
486
+ });
487
+ },
488
+ claimOccurrence(id, options) {
489
+ return withStoreLock(normalizedWorkspacePath, schedules => {
490
+ const schedule = requireSchedule(schedules, typeof id === 'string' ? id : id.id);
491
+ if (schedule.last_occurrence_key === options.occurrenceKey) {
492
+ return {
493
+ claimed: false,
494
+ schedule: cloneSchedule(schedule),
495
+ };
496
+ }
497
+ const startedAt = options.startedAt ?? new Date().toISOString();
498
+ schedule.last_started_at = startedAt;
499
+ schedule.last_occurrence_key = options.occurrenceKey;
500
+ schedule.updated_at = startedAt;
501
+ return {
502
+ claimed: true,
503
+ schedule: cloneSchedule(schedule),
504
+ };
505
+ });
506
+ },
507
+ recordStarted(id, options) {
508
+ return withStoreLock(normalizedWorkspacePath, schedules => {
509
+ const schedule = requireSchedule(schedules, typeof id === 'string' ? id : id.id);
510
+ const normalizedOptions = typeof options === 'string' ? { startedAt: options } : options;
511
+ const startedAt = normalizedOptions?.startedAt ?? new Date().toISOString();
512
+ schedule.last_started_at = startedAt;
513
+ if (normalizedOptions?.occurrenceKey !== undefined) {
514
+ schedule.last_occurrence_key = normalizedOptions.occurrenceKey;
515
+ }
516
+ schedule.updated_at = startedAt;
517
+ return cloneSchedule(schedule);
518
+ });
519
+ },
520
+ recordFinished(id, options) {
521
+ return withStoreLock(normalizedWorkspacePath, schedules => {
522
+ const schedule = requireSchedule(schedules, typeof id === 'string' ? id : id.id);
523
+ const finishedAt = options?.finishedAt ?? new Date().toISOString();
524
+ schedule.last_run = finishedAt;
525
+ schedule.run_count = (schedule.run_count || 0) + 1;
526
+ schedule.next_run = options?.nextRun !== undefined
527
+ ? options.nextRun
528
+ : calculateNextRun(schedule.cron, schedule.timezone);
529
+ if (options?.outcome !== undefined)
530
+ schedule.last_outcome = options.outcome;
531
+ else if (options?.success !== undefined)
532
+ schedule.last_outcome = options.success ? 'success' : 'failure';
533
+ schedule.updated_at = finishedAt;
534
+ return cloneSchedule(schedule);
535
+ });
536
+ },
537
+ };
538
+ }
539
+ function loadSchedules(dir) {
540
+ return createScheduleStore(dir ?? DEFAULT_WORKSPACE_PATH).list();
541
+ }
189
542
  function saveSchedules(schedules, dir) {
190
- ensureDataDir(dir);
191
- const schedulesFile = resolveSchedulesFile(dir);
192
- const tempFile = `${schedulesFile}.tmp`;
193
- fs.writeFileSync(tempFile, JSON.stringify(schedules, null, 2));
194
- fs.renameSync(tempFile, schedulesFile);
543
+ const workspacePath = path.resolve(dir ?? DEFAULT_WORKSPACE_PATH);
544
+ const lock = acquireWorkspaceLock(workspacePath);
545
+ try {
546
+ const schedulesFile = resolveSchedulesFile(workspacePath);
547
+ readSchedulesFile(schedulesFile);
548
+ writeSchedulesAtomic(schedulesFile, schedules.map(cloneSchedule));
549
+ }
550
+ finally {
551
+ releaseWorkspaceLock(lock);
552
+ }
195
553
  }
196
554
  function loadBuiltinsMeta(dir) {
197
555
  const data = safeReadJsonFile(resolveBuiltinsMetaFile(dir));
@@ -215,7 +573,8 @@ function recordRemovedBuiltin(builtinId, dir) {
215
573
  }
216
574
  }
217
575
  function seedBuiltinSchedules(dir) {
218
- const schedules = loadSchedules(dir);
576
+ const store = createScheduleStore(dir ?? DEFAULT_WORKSPACE_PATH);
577
+ const schedules = store.list();
219
578
  const existingNames = new Set(schedules.map(s => s.name));
220
579
  const removedBuiltins = new Set(getRemovedBuiltins(dir));
221
580
  const seeded = [];
@@ -225,7 +584,7 @@ function seedBuiltinSchedules(dir) {
225
584
  if (removedBuiltins.has(def.builtin_id))
226
585
  continue;
227
586
  const now = new Date().toISOString();
228
- schedules.push({
587
+ const schedule = {
229
588
  id: (0, sdk_1.generateId)('cron'),
230
589
  cron: def.cron,
231
590
  command: def.buildCommand(),
@@ -240,102 +599,207 @@ function seedBuiltinSchedules(dir) {
240
599
  updated_at: now,
241
600
  run_count: 0,
242
601
  next_run: calculateNextRun(def.cron, null),
243
- });
602
+ };
603
+ store.create(schedule);
604
+ existingNames.add(def.name);
244
605
  seeded.push(def.builtin_id);
245
606
  }
246
- if (seeded.length > 0) {
247
- saveSchedules(schedules, dir);
248
- }
249
607
  return seeded;
250
608
  }
251
- async function acquireLock(timeout = 5000) {
252
- ensureDataDir();
253
- const startTime = Date.now();
254
- while (Date.now() - startTime < timeout) {
255
- try {
256
- fs.writeFileSync(LOCK_FILE, String(process.pid), { flag: 'wx' });
257
- return true;
258
- }
259
- catch (e) {
260
- const err = e;
261
- if (err.code === 'EEXIST') {
262
- try {
263
- const stat = fs.statSync(LOCK_FILE);
264
- if (Date.now() - stat.mtimeMs > 30000) {
265
- fs.unlinkSync(LOCK_FILE);
266
- continue;
267
- }
268
- }
269
- catch {
270
- continue;
271
- }
272
- await new Promise(resolve => setTimeout(resolve, 100));
273
- }
274
- else {
275
- throw e;
609
+ const legacyLockOwners = new Map();
610
+ async function acquireLock(timeout = STORE_LOCK_TIMEOUT_MS, dir) {
611
+ const workspacePath = path.resolve(dir ?? DEFAULT_WORKSPACE_PATH);
612
+ try {
613
+ legacyLockOwners.set(workspacePath, acquireWorkspaceLock(workspacePath, timeout));
614
+ return true;
615
+ }
616
+ catch (error) {
617
+ if (error instanceof types_1.CronStoreError && error.code === 'CRON_STORE_LOCK_TIMEOUT')
618
+ return false;
619
+ throw error;
620
+ }
621
+ }
622
+ function releaseLock(dir) {
623
+ const workspacePath = path.resolve(dir ?? DEFAULT_WORKSPACE_PATH);
624
+ const owner = legacyLockOwners.get(workspacePath);
625
+ if (!owner)
626
+ return;
627
+ legacyLockOwners.delete(workspacePath);
628
+ releaseWorkspaceLock(owner);
629
+ }
630
+ function readRunLease(lockFile) {
631
+ return safeReadJsonFile(lockFile);
632
+ }
633
+ function writeRunLeaseAtomic(lockFile, lease) {
634
+ const directory = path.dirname(lockFile);
635
+ const tempFile = path.join(directory, `.${path.basename(lockFile)}.${process.pid}.${(0, crypto_1.randomUUID)()}.tmp`);
636
+ let fd;
637
+ try {
638
+ fd = fs.openSync(tempFile, 'wx', 0o600);
639
+ fs.writeFileSync(fd, JSON.stringify(lease));
640
+ fs.fsyncSync(fd);
641
+ fs.closeSync(fd);
642
+ fd = undefined;
643
+ fs.renameSync(tempFile, lockFile);
644
+ }
645
+ finally {
646
+ if (fd !== undefined) {
647
+ try {
648
+ fs.closeSync(fd);
276
649
  }
650
+ catch { }
651
+ }
652
+ try {
653
+ fs.unlinkSync(tempFile);
277
654
  }
655
+ catch { }
656
+ }
657
+ }
658
+ function withRunLeaseStoreLock(workspacePath, scheduleId, operation) {
659
+ const owner = acquireWorkspaceLock(workspacePath);
660
+ try {
661
+ return operation(resolveRunLockFile(scheduleId, workspacePath));
662
+ }
663
+ finally {
664
+ releaseWorkspaceLock(owner);
278
665
  }
279
- return false;
280
666
  }
281
- function releaseLock() {
667
+ function getLeaseAgeMs(lockFile) {
282
668
  try {
283
- fs.unlinkSync(LOCK_FILE);
669
+ return Math.max(0, Date.now() - fs.statSync(lockFile).mtimeMs);
284
670
  }
285
671
  catch {
672
+ return null;
286
673
  }
287
674
  }
288
- function readRunLease(lockFile) {
289
- return safeReadJsonFile(lockFile);
675
+ function compareProcessIdentity(expected, actual) {
676
+ if (expected.startTime !== null && actual.startTime !== null) {
677
+ return expected.startTime === actual.startTime;
678
+ }
679
+ if (expected.commandLine !== null && actual.commandLine !== null) {
680
+ return expected.commandLine === actual.commandLine;
681
+ }
682
+ return null;
683
+ }
684
+ function isRunLeaseStale(lease, lockFile, timeoutMs) {
685
+ if (lease.phase !== 'child') {
686
+ const ageMs = getLeaseAgeMs(lockFile);
687
+ if (ageMs === null)
688
+ return false;
689
+ const ownerPid = lease.hostPid ?? lease.pid;
690
+ const ownerAlive = isPidAlive(ownerPid);
691
+ return ownerAlive === false || ageMs > timeoutMs + RUN_LOCK_STALE_GRACE_MS;
692
+ }
693
+ const childPid = lease.childPid ?? lease.pid;
694
+ const childAlive = isPidAlive(childPid);
695
+ if (childAlive === false)
696
+ return true;
697
+ if (childAlive !== true)
698
+ return false;
699
+ if (!lease.childIdentity)
700
+ return false;
701
+ const currentIdentity = captureProcessIdentity(childPid);
702
+ if (!currentIdentity)
703
+ return false;
704
+ const identityMatch = compareProcessIdentity(lease.childIdentity, currentIdentity);
705
+ if (identityMatch === false)
706
+ return true;
707
+ return false;
290
708
  }
291
709
  function acquireRunLock(scheduleId, taskId, timeoutMs, dir) {
292
- ensureDataDir(dir);
293
- const lockFile = resolveRunLockFile(scheduleId, dir);
294
- const lease = { pid: process.pid, taskId, startedAt: new Date().toISOString() };
295
- for (let attempt = 0; attempt < 2; attempt++) {
296
- try {
297
- fs.writeFileSync(lockFile, JSON.stringify(lease), { flag: 'wx' });
298
- return { acquired: true };
299
- }
300
- catch (e) {
301
- const err = e;
302
- if (err.code !== 'EEXIST')
303
- throw err;
304
- const existing = readRunLease(lockFile);
305
- if (!existing) {
306
- try {
307
- fs.unlinkSync(lockFile);
308
- }
309
- catch { }
310
- continue;
311
- }
312
- let ageMs;
710
+ const workspacePath = path.resolve(dir ?? DEFAULT_WORKSPACE_PATH);
711
+ ensureDataDir(workspacePath);
712
+ const lockFile = resolveRunLockFile(scheduleId, workspacePath);
713
+ const leaseId = (0, crypto_1.randomUUID)();
714
+ const ownerToken = (0, crypto_1.randomUUID)();
715
+ const lease = {
716
+ leaseId,
717
+ ownerToken,
718
+ phase: 'admitting',
719
+ hostPid: process.pid,
720
+ pid: process.pid,
721
+ taskId,
722
+ startedAt: new Date().toISOString(),
723
+ };
724
+ return withRunLeaseStoreLock(workspacePath, scheduleId, () => {
725
+ for (let attempt = 0; attempt < 2; attempt++) {
313
726
  try {
314
- ageMs = Date.now() - fs.statSync(lockFile).mtimeMs;
315
- }
316
- catch {
317
- continue;
727
+ fs.writeFileSync(lockFile, JSON.stringify(lease), { flag: 'wx', mode: 0o600 });
728
+ return {
729
+ acquired: true,
730
+ lease: { scheduleId, workspacePath, lockFile, leaseId, ownerToken, taskId },
731
+ };
318
732
  }
319
- const stale = !isPidAlive(existing.pid) || ageMs > timeoutMs + RUN_LOCK_STALE_GRACE_MS;
320
- if (stale) {
321
- try {
322
- fs.unlinkSync(lockFile);
733
+ catch (e) {
734
+ const err = e;
735
+ if (err.code !== 'EEXIST')
736
+ throw err;
737
+ const existing = readRunLease(lockFile);
738
+ if (!existing) {
739
+ return { acquired: false, runningTaskId: 'unknown', reason: 'execution_lease' };
323
740
  }
324
- catch { }
325
- continue;
741
+ if (isRunLeaseStale(existing, lockFile, timeoutMs)) {
742
+ const current = readRunLease(lockFile);
743
+ if (current?.leaseId !== undefined && current.leaseId !== existing.leaseId) {
744
+ continue;
745
+ }
746
+ try {
747
+ fs.unlinkSync(lockFile);
748
+ }
749
+ catch { }
750
+ continue;
751
+ }
752
+ return {
753
+ acquired: false,
754
+ runningTaskId: existing.taskId,
755
+ reason: 'execution_lease',
756
+ };
326
757
  }
327
- return { acquired: false, runningTaskId: existing.taskId };
328
758
  }
329
- }
330
- const existing = readRunLease(lockFile);
331
- return { acquired: false, runningTaskId: existing?.taskId ?? taskId };
759
+ const existing = readRunLease(lockFile);
760
+ return {
761
+ acquired: false,
762
+ runningTaskId: existing?.taskId ?? 'unknown',
763
+ reason: 'execution_lease',
764
+ };
765
+ });
332
766
  }
333
- function releaseRunLock(scheduleId, dir) {
334
- try {
335
- fs.unlinkSync(resolveRunLockFile(scheduleId, dir));
336
- }
337
- catch {
338
- }
767
+ function handoffRunLock(lease, childPid, childIdentity) {
768
+ return withRunLeaseStoreLock(lease.workspacePath, lease.scheduleId, lockFile => {
769
+ const current = readRunLease(lockFile);
770
+ if (!current ||
771
+ current.leaseId !== lease.leaseId ||
772
+ current.ownerToken !== lease.ownerToken) {
773
+ return false;
774
+ }
775
+ writeRunLeaseAtomic(lockFile, {
776
+ ...current,
777
+ phase: 'child',
778
+ pid: childPid,
779
+ childPid,
780
+ childIdentity,
781
+ });
782
+ return true;
783
+ });
784
+ }
785
+ function releaseRunLock(scheduleId, leaseId, dir) {
786
+ const workspacePath = path.resolve(dir ?? DEFAULT_WORKSPACE_PATH);
787
+ ensureDataDir(workspacePath);
788
+ return withRunLeaseStoreLock(workspacePath, scheduleId, lockFile => {
789
+ const current = readRunLease(lockFile);
790
+ if (!current || current.leaseId !== leaseId)
791
+ return false;
792
+ try {
793
+ fs.unlinkSync(lockFile);
794
+ return true;
795
+ }
796
+ catch (error) {
797
+ const err = error;
798
+ if (err.code === 'ENOENT')
799
+ return false;
800
+ throw error;
801
+ }
802
+ });
339
803
  }
340
804
  const DAY_NAMES = {
341
805
  SUN: 0, MON: 1, TUE: 2, WED: 3, THU: 4, FRI: 5, SAT: 6,
@@ -612,6 +1076,30 @@ function getProcessCommandLine(pid) {
612
1076
  return null;
613
1077
  }
614
1078
  }
1079
+ function getProcessStartTime(pid) {
1080
+ try {
1081
+ const output = process.platform === 'win32'
1082
+ ? (0, child_process_1.execFileSync)('powershell.exe', [
1083
+ '-NoLogo',
1084
+ '-NoProfile',
1085
+ '-NonInteractive',
1086
+ '-Command',
1087
+ `(Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}").CreationDate.ToUniversalTime().ToString("o")`,
1088
+ ], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
1089
+ : (0, child_process_1.execFileSync)('ps', ['-p', String(pid), '-o', 'lstart='], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
1090
+ return output.trim() || null;
1091
+ }
1092
+ catch {
1093
+ return null;
1094
+ }
1095
+ }
1096
+ function captureProcessIdentity(pid) {
1097
+ const identity = {
1098
+ commandLine: getProcessCommandLine(pid),
1099
+ startTime: getProcessStartTime(pid),
1100
+ };
1101
+ return identity.commandLine !== null || identity.startTime !== null ? identity : null;
1102
+ }
615
1103
  const DEFAULT_DAEMON_PROCESS_PROBE = {
616
1104
  isAlive: isPidAlive,
617
1105
  getCommandLine: getProcessCommandLine,
@@ -687,6 +1175,9 @@ function isDaemonRunning(dir, probe = DEFAULT_DAEMON_PROCESS_PROBE) {
687
1175
  return inspectDaemon(dir, probe) === 'running';
688
1176
  }
689
1177
  function spawnDaemon(dir, spawnImpl = child_process_1.spawn) {
1178
+ if ((0, sdk_1.isDurablyQuiescing)()) {
1179
+ throw new Error('Cannot start cron daemon: a desktop update is draining active work');
1180
+ }
690
1181
  ensureDataDir(dir);
691
1182
  const cliPath = getDaemonCliPath();
692
1183
  const child = spawnImpl('node', [cliPath, 'daemon', '--foreground'], {
@@ -756,7 +1247,7 @@ function ensureDaemonRunning(dir, probe = DEFAULT_DAEMON_PROCESS_PROBE) {
756
1247
  }
757
1248
  }
758
1249
  function getDaemonStatus(dir) {
759
- const schedules = loadSchedules(dir);
1250
+ const schedules = createScheduleStore(dir ?? DEFAULT_WORKSPACE_PATH).list();
760
1251
  const enabled = schedules.filter(s => s.enabled).length;
761
1252
  const running = isDaemonRunning(dir);
762
1253
  const pid = running ? readDaemonPid(dir) : null;
@@ -782,16 +1273,68 @@ function getDaemonStatus(dir) {
782
1273
  }
783
1274
  async function executeSchedule(schedule, options) {
784
1275
  const dir = options?.dir;
1276
+ const workspacePath = path.resolve(dir ?? DEFAULT_WORKSPACE_PATH);
785
1277
  const trigger = options?.trigger ?? 'cron';
786
1278
  const timeoutMs = schedule.timeout_ms || 600000;
787
1279
  const taskId = (0, sdk_1.generateId)('tsk');
1280
+ let runLease;
788
1281
  if (!schedule.allow_concurrent) {
789
1282
  const lock = acquireRunLock(schedule.id, taskId, timeoutMs, dir);
790
1283
  if (!lock.acquired) {
791
1284
  console.log(`[${getLocalTimestamp()}] Schedule ${schedule.id} already running (task ${lock.runningTaskId}), skipping`);
792
- return { status: 'conflict', runningTaskId: lock.runningTaskId };
1285
+ return {
1286
+ status: 'conflict',
1287
+ runningTaskId: lock.runningTaskId,
1288
+ reason: lock.reason,
1289
+ };
1290
+ }
1291
+ runLease = lock.lease;
1292
+ }
1293
+ const releaseLease = () => {
1294
+ if (runLease) {
1295
+ releaseRunLock(schedule.id, runLease.leaseId, dir);
1296
+ runLease = undefined;
1297
+ }
1298
+ releaseAdmissionReservation(taskId);
1299
+ };
1300
+ if (!reserveAdmission(taskId, schedule.id, timeoutMs)) {
1301
+ console.log(`[${getLocalTimestamp()}] Schedule ${schedule.id} blocked: could not durably record the update-admission reservation`);
1302
+ releaseLease();
1303
+ return { status: 'blocked', reason: 'ADMISSION_UNKNOWN' };
1304
+ }
1305
+ if ((0, sdk_1.isDurablyQuiescing)()) {
1306
+ console.log(`[${getLocalTimestamp()}] Schedule ${schedule.id} blocked: a desktop update is draining active work`);
1307
+ releaseLease();
1308
+ return { status: 'blocked', reason: 'UPDATE_QUIESCING' };
1309
+ }
1310
+ try {
1311
+ const store = createScheduleStore(workspacePath);
1312
+ const startedAt = new Date().toISOString();
1313
+ if (options?.occurrenceKey !== undefined) {
1314
+ const claim = store.claimOccurrence(schedule.id, {
1315
+ occurrenceKey: options.occurrenceKey,
1316
+ startedAt,
1317
+ });
1318
+ if (!claim.claimed) {
1319
+ releaseLease();
1320
+ console.log(`[${getLocalTimestamp()}] Schedule ${schedule.id} occurrence ${options.occurrenceKey} already claimed, skipping`);
1321
+ return {
1322
+ status: 'conflict',
1323
+ runningTaskId: '',
1324
+ reason: 'occurrence_claim',
1325
+ };
1326
+ }
1327
+ }
1328
+ else {
1329
+ if (store.get(schedule.id)) {
1330
+ store.recordStarted(schedule.id, { startedAt });
1331
+ }
793
1332
  }
794
1333
  }
1334
+ catch (error) {
1335
+ releaseLease();
1336
+ throw error;
1337
+ }
795
1338
  const workingDir = schedule.working_directory || options?.dir || (0, builtins_1.getProjectRoot)();
796
1339
  const mode = schedule.mode;
797
1340
  const modeFlag = mode === 'execute' ? 'x' : 'q';
@@ -799,10 +1342,6 @@ async function executeSchedule(schedule, options) {
799
1342
  const effectiveProvider = options?.overrides?.provider ?? schedule.provider;
800
1343
  console.log(`[${getLocalTimestamp()}] Executing schedule ${schedule.id}: ${schedule.name ?? schedule.cron} (task ${taskId})`);
801
1344
  console.log(`[${getLocalTimestamp()}] Command: ${schedule.command}`);
802
- const releaseLease = () => {
803
- if (!schedule.allow_concurrent)
804
- releaseRunLock(schedule.id, dir);
805
- };
806
1345
  const env = (0, sdk_1.withoutCrewxRootContext)({ ...process.env, ...schedule.env, CREWX_TRACE_ID: taskId });
807
1346
  delete env['CLAUDECODE'];
808
1347
  if (!schedule.env || !('CREWX_CONFIG' in schedule.env)) {
@@ -869,9 +1408,27 @@ async function executeSchedule(schedule, options) {
869
1408
  releaseLease();
870
1409
  throw err;
871
1410
  }
1411
+ if (runLease && child.pid) {
1412
+ const handedOff = handoffRunLock(runLease, child.pid, captureProcessIdentity(child.pid));
1413
+ if (!handedOff) {
1414
+ const error = new Error('Cron execution lease was replaced before child ownership was established');
1415
+ console.error(`[${getLocalTimestamp()}] Schedule ${schedule.id} failed to hand off its execution lease: ${error.message}`);
1416
+ try {
1417
+ child.kill('SIGTERM');
1418
+ }
1419
+ catch {
1420
+ }
1421
+ releaseLease();
1422
+ throw error;
1423
+ }
1424
+ }
872
1425
  child.unref();
873
1426
  const done = new Promise((resolve) => {
1427
+ let timedOut = false;
1428
+ let settled = false;
1429
+ let graceTimer;
874
1430
  const timeout = setTimeout(() => {
1431
+ timedOut = true;
875
1432
  console.log(`[${getLocalTimestamp()}] Schedule ${schedule.id} timeout, killing process`);
876
1433
  try {
877
1434
  child.kill('SIGTERM');
@@ -880,35 +1437,69 @@ async function executeSchedule(schedule, options) {
880
1437
  const err = e;
881
1438
  console.error(`Failed to kill process: ${err.message}`);
882
1439
  }
883
- releaseLease();
884
- resolve({ success: false, error: 'Timeout' });
1440
+ graceTimer = setTimeout(() => {
1441
+ try {
1442
+ child.kill('SIGKILL');
1443
+ }
1444
+ catch {
1445
+ }
1446
+ }, RUN_LOCK_STALE_GRACE_MS);
1447
+ if (typeof graceTimer === 'object' && graceTimer !== null && 'unref' in graceTimer) {
1448
+ graceTimer.unref();
1449
+ }
1450
+ if (!settled) {
1451
+ settled = true;
1452
+ resolve({ success: false, error: 'Timeout' });
1453
+ }
885
1454
  }, timeoutMs);
886
1455
  child.on('close', (code) => {
887
1456
  clearTimeout(timeout);
1457
+ if (graceTimer)
1458
+ clearTimeout(graceTimer);
888
1459
  releaseLease();
889
- const schedules = loadSchedules(dir);
890
- const idx = schedules.findIndex(s => s.id === schedule.id);
891
- if (idx !== -1) {
892
- const s = schedules[idx];
893
- s.last_run = new Date().toISOString();
894
- s.run_count = (s.run_count || 0) + 1;
895
- s.next_run = calculateNextRun(schedule.cron, schedule.timezone);
896
- saveSchedules(schedules, dir);
1460
+ const finishedAt = new Date().toISOString();
1461
+ const outcome = timedOut ? 'timeout' : code === 0 ? 'success' : 'failure';
1462
+ try {
1463
+ createScheduleStore(workspacePath).recordFinished(schedule.id, {
1464
+ finishedAt,
1465
+ outcome,
1466
+ });
897
1467
  }
898
- if (code === 0) {
1468
+ catch (error) {
1469
+ const err = error;
1470
+ if (!(error instanceof types_1.CronStoreError && error.code === 'CRON_SCHEDULE_NOT_FOUND')) {
1471
+ console.error(`[${getLocalTimestamp()}] Failed to record schedule ${schedule.id} completion: ${err.message}`);
1472
+ }
1473
+ }
1474
+ if (timedOut) {
1475
+ console.log(`[${getLocalTimestamp()}] Schedule ${schedule.id} terminated after timeout`);
1476
+ if (!settled) {
1477
+ settled = true;
1478
+ resolve({ success: false, error: 'Timeout' });
1479
+ }
1480
+ }
1481
+ else if (code === 0) {
899
1482
  console.log(`[${getLocalTimestamp()}] Schedule ${schedule.id} completed successfully`);
900
- resolve({ success: true });
1483
+ if (!settled) {
1484
+ settled = true;
1485
+ resolve({ success: true });
1486
+ }
901
1487
  }
902
1488
  else {
903
1489
  console.log(`[${getLocalTimestamp()}] Schedule ${schedule.id} failed with code ${code}`);
904
- resolve({ success: false, error: `Exit code: ${code}` });
1490
+ if (!settled) {
1491
+ settled = true;
1492
+ resolve({ success: false, error: `Exit code: ${code}` });
1493
+ }
905
1494
  }
906
1495
  });
907
1496
  child.on('error', (err) => {
908
1497
  clearTimeout(timeout);
909
- releaseLease();
910
1498
  console.error(`[${getLocalTimestamp()}] Schedule ${schedule.id} error: ${err.message}`);
911
- resolve({ success: false, error: err.message });
1499
+ if (!settled) {
1500
+ settled = true;
1501
+ resolve({ success: false, error: err.message });
1502
+ }
912
1503
  });
913
1504
  });
914
1505
  return { status: 'running', taskId, done };
@@ -924,9 +1515,16 @@ function getLocalMinuteKey(now, timezone) {
924
1515
  });
925
1516
  return fmt.format(now);
926
1517
  }
1518
+ function buildOccurrenceKey(cronExpression, scheduledMinute) {
1519
+ return `${cronExpression.trim()}|${scheduledMinute}`;
1520
+ }
927
1521
  async function daemonTick(dir) {
1522
+ if ((0, sdk_1.isDurablyQuiescing)()) {
1523
+ console.log(`[${getLocalTimestamp()}] Daemon tick skipped: a desktop update is draining active work`);
1524
+ return;
1525
+ }
928
1526
  const now = new Date();
929
- const schedules = loadSchedules(dir);
1527
+ const schedules = createScheduleStore(dir ?? DEFAULT_WORKSPACE_PATH).list();
930
1528
  const enabledSchedules = schedules.filter(s => s.enabled);
931
1529
  if (enabledSchedules.length === 0) {
932
1530
  return;
@@ -934,17 +1532,9 @@ async function daemonTick(dir) {
934
1532
  for (const schedule of enabledSchedules) {
935
1533
  if (shouldRunNow(schedule.cron, now, schedule.timezone)) {
936
1534
  const minuteKey = getLocalMinuteKey(now, schedule.timezone);
937
- if (schedule.last_run) {
938
- const lastRunMinuteKey = getLocalMinuteKey(new Date(schedule.last_run), schedule.timezone);
939
- if (lastRunMinuteKey === minuteKey) {
940
- continue;
941
- }
942
- }
1535
+ const occurrenceKey = buildOccurrenceKey(schedule.cron, minuteKey);
943
1536
  try {
944
- const outcome = await executeSchedule(schedule, { dir });
945
- if (outcome.status === 'running') {
946
- await outcome.done;
947
- }
1537
+ await executeSchedule(schedule, { dir, occurrenceKey });
948
1538
  }
949
1539
  catch (e) {
950
1540
  const err = e;
@@ -953,6 +1543,9 @@ async function daemonTick(dir) {
953
1543
  }
954
1544
  }
955
1545
  }
1546
+ var runtime_1 = require("./runtime");
1547
+ Object.defineProperty(exports, "createCronRuntime", { enumerable: true, get: function () { return runtime_1.createCronRuntime; } });
1548
+ Object.defineProperty(exports, "CRON_RUNTIME_PROCESS_LEASE_FILENAME", { enumerable: true, get: function () { return runtime_1.CRON_RUNTIME_PROCESS_LEASE_FILENAME; } });
956
1549
  function runDaemonForeground(dir) {
957
1550
  writeDaemonPid(process.pid, dir);
958
1551
  let cleanedUp = false;
@@ -977,7 +1570,7 @@ function runDaemonForeground(dir) {
977
1570
  console.log(`[${getLocalTimestamp()}] Cron daemon starting in foreground mode`);
978
1571
  console.log(`[${getLocalTimestamp()}] Check interval: 1 minute`);
979
1572
  console.log(`[${getLocalTimestamp()}] Press Ctrl+C to stop\n`);
980
- const schedules = loadSchedules(dir);
1573
+ const schedules = createScheduleStore(dir ?? DEFAULT_WORKSPACE_PATH).list();
981
1574
  const enabled = schedules.filter(s => s.enabled).length;
982
1575
  console.log(`[${getLocalTimestamp()}] Active schedules: ${enabled}/${schedules.length}\n`);
983
1576
  void daemonTick(dir);
@@ -1012,19 +1605,10 @@ function addSchedule(cron, command, options, dataDir) {
1012
1605
  schedule.provider = options.provider;
1013
1606
  }
1014
1607
  schedule.next_run = calculateNextRun(cron, schedule.timezone);
1015
- const schedules = loadSchedules(dataDir);
1016
- schedules.push(schedule);
1017
- saveSchedules(schedules, dataDir);
1018
- return schedule;
1608
+ return createScheduleStore(dataDir ?? DEFAULT_WORKSPACE_PATH).create(schedule);
1019
1609
  }
1020
1610
  function removeSchedule(id, dir) {
1021
- const schedules = loadSchedules(dir);
1022
- const idx = schedules.findIndex(s => s.id === id);
1023
- if (idx === -1) {
1024
- throw new Error(`Schedule not found: ${id}`);
1025
- }
1026
- const removed = schedules.splice(idx, 1)[0];
1027
- saveSchedules(schedules, dir);
1611
+ const removed = createScheduleStore(dir ?? DEFAULT_WORKSPACE_PATH).remove(id);
1028
1612
  const matchedBuiltin = (0, builtins_1.findBuiltinByName)(removed.name);
1029
1613
  if (matchedBuiltin) {
1030
1614
  recordRemovedBuiltin(matchedBuiltin.builtin_id, dir);
@@ -1032,100 +1616,82 @@ function removeSchedule(id, dir) {
1032
1616
  return removed;
1033
1617
  }
1034
1618
  function enableSchedule(id, dir) {
1035
- const schedules = loadSchedules(dir);
1036
- const schedule = schedules.find(s => s.id === id);
1037
- if (!schedule) {
1038
- throw new Error(`Schedule not found: ${id}`);
1039
- }
1040
- schedule.enabled = true;
1041
- schedule.updated_at = new Date().toISOString();
1042
- schedule.next_run = calculateNextRun(schedule.cron, schedule.timezone);
1043
- saveSchedules(schedules, dir);
1044
- return schedule;
1619
+ return createScheduleStore(dir ?? DEFAULT_WORKSPACE_PATH).toggle(id, true);
1045
1620
  }
1046
1621
  function disableSchedule(id, dir) {
1047
- const schedules = loadSchedules(dir);
1048
- const schedule = schedules.find(s => s.id === id);
1049
- if (!schedule) {
1050
- throw new Error(`Schedule not found: ${id}`);
1051
- }
1052
- schedule.enabled = false;
1053
- schedule.updated_at = new Date().toISOString();
1054
- saveSchedules(schedules, dir);
1055
- return schedule;
1622
+ return createScheduleStore(dir ?? DEFAULT_WORKSPACE_PATH).toggle(id, false);
1056
1623
  }
1057
1624
  function updateSchedule(id, options, dir) {
1058
- const schedules = loadSchedules(dir);
1059
- const schedule = schedules.find(s => s.id === id);
1060
- if (!schedule) {
1061
- throw new Error(`Schedule not found: ${id}`);
1062
- }
1063
1625
  if (options.timezone !== undefined && options.timezone !== null && !isValidTimezone(options.timezone)) {
1064
1626
  throw new Error(`Invalid timezone: ${options.timezone}`);
1065
1627
  }
1628
+ const store = createScheduleStore(dir ?? DEFAULT_WORKSPACE_PATH);
1629
+ const current = store.get(id);
1630
+ if (!current) {
1631
+ throw new Error(`Schedule not found: ${id}`);
1632
+ }
1066
1633
  const changes = [];
1634
+ const patch = {};
1067
1635
  if (options.cron !== undefined) {
1068
- schedule.cron = options.cron;
1636
+ patch.cron = options.cron;
1069
1637
  changes.push(`cron: ${options.cron}`);
1070
1638
  }
1071
1639
  if (options.command !== undefined) {
1072
1640
  warnIfOverdriveInCommand(options.command);
1073
- schedule.command = options.command;
1641
+ patch.command = options.command;
1074
1642
  changes.push(`command: ${options.command}`);
1075
1643
  }
1076
1644
  if (options.name !== undefined) {
1077
- schedule.name = options.name;
1645
+ patch.name = options.name;
1078
1646
  changes.push(`name: ${options.name}`);
1079
1647
  }
1080
1648
  if (options.mode !== undefined) {
1081
- schedule.mode = normalizeMode(options.mode);
1082
- changes.push(`mode: ${schedule.mode}`);
1649
+ patch.mode = normalizeMode(options.mode);
1650
+ changes.push(`mode: ${patch.mode}`);
1083
1651
  }
1084
1652
  if (options.timezone !== undefined) {
1085
- schedule.timezone = options.timezone;
1653
+ patch.timezone = options.timezone;
1086
1654
  changes.push(`timezone: ${options.timezone}`);
1087
1655
  }
1088
1656
  if (options.working_directory !== undefined) {
1089
- schedule.working_directory = options.working_directory;
1657
+ patch.working_directory = options.working_directory;
1090
1658
  changes.push(`working_directory: ${options.working_directory}`);
1091
1659
  }
1092
1660
  if (options.timeout_ms !== undefined) {
1093
- schedule.timeout_ms = options.timeout_ms;
1661
+ patch.timeout_ms = options.timeout_ms;
1094
1662
  changes.push(`timeout: ${options.timeout_ms}ms`);
1095
1663
  }
1096
1664
  if (options.allow_concurrent !== undefined) {
1097
- schedule.allow_concurrent = options.allow_concurrent;
1665
+ patch.allow_concurrent = options.allow_concurrent;
1098
1666
  changes.push(`allow_concurrent: ${options.allow_concurrent}`);
1099
1667
  }
1100
1668
  if (options.model !== undefined) {
1101
1669
  if (options.model === null) {
1102
- delete schedule.model;
1670
+ patch.model = null;
1103
1671
  changes.push('model: (cleared, reverts to agent default)');
1104
1672
  }
1105
1673
  else {
1106
- schedule.model = options.model;
1674
+ patch.model = options.model;
1107
1675
  changes.push(`model: ${options.model}`);
1108
1676
  }
1109
1677
  }
1110
1678
  if (options.provider !== undefined) {
1111
1679
  if (options.provider === null) {
1112
- delete schedule.provider;
1680
+ patch.provider = null;
1113
1681
  changes.push('provider: (cleared, reverts to agent default)');
1114
1682
  }
1115
1683
  else {
1116
- schedule.provider = options.provider;
1684
+ patch.provider = options.provider;
1117
1685
  changes.push(`provider: ${options.provider}`);
1118
1686
  }
1119
1687
  }
1120
- schedule.updated_at = new Date().toISOString();
1121
- schedule.next_run = calculateNextRun(schedule.cron, schedule.timezone);
1122
- saveSchedules(schedules, dir);
1123
- return { schedule, changes };
1688
+ return { schedule: store.update(current.id, patch), changes };
1124
1689
  }
1125
1690
  exports.DATA_PATHS = {
1126
1691
  DATA_DIR,
1127
1692
  SCHEDULES_FILE,
1128
1693
  LOG_FILE,
1694
+ LOCK_FILE,
1129
1695
  USAGE_LOG_FILE,
1130
1696
  PID_FILE,
1131
1697
  BUILTINS_META_FILE,