beecork 1.4.6 → 1.4.8

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.
package/dist/daemon.js CHANGED
@@ -149,6 +149,8 @@ async function main() {
149
149
  try {
150
150
  taskScheduler.checkForReload();
151
151
  watcherScheduler.checkForReload();
152
+ taskScheduler.tick();
153
+ watcherScheduler.tick();
152
154
  tabManager.processPendingMessages();
153
155
  // Media cleanup every 60 seconds
154
156
  if (Date.now() - lastMediaCleanup > 60000) {
@@ -4,16 +4,25 @@ export declare const execAsync: typeof exec.__promisify__;
4
4
  export declare class TaskScheduler {
5
5
  private tabManager;
6
6
  private onNotify;
7
- private scheduledJobs;
7
+ private nextRunAt;
8
+ private running;
9
+ private stopping;
8
10
  private store;
9
11
  constructor(tabManager: TabManager, onNotify: NotifyCallback | null);
10
12
  /** Load all tasks from store and schedule them */
11
13
  loadAndSchedule(): void;
12
14
  /** Check for the reload signal file and reload if present */
13
15
  checkForReload(): void;
14
- /** Stop all scheduled tasks */
16
+ /**
17
+ * Fire any tasks whose nextRunAt has elapsed. Called every ~5s by the daemon
18
+ * poll loop. This is the macOS-sleep-resilient replacement for setTimeout-driven
19
+ * cron heartbeats — after a wake, the next tick catches all overdue tasks.
20
+ */
21
+ tick(): void;
22
+ /** Stop the scheduler. In-flight fireJob promises are detached and resolve naturally. */
15
23
  stopAll(): void;
16
- private scheduleJob;
24
+ /** Compute next run time in ms epoch, given a "from" anchor. Returns null if invalid. */
25
+ private computeNextRun;
17
26
  private fireJob;
18
27
  private handleSystemEvent;
19
28
  }
@@ -1,4 +1,4 @@
1
- import cron from 'node-cron';
1
+ import { CronExpressionParser } from 'cron-parser';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { exec } from 'node:child_process';
@@ -10,7 +10,11 @@ export const execAsync = promisify(exec);
10
10
  export class TaskScheduler {
11
11
  tabManager;
12
12
  onNotify;
13
- scheduledJobs = new Map();
13
+ // taskId -> due time in ms epoch
14
+ nextRunAt = new Map();
15
+ // taskIds with an in-flight fireJob
16
+ running = new Set();
17
+ stopping = false;
14
18
  store = new TaskStore();
15
19
  constructor(tabManager, onNotify) {
16
20
  this.tabManager = tabManager;
@@ -18,29 +22,42 @@ export class TaskScheduler {
18
22
  }
19
23
  /** Load all tasks from store and schedule them */
20
24
  loadAndSchedule() {
21
- // Cancel existing
22
- for (const [, task] of this.scheduledJobs) {
23
- task.stop();
24
- }
25
- this.scheduledJobs.clear();
25
+ this.nextRunAt.clear();
26
+ this.stopping = false;
26
27
  const jobs = this.store.list();
27
28
  let scheduled = 0;
28
29
  let missedFires = 0;
29
30
  for (const job of jobs) {
30
31
  if (!job.enabled)
31
32
  continue;
32
- // Detect missed fires: one-time "at" jobs whose time has passed but never ran
33
+ // Fast path: one-time 'at' tasks whose target time has passed without ever firing.
34
+ // (The general tick() loop would catch these too, but this keeps the original
35
+ // single-pass startup behavior — fire now and disable atomically.)
33
36
  if (job.scheduleType === 'at' && !job.lastRunAt) {
34
37
  const targetTime = new Date(job.schedule).getTime();
35
- if (targetTime <= Date.now()) {
38
+ if (Number.isFinite(targetTime) && targetTime <= Date.now()) {
36
39
  logger.warn(`Task: missed fire detected for "${job.name}" (was scheduled for ${job.schedule}), firing now`);
37
40
  missedFires++;
38
- this.fireJob(job);
39
- this.store.update(job.id, { enabled: false }); // Disable after one-time execution
41
+ this.store.update(job.id, { enabled: false, nextRunAt: null });
42
+ void this.fireJob(job);
40
43
  continue;
41
44
  }
42
45
  }
43
- this.scheduleJob(job);
46
+ // Seed nextRunAt: prefer stored value (catch-up after sleep/restart);
47
+ // otherwise compute fresh from now.
48
+ let due = null;
49
+ if (job.nextRunAt) {
50
+ const stored = new Date(job.nextRunAt).getTime();
51
+ if (Number.isFinite(stored))
52
+ due = stored;
53
+ }
54
+ if (due === null) {
55
+ due = this.computeNextRun(job, Date.now());
56
+ if (due === null)
57
+ continue; // computeNextRun already logged
58
+ this.store.update(job.id, { nextRunAt: new Date(due).toISOString() });
59
+ }
60
+ this.nextRunAt.set(job.id, due);
44
61
  scheduled++;
45
62
  }
46
63
  logger.info(`Tasks: loaded ${scheduled} active tasks (${jobs.length} total)${missedFires > 0 ? `, fired ${missedFires} missed` : ''}`);
@@ -57,62 +74,75 @@ export class TaskScheduler {
57
74
  this.loadAndSchedule();
58
75
  }
59
76
  }
60
- /** Stop all scheduled tasks */
61
- stopAll() {
62
- for (const [, task] of this.scheduledJobs) {
63
- task.stop();
64
- }
65
- this.scheduledJobs.clear();
66
- }
67
- scheduleJob(job) {
68
- switch (job.scheduleType) {
69
- case 'cron': {
70
- if (!cron.validate(job.schedule)) {
71
- logger.error(`Task: invalid expression for "${job.name}": ${job.schedule}`);
72
- return;
73
- }
74
- const task = cron.schedule(job.schedule, () => this.fireJob(job));
75
- this.scheduledJobs.set(job.id, task);
76
- break;
77
+ /**
78
+ * Fire any tasks whose nextRunAt has elapsed. Called every ~5s by the daemon
79
+ * poll loop. This is the macOS-sleep-resilient replacement for setTimeout-driven
80
+ * cron heartbeats — after a wake, the next tick catches all overdue tasks.
81
+ */
82
+ tick() {
83
+ if (this.stopping)
84
+ return;
85
+ const now = Date.now();
86
+ for (const [id, due] of [...this.nextRunAt]) {
87
+ if (this.stopping)
88
+ return;
89
+ if (due > now)
90
+ continue;
91
+ if (this.running.has(id))
92
+ continue;
93
+ const job = this.store.get(id);
94
+ if (!job || !job.enabled) {
95
+ this.nextRunAt.delete(id);
96
+ continue;
77
97
  }
78
- case 'every': {
79
- const cronExpr = intervalToCron(job.schedule);
80
- if (cronExpr) {
81
- if (!cron.validate(cronExpr)) {
82
- logger.error(`Task: invalid cron expression for "${job.name}": ${cronExpr}`);
83
- return;
84
- }
85
- const task = cron.schedule(cronExpr, () => this.fireJob(job));
86
- this.scheduledJobs.set(job.id, task);
98
+ // Advance BEFORE firing — eliminates double-fire race if tick is re-entered
99
+ // before the async fireJob completes.
100
+ if (job.scheduleType === 'at') {
101
+ this.nextRunAt.delete(id);
102
+ this.store.update(id, { enabled: false, nextRunAt: null });
103
+ }
104
+ else {
105
+ const next = this.computeNextRun(job, now);
106
+ if (next === null) {
107
+ this.nextRunAt.delete(id);
108
+ continue;
87
109
  }
88
- else {
89
- // Use setInterval for non-cron-expressible intervals
90
- const totalMs = intervalToMs(job.schedule);
91
- if (totalMs) {
92
- const timer = setInterval(() => this.fireJob(job), totalMs);
93
- this.scheduledJobs.set(job.id, { stop: () => clearInterval(timer) });
94
- }
95
- else {
96
- logger.error(`Task: invalid interval for "${job.name}": ${job.schedule}`);
110
+ this.nextRunAt.set(id, next);
111
+ this.store.update(id, { nextRunAt: new Date(next).toISOString() });
112
+ }
113
+ this.running.add(id);
114
+ void this.fireJob(job).finally(() => this.running.delete(id));
115
+ }
116
+ }
117
+ /** Stop the scheduler. In-flight fireJob promises are detached and resolve naturally. */
118
+ stopAll() {
119
+ this.stopping = true;
120
+ this.nextRunAt.clear();
121
+ }
122
+ /** Compute next run time in ms epoch, given a "from" anchor. Returns null if invalid. */
123
+ computeNextRun(job, fromMs) {
124
+ try {
125
+ switch (job.scheduleType) {
126
+ case 'cron':
127
+ return CronExpressionParser.parse(job.schedule, { currentDate: new Date(fromMs) }).next().getTime();
128
+ case 'every': {
129
+ const expr = intervalToCron(job.schedule);
130
+ if (expr) {
131
+ return CronExpressionParser.parse(expr, { currentDate: new Date(fromMs) }).next().getTime();
97
132
  }
133
+ const ms = intervalToMs(job.schedule);
134
+ return ms ? fromMs + ms : null;
98
135
  }
99
- break;
100
- }
101
- case 'at': {
102
- const targetTime = new Date(job.schedule).getTime();
103
- const delay = targetTime - Date.now();
104
- if (delay <= 0) {
105
- logger.warn(`Task: one-time task "${job.name}" is in the past, skipping`);
106
- return;
136
+ case 'at': {
137
+ const t = new Date(job.schedule).getTime();
138
+ return Number.isFinite(t) ? t : null;
107
139
  }
108
- const timer = setTimeout(() => {
109
- this.fireJob(job);
110
- this.store.update(job.id, { enabled: false });
111
- }, delay);
112
- this.scheduledJobs.set(job.id, { stop: () => clearTimeout(timer) });
113
- break;
114
140
  }
115
141
  }
142
+ catch (err) {
143
+ logger.error(`Task: invalid expression for "${job.name}": ${job.schedule}`, err);
144
+ return null;
145
+ }
116
146
  }
117
147
  async fireJob(job) {
118
148
  logger.info(`Task firing: "${job.name}" (${job.payloadType || 'agentTurn'}) -> tab:${job.tabName}`);
@@ -214,6 +244,6 @@ export function intervalToCron(interval) {
214
244
  // Weekly intervals
215
245
  if (mins === 0 && hours === 0 && days === 0 && weeks > 0)
216
246
  return `0 0 * * 0`;
217
- // Combined or large intervals -- return null, handled by setInterval in scheduleJob
247
+ // Combined or large intervals -- return null, handled by intervalToMs fallback
218
248
  return null;
219
249
  }
@@ -35,7 +35,14 @@ export class TaskStore {
35
35
  const existing = this.get(id);
36
36
  if (!existing)
37
37
  return false;
38
+ // If the schedule changes, the stored next_run_at is no longer valid —
39
+ // null it out so the scheduler recomputes from the new expression on
40
+ // next load. Caller can override by explicitly passing nextRunAt.
41
+ const scheduleChanged = (updates.schedule !== undefined && updates.schedule !== existing.schedule) ||
42
+ (updates.scheduleType !== undefined && updates.scheduleType !== existing.scheduleType);
38
43
  const merged = { ...existing, ...updates };
44
+ if (scheduleChanged && updates.nextRunAt === undefined)
45
+ merged.nextRunAt = null;
39
46
  db.prepare(`UPDATE tasks SET name=?, schedule_type=?, schedule=?, tab_name=?, message=?, enabled=?, last_run_at=?, next_run_at=? WHERE id=?`).run(merged.name, merged.scheduleType, merged.schedule, merged.tabName, merged.message, merged.enabled ? 1 : 0, merged.lastRunAt, merged.nextRunAt, id);
40
47
  return true;
41
48
  }
@@ -1,12 +1,19 @@
1
1
  export declare class WatcherScheduler {
2
2
  private store;
3
- private intervals;
3
+ private nextCheckAt;
4
+ private running;
5
+ private stopping;
4
6
  onNotify: ((text: string) => Promise<void>) | null;
5
7
  /** Load all watchers from store and schedule them */
6
8
  loadAndSchedule(): void;
7
9
  /** Check for the reload signal file and reload if present */
8
10
  checkForReload(): void;
9
- /** Stop all scheduled watchers */
11
+ /**
12
+ * Fire any watchers whose nextCheckAt has elapsed. Called every ~5s by the daemon
13
+ * poll loop. Sleep-resilient: after wake, the next tick catches all overdue checks.
14
+ */
15
+ tick(): void;
16
+ /** Stop the scheduler. In-flight runCheck promises detach and resolve naturally. */
10
17
  stopAll(): void;
11
18
  private runCheck;
12
19
  private executeAction;
@@ -8,11 +8,16 @@ import { logger } from '../util/logger.js';
8
8
  const WATCHER_RELOAD_SIGNAL_NAME = '.watcher-reload';
9
9
  export class WatcherScheduler {
10
10
  store = new WatcherStore();
11
- intervals = new Map();
11
+ // watcherId -> due time in ms epoch for next check
12
+ nextCheckAt = new Map();
13
+ // watcherIds with an in-flight runCheck
14
+ running = new Set();
15
+ stopping = false;
12
16
  onNotify = null;
13
17
  /** Load all watchers from store and schedule them */
14
18
  loadAndSchedule() {
15
19
  this.stopAll();
20
+ this.stopping = false;
16
21
  const watchers = this.store.list();
17
22
  let scheduled = 0;
18
23
  for (const watcher of watchers) {
@@ -23,8 +28,18 @@ export class WatcherScheduler {
23
28
  logger.error(`Watcher: invalid schedule for "${watcher.name}": ${watcher.schedule}`);
24
29
  continue;
25
30
  }
26
- const timer = setInterval(() => this.runCheck(watcher), ms);
27
- this.intervals.set(watcher.id, timer);
31
+ // Seed nextCheckAt: if we have a lastCheckAt, fire `intervalMs` after it
32
+ // (catches up overdue checks after sleep/restart on the next tick).
33
+ // Otherwise wait `intervalMs` before first fire — matches today's setInterval semantics.
34
+ let due;
35
+ if (watcher.lastCheckAt) {
36
+ const last = new Date(watcher.lastCheckAt).getTime();
37
+ due = Number.isFinite(last) ? last + ms : Date.now() + ms;
38
+ }
39
+ else {
40
+ due = Date.now() + ms;
41
+ }
42
+ this.nextCheckAt.set(watcher.id, due);
28
43
  scheduled++;
29
44
  }
30
45
  if (scheduled > 0 || watchers.length > 0) {
@@ -43,12 +58,41 @@ export class WatcherScheduler {
43
58
  this.loadAndSchedule();
44
59
  }
45
60
  }
46
- /** Stop all scheduled watchers */
47
- stopAll() {
48
- for (const [, timer] of this.intervals) {
49
- clearInterval(timer);
61
+ /**
62
+ * Fire any watchers whose nextCheckAt has elapsed. Called every ~5s by the daemon
63
+ * poll loop. Sleep-resilient: after wake, the next tick catches all overdue checks.
64
+ */
65
+ tick() {
66
+ if (this.stopping)
67
+ return;
68
+ const now = Date.now();
69
+ for (const [id, due] of [...this.nextCheckAt]) {
70
+ if (this.stopping)
71
+ return;
72
+ if (due > now)
73
+ continue;
74
+ if (this.running.has(id))
75
+ continue;
76
+ const watcher = this.store.get(id);
77
+ if (!watcher || !watcher.enabled) {
78
+ this.nextCheckAt.delete(id);
79
+ continue;
80
+ }
81
+ const intervalMs = parseScheduleToMs(watcher.schedule);
82
+ if (!intervalMs) {
83
+ this.nextCheckAt.delete(id);
84
+ continue;
85
+ }
86
+ // Advance BEFORE running — eliminates double-fire race
87
+ this.nextCheckAt.set(id, now + intervalMs);
88
+ this.running.add(id);
89
+ void this.runCheck(watcher).finally(() => this.running.delete(id));
50
90
  }
51
- this.intervals.clear();
91
+ }
92
+ /** Stop the scheduler. In-flight runCheck promises detach and resolve naturally. */
93
+ stopAll() {
94
+ this.stopping = true;
95
+ this.nextCheckAt.clear();
52
96
  }
53
97
  async runCheck(watcher) {
54
98
  const logFile = path.join(getLogsDir(), `watcher-${watcher.name}.log`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "beecork",
3
- "version": "1.4.6",
3
+ "version": "1.4.8",
4
4
  "description": "Claude Code always-on infrastructure — a phone number, a memory, and an alarm clock",
5
5
  "type": "module",
6
6
  "bin": {
@@ -23,8 +23,8 @@
23
23
  "@whiskeysockets/baileys": "^6.7.0",
24
24
  "better-sqlite3": "^12.8.0",
25
25
  "commander": "^14.0.3",
26
+ "cron-parser": "^5.5.0",
26
27
  "discord.js": "^14.26.2",
27
- "node-cron": "^4.2.1",
28
28
  "node-telegram-bot-api": "^0.67.0",
29
29
  "qrcode-terminal": "^0.12.0",
30
30
  "uuid": "^13.0.0"
@@ -33,7 +33,6 @@
33
33
  "@tailwindcss/cli": "^4.2.2",
34
34
  "@types/better-sqlite3": "^7.6.13",
35
35
  "@types/node": "^24.0.0",
36
- "@types/node-cron": "^3.0.11",
37
36
  "@types/node-telegram-bot-api": "^0.64.14",
38
37
  "eslint": "^10.2.0",
39
38
  "tailwindcss": "^4.2.2",