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 +2 -0
- package/dist/tasks/scheduler.d.ts +12 -3
- package/dist/tasks/scheduler.js +92 -62
- package/dist/tasks/store.js +7 -0
- package/dist/watchers/scheduler.d.ts +9 -2
- package/dist/watchers/scheduler.js +52 -8
- package/package.json +2 -3
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
}
|
package/dist/tasks/scheduler.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
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
|
-
|
|
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
|
-
|
|
22
|
-
|
|
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
|
-
//
|
|
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.
|
|
39
|
-
this.
|
|
41
|
+
this.store.update(job.id, { enabled: false, nextRunAt: null });
|
|
42
|
+
void this.fireJob(job);
|
|
40
43
|
continue;
|
|
41
44
|
}
|
|
42
45
|
}
|
|
43
|
-
|
|
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
|
-
/**
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
|
247
|
+
// Combined or large intervals -- return null, handled by intervalToMs fallback
|
|
218
248
|
return null;
|
|
219
249
|
}
|
package/dist/tasks/store.js
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
27
|
-
|
|
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
|
-
/**
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|