@hybridaione/hybridclaw 0.2.2 → 0.2.6

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.
Files changed (277) hide show
  1. package/.github/workflows/ci.yml +70 -0
  2. package/.husky/pre-commit +1 -0
  3. package/CHANGELOG.md +85 -0
  4. package/CONTRIBUTING.md +33 -0
  5. package/README.md +41 -16
  6. package/SECURITY.md +17 -0
  7. package/biome.json +35 -0
  8. package/config.example.json +71 -8
  9. package/container/package-lock.json +2 -2
  10. package/container/package.json +1 -1
  11. package/container/src/approval-policy.ts +1303 -0
  12. package/container/src/browser-tools.ts +431 -136
  13. package/container/src/extensions.ts +36 -12
  14. package/container/src/hybridai-client.ts +34 -13
  15. package/container/src/index.ts +451 -109
  16. package/container/src/ipc.ts +5 -3
  17. package/container/src/token-usage.ts +20 -10
  18. package/container/src/tools.ts +599 -225
  19. package/container/src/types.ts +32 -2
  20. package/container/src/web-fetch.ts +89 -32
  21. package/dist/agent.d.ts.map +1 -1
  22. package/dist/agent.js +10 -2
  23. package/dist/agent.js.map +1 -1
  24. package/dist/audit-cli.d.ts.map +1 -1
  25. package/dist/audit-cli.js +4 -2
  26. package/dist/audit-cli.js.map +1 -1
  27. package/dist/audit-events.d.ts.map +1 -1
  28. package/dist/audit-events.js +53 -3
  29. package/dist/audit-events.js.map +1 -1
  30. package/dist/audit-trail.d.ts.map +1 -1
  31. package/dist/audit-trail.js +17 -8
  32. package/dist/audit-trail.js.map +1 -1
  33. package/dist/channels/discord/attachments.d.ts.map +1 -1
  34. package/dist/channels/discord/attachments.js +14 -7
  35. package/dist/channels/discord/attachments.js.map +1 -1
  36. package/dist/channels/discord/debounce.d.ts +9 -0
  37. package/dist/channels/discord/debounce.d.ts.map +1 -0
  38. package/dist/channels/discord/debounce.js +20 -0
  39. package/dist/channels/discord/debounce.js.map +1 -0
  40. package/dist/channels/discord/delivery.d.ts +4 -1
  41. package/dist/channels/discord/delivery.d.ts.map +1 -1
  42. package/dist/channels/discord/delivery.js +19 -3
  43. package/dist/channels/discord/delivery.js.map +1 -1
  44. package/dist/channels/discord/human-delay.d.ts +16 -0
  45. package/dist/channels/discord/human-delay.d.ts.map +1 -0
  46. package/dist/channels/discord/human-delay.js +29 -0
  47. package/dist/channels/discord/human-delay.js.map +1 -0
  48. package/dist/channels/discord/inbound.d.ts +4 -0
  49. package/dist/channels/discord/inbound.d.ts.map +1 -1
  50. package/dist/channels/discord/inbound.js +45 -4
  51. package/dist/channels/discord/inbound.js.map +1 -1
  52. package/dist/channels/discord/mentions.d.ts.map +1 -1
  53. package/dist/channels/discord/mentions.js +16 -4
  54. package/dist/channels/discord/mentions.js.map +1 -1
  55. package/dist/channels/discord/presence.d.ts +33 -0
  56. package/dist/channels/discord/presence.d.ts.map +1 -0
  57. package/dist/channels/discord/presence.js +111 -0
  58. package/dist/channels/discord/presence.js.map +1 -0
  59. package/dist/channels/discord/rate-limiter.d.ts +14 -0
  60. package/dist/channels/discord/rate-limiter.d.ts.map +1 -0
  61. package/dist/channels/discord/rate-limiter.js +49 -0
  62. package/dist/channels/discord/rate-limiter.js.map +1 -0
  63. package/dist/channels/discord/reactions.d.ts +38 -0
  64. package/dist/channels/discord/reactions.d.ts.map +1 -0
  65. package/dist/channels/discord/reactions.js +151 -0
  66. package/dist/channels/discord/reactions.js.map +1 -0
  67. package/dist/channels/discord/runtime.d.ts +6 -3
  68. package/dist/channels/discord/runtime.d.ts.map +1 -1
  69. package/dist/channels/discord/runtime.js +621 -125
  70. package/dist/channels/discord/runtime.js.map +1 -1
  71. package/dist/channels/discord/stream.d.ts +4 -1
  72. package/dist/channels/discord/stream.d.ts.map +1 -1
  73. package/dist/channels/discord/stream.js +16 -8
  74. package/dist/channels/discord/stream.js.map +1 -1
  75. package/dist/channels/discord/tool-actions.d.ts.map +1 -1
  76. package/dist/channels/discord/tool-actions.js +24 -12
  77. package/dist/channels/discord/tool-actions.js.map +1 -1
  78. package/dist/channels/discord/typing.d.ts +15 -0
  79. package/dist/channels/discord/typing.d.ts.map +1 -0
  80. package/dist/channels/discord/typing.js +106 -0
  81. package/dist/channels/discord/typing.js.map +1 -0
  82. package/dist/chunk.d.ts.map +1 -1
  83. package/dist/chunk.js +4 -2
  84. package/dist/chunk.js.map +1 -1
  85. package/dist/cli.js +47 -22
  86. package/dist/cli.js.map +1 -1
  87. package/dist/config.d.ts +19 -0
  88. package/dist/config.d.ts.map +1 -1
  89. package/dist/config.js +103 -18
  90. package/dist/config.js.map +1 -1
  91. package/dist/container-runner.d.ts.map +1 -1
  92. package/dist/container-runner.js +58 -26
  93. package/dist/container-runner.js.map +1 -1
  94. package/dist/container-setup.d.ts.map +1 -1
  95. package/dist/container-setup.js +10 -9
  96. package/dist/container-setup.js.map +1 -1
  97. package/dist/conversation.d.ts +2 -2
  98. package/dist/conversation.d.ts.map +1 -1
  99. package/dist/conversation.js +1 -1
  100. package/dist/conversation.js.map +1 -1
  101. package/dist/db.d.ts +118 -2
  102. package/dist/db.d.ts.map +1 -1
  103. package/dist/db.js +1568 -50
  104. package/dist/db.js.map +1 -1
  105. package/dist/delegation-manager.d.ts.map +1 -1
  106. package/dist/delegation-manager.js +3 -2
  107. package/dist/delegation-manager.js.map +1 -1
  108. package/dist/gateway-client.d.ts +2 -2
  109. package/dist/gateway-client.d.ts.map +1 -1
  110. package/dist/gateway-client.js +10 -4
  111. package/dist/gateway-client.js.map +1 -1
  112. package/dist/gateway-service.d.ts +3 -3
  113. package/dist/gateway-service.d.ts.map +1 -1
  114. package/dist/gateway-service.js +563 -73
  115. package/dist/gateway-service.js.map +1 -1
  116. package/dist/gateway-types.d.ts +24 -0
  117. package/dist/gateway-types.d.ts.map +1 -1
  118. package/dist/gateway-types.js.map +1 -1
  119. package/dist/gateway.js +179 -24
  120. package/dist/gateway.js.map +1 -1
  121. package/dist/health.d.ts.map +1 -1
  122. package/dist/health.js +20 -10
  123. package/dist/health.js.map +1 -1
  124. package/dist/heartbeat.d.ts +4 -0
  125. package/dist/heartbeat.d.ts.map +1 -1
  126. package/dist/heartbeat.js +48 -20
  127. package/dist/heartbeat.js.map +1 -1
  128. package/dist/hybridai-bots.d.ts.map +1 -1
  129. package/dist/hybridai-bots.js +4 -2
  130. package/dist/hybridai-bots.js.map +1 -1
  131. package/dist/instruction-approval-audit.d.ts.map +1 -1
  132. package/dist/instruction-approval-audit.js.map +1 -1
  133. package/dist/instruction-integrity.d.ts.map +1 -1
  134. package/dist/instruction-integrity.js +8 -2
  135. package/dist/instruction-integrity.js.map +1 -1
  136. package/dist/ipc.d.ts.map +1 -1
  137. package/dist/ipc.js +6 -1
  138. package/dist/ipc.js.map +1 -1
  139. package/dist/logger.js.map +1 -1
  140. package/dist/memory-consolidation.d.ts +17 -0
  141. package/dist/memory-consolidation.d.ts.map +1 -0
  142. package/dist/memory-consolidation.js +25 -0
  143. package/dist/memory-consolidation.js.map +1 -0
  144. package/dist/memory-service.d.ts +200 -0
  145. package/dist/memory-service.d.ts.map +1 -0
  146. package/dist/memory-service.js +294 -0
  147. package/dist/memory-service.js.map +1 -0
  148. package/dist/mount-security.d.ts.map +1 -1
  149. package/dist/mount-security.js +31 -7
  150. package/dist/mount-security.js.map +1 -1
  151. package/dist/observability-ingest.d.ts.map +1 -1
  152. package/dist/observability-ingest.js +32 -11
  153. package/dist/observability-ingest.js.map +1 -1
  154. package/dist/onboarding.d.ts.map +1 -1
  155. package/dist/onboarding.js +32 -9
  156. package/dist/onboarding.js.map +1 -1
  157. package/dist/proactive-policy.d.ts.map +1 -1
  158. package/dist/proactive-policy.js +2 -1
  159. package/dist/proactive-policy.js.map +1 -1
  160. package/dist/prompt-hooks.d.ts.map +1 -1
  161. package/dist/prompt-hooks.js +9 -7
  162. package/dist/prompt-hooks.js.map +1 -1
  163. package/dist/runtime-config.d.ts +98 -1
  164. package/dist/runtime-config.d.ts.map +1 -1
  165. package/dist/runtime-config.js +477 -23
  166. package/dist/runtime-config.js.map +1 -1
  167. package/dist/scheduled-task-runner.d.ts +1 -0
  168. package/dist/scheduled-task-runner.d.ts.map +1 -1
  169. package/dist/scheduled-task-runner.js +29 -10
  170. package/dist/scheduled-task-runner.js.map +1 -1
  171. package/dist/scheduler.d.ts +43 -4
  172. package/dist/scheduler.d.ts.map +1 -1
  173. package/dist/scheduler.js +530 -56
  174. package/dist/scheduler.js.map +1 -1
  175. package/dist/session-export.d.ts +26 -0
  176. package/dist/session-export.d.ts.map +1 -0
  177. package/dist/session-export.js +149 -0
  178. package/dist/session-export.js.map +1 -0
  179. package/dist/session-maintenance.d.ts.map +1 -1
  180. package/dist/session-maintenance.js +75 -13
  181. package/dist/session-maintenance.js.map +1 -1
  182. package/dist/session-transcripts.d.ts.map +1 -1
  183. package/dist/session-transcripts.js.map +1 -1
  184. package/dist/side-effects.d.ts.map +1 -1
  185. package/dist/side-effects.js +14 -2
  186. package/dist/side-effects.js.map +1 -1
  187. package/dist/skills-guard.d.ts.map +1 -1
  188. package/dist/skills-guard.js +893 -130
  189. package/dist/skills-guard.js.map +1 -1
  190. package/dist/skills.d.ts +5 -0
  191. package/dist/skills.d.ts.map +1 -1
  192. package/dist/skills.js +29 -15
  193. package/dist/skills.js.map +1 -1
  194. package/dist/token-efficiency.d.ts.map +1 -1
  195. package/dist/token-efficiency.js.map +1 -1
  196. package/dist/tui.js +92 -11
  197. package/dist/tui.js.map +1 -1
  198. package/dist/types.d.ts +146 -0
  199. package/dist/types.d.ts.map +1 -1
  200. package/dist/types.js +24 -1
  201. package/dist/types.js.map +1 -1
  202. package/dist/update.d.ts.map +1 -1
  203. package/dist/update.js +42 -14
  204. package/dist/update.js.map +1 -1
  205. package/dist/workspace.d.ts.map +1 -1
  206. package/dist/workspace.js +49 -9
  207. package/dist/workspace.js.map +1 -1
  208. package/docs/chat.html +9 -3
  209. package/docs/index.html +37 -13
  210. package/package.json +8 -2
  211. package/src/agent.ts +16 -3
  212. package/src/audit-cli.ts +44 -16
  213. package/src/audit-events.ts +69 -5
  214. package/src/audit-trail.ts +41 -15
  215. package/src/channels/discord/attachments.ts +81 -27
  216. package/src/channels/discord/debounce.ts +25 -0
  217. package/src/channels/discord/delivery.ts +57 -13
  218. package/src/channels/discord/human-delay.ts +48 -0
  219. package/src/channels/discord/inbound.ts +66 -7
  220. package/src/channels/discord/mentions.ts +42 -18
  221. package/src/channels/discord/presence.ts +148 -0
  222. package/src/channels/discord/rate-limiter.ts +58 -0
  223. package/src/channels/discord/reactions.ts +211 -0
  224. package/src/channels/discord/runtime.ts +1048 -182
  225. package/src/channels/discord/stream.ts +73 -27
  226. package/src/channels/discord/tool-actions.ts +78 -37
  227. package/src/channels/discord/typing.ts +140 -0
  228. package/src/chunk.ts +12 -4
  229. package/src/cli.ts +141 -56
  230. package/src/config.ts +192 -34
  231. package/src/container-runner.ts +132 -42
  232. package/src/container-setup.ts +57 -22
  233. package/src/conversation.ts +9 -7
  234. package/src/db.ts +2217 -84
  235. package/src/delegation-manager.ts +6 -2
  236. package/src/gateway-client.ts +41 -17
  237. package/src/gateway-service.ts +1019 -201
  238. package/src/gateway-types.ts +33 -0
  239. package/src/gateway.ts +321 -48
  240. package/src/health.ts +66 -26
  241. package/src/heartbeat.ts +84 -22
  242. package/src/hybridai-bots.ts +14 -5
  243. package/src/instruction-approval-audit.ts +4 -1
  244. package/src/instruction-integrity.ts +30 -9
  245. package/src/ipc.ts +23 -5
  246. package/src/logger.ts +4 -1
  247. package/src/memory-consolidation.ts +41 -0
  248. package/src/memory-service.ts +606 -0
  249. package/src/mount-security.ts +58 -13
  250. package/src/observability-ingest.ts +134 -35
  251. package/src/onboarding.ts +126 -35
  252. package/src/proactive-policy.ts +3 -1
  253. package/src/prompt-hooks.ts +40 -17
  254. package/src/runtime-config.ts +1114 -99
  255. package/src/scheduled-task-runner.ts +63 -11
  256. package/src/scheduler.ts +683 -60
  257. package/src/session-export.ts +196 -0
  258. package/src/session-maintenance.ts +125 -22
  259. package/src/session-transcripts.ts +12 -3
  260. package/src/side-effects.ts +28 -5
  261. package/src/skills-guard.ts +1067 -219
  262. package/src/skills.ts +163 -65
  263. package/src/token-efficiency.ts +31 -9
  264. package/src/tui.ts +166 -25
  265. package/src/types.ts +195 -2
  266. package/src/update.ts +79 -23
  267. package/src/workspace.ts +63 -11
  268. package/tests/approval-policy.test.ts +224 -0
  269. package/tests/discord.basic.test.ts +82 -2
  270. package/tests/discord.human-presence.test.ts +85 -0
  271. package/tests/gateway-service.media-routing.test.ts +8 -2
  272. package/tests/memory-service.test.ts +1114 -0
  273. package/tests/token-efficiency.basic.test.ts +8 -2
  274. package/vitest.e2e.config.ts +3 -1
  275. package/vitest.integration.config.ts +3 -1
  276. package/vitest.live.config.ts +3 -1
  277. package/vitest.unit.config.ts +9 -0
package/src/scheduler.ts CHANGED
@@ -1,24 +1,83 @@
1
1
  /**
2
2
  * Scheduler — timer-based, arms for exact next-fire time.
3
3
  *
4
- * Instead of polling every 60s, computes when the next task is due and
5
- * sets a single setTimeout for that moment. Re-arms after every tick
6
- * and whenever a task is added/removed via rearmScheduler().
4
+ * Runs both legacy DB-backed tasks and config-backed scheduler.jobs.
7
5
  */
6
+
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
8
9
  import { CronExpressionParser } from 'cron-parser';
9
10
 
10
- import { deleteTask, getAllEnabledTasks, updateTaskLastRun } from './db.js';
11
+ import { DATA_DIR, getConfigSnapshot } from './config.js';
12
+ import {
13
+ deleteTask,
14
+ getAllEnabledTasks,
15
+ markTaskFailure,
16
+ markTaskSuccess,
17
+ updateTaskLastRun,
18
+ } from './db.js';
11
19
  import { logger } from './logger.js';
20
+ import type { RuntimeSchedulerJob } from './runtime-config.js';
21
+ import type { ScheduledTask } from './types.js';
12
22
 
13
23
  const MAX_TIMER_DELAY_MS = 300_000; // 5 min safety net for clock drift
24
+ const MAX_CONSECUTIVE_FAILURES = 5;
25
+ const CONFIG_ONESHOT_RETRY_MS = 60_000;
26
+ const SCHEDULER_STATE_VERSION = 1;
27
+ const SCHEDULER_STATE_PATH = path.join(DATA_DIR, 'scheduler-jobs-state.json');
28
+
29
+ export interface SchedulerDispatchRequest {
30
+ source: 'db-task' | 'config-job';
31
+ taskId?: number;
32
+ jobId?: string;
33
+ sessionId: string;
34
+ channelId: string;
35
+ prompt: string;
36
+ actionKind: 'agent_turn' | 'system_event';
37
+ delivery:
38
+ | { kind: 'channel'; channelId: string }
39
+ | { kind: 'last-channel' }
40
+ | { kind: 'webhook'; webhookUrl: string };
41
+ }
42
+
43
+ type TaskRunner = (request: SchedulerDispatchRequest) => Promise<void>;
44
+
45
+ interface ConfigJobMeta {
46
+ lastRun: string | null;
47
+ lastStatus: 'success' | 'error' | null;
48
+ nextRunAt: string | null;
49
+ consecutiveErrors: number;
50
+ disabled: boolean;
51
+ oneShotCompleted: boolean;
52
+ }
53
+
54
+ export interface ConfigJobRuntimeState {
55
+ lastRun: string | null;
56
+ lastStatus: 'success' | 'error' | null;
57
+ nextRunAt: string | null;
58
+ disabled: boolean;
59
+ consecutiveErrors: number;
60
+ }
14
61
 
15
- type TaskRunner = (sessionId: string, channelId: string, prompt: string, taskId: number) => Promise<void>;
62
+ export interface SchedulerStatusJob extends ConfigJobRuntimeState {
63
+ id: string;
64
+ name: string;
65
+ description: string | null;
66
+ enabled: boolean;
67
+ }
68
+
69
+ interface SchedulerStateFile {
70
+ version: number;
71
+ updatedAt: string;
72
+ configJobs: Record<string, ConfigJobMeta>;
73
+ }
16
74
 
17
75
  let timer: ReturnType<typeof setTimeout> | null = null;
18
76
  let taskRunner: TaskRunner | null = null;
19
77
  let ticking = false;
78
+ const schedulerState: SchedulerStateFile = loadSchedulerState();
20
79
 
21
- // --- Prompt framing (OpenClaw style) ---
80
+ // --- Prompt framing ---
22
81
 
23
82
  function formatFireTime(): string {
24
83
  return new Date().toLocaleString('en-US', {
@@ -31,39 +90,260 @@ function formatFireTime(): string {
31
90
  });
32
91
  }
33
92
 
34
- export function wrapCronPrompt(taskId: number, taskName: string, message: string): string {
93
+ export function wrapCronPrompt(jobLabel: string, message: string): string {
35
94
  const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
36
- return `[cron:#${taskId} ${taskName}] ${message}\nCurrent time: ${formatFireTime()} (${tz})\n\nReturn your response as plain text; it will be delivered automatically. If the task explicitly calls for messaging a specific external recipient, note who/where it should go instead of sending it yourself.`;
95
+ return `[cron:${jobLabel}] ${message}\nCurrent time: ${formatFireTime()} (${tz})\n\nReturn your response as plain text; it will be delivered automatically. If the task explicitly calls for messaging a specific external recipient, note who/where it should go instead of sending it yourself.`;
37
96
  }
38
97
 
39
- // --- Timer logic ---
98
+ function defaultConfigJobMeta(): ConfigJobMeta {
99
+ return {
100
+ lastRun: null,
101
+ lastStatus: null,
102
+ nextRunAt: null,
103
+ consecutiveErrors: 0,
104
+ disabled: false,
105
+ oneShotCompleted: false,
106
+ };
107
+ }
40
108
 
41
- function computeNextFireMs(): number | null {
42
- const tasks = getAllEnabledTasks();
43
- let earliest: number | null = null;
109
+ function isRecord(value: unknown): value is Record<string, unknown> {
110
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
111
+ }
44
112
 
45
- for (const task of tasks) {
46
- if (task.run_at) {
47
- if (!task.last_run) {
48
- const ms = new Date(task.run_at).getTime();
49
- if (earliest === null || ms < earliest) earliest = ms;
50
- }
51
- continue;
52
- }
113
+ function normalizeConfigJobMeta(value: unknown): ConfigJobMeta {
114
+ if (!isRecord(value)) return defaultConfigJobMeta();
115
+ const lastRun =
116
+ typeof value.lastRun === 'string' && value.lastRun.trim()
117
+ ? value.lastRun.trim()
118
+ : null;
119
+ const lastStatus =
120
+ value.lastStatus === 'success' || value.lastStatus === 'error'
121
+ ? value.lastStatus
122
+ : null;
123
+ const nextRunAt =
124
+ typeof value.nextRunAt === 'string' && value.nextRunAt.trim()
125
+ ? value.nextRunAt.trim()
126
+ : null;
127
+ const consecutiveErrors =
128
+ typeof value.consecutiveErrors === 'number' &&
129
+ Number.isFinite(value.consecutiveErrors)
130
+ ? Math.max(0, Math.floor(value.consecutiveErrors))
131
+ : 0;
132
+ return {
133
+ lastRun,
134
+ lastStatus,
135
+ nextRunAt,
136
+ consecutiveErrors,
137
+ disabled: Boolean(value.disabled),
138
+ oneShotCompleted: Boolean(value.oneShotCompleted),
139
+ };
140
+ }
53
141
 
54
- if (task.every_ms) {
55
- const lastRunMs = task.last_run ? new Date(task.last_run).getTime() : 0;
56
- const nextMs = lastRunMs > 0 ? lastRunMs + task.every_ms : Date.now();
57
- if (earliest === null || nextMs < earliest) earliest = nextMs;
58
- continue;
142
+ function loadSchedulerState(): SchedulerStateFile {
143
+ try {
144
+ if (!fs.existsSync(SCHEDULER_STATE_PATH)) {
145
+ return {
146
+ version: SCHEDULER_STATE_VERSION,
147
+ updatedAt: new Date(0).toISOString(),
148
+ configJobs: {},
149
+ };
59
150
  }
151
+ const raw = fs.readFileSync(SCHEDULER_STATE_PATH, 'utf-8');
152
+ const parsed = JSON.parse(raw) as unknown;
153
+ if (!isRecord(parsed)) throw new Error('state file root must be object');
154
+ const rawJobs = isRecord(parsed.configJobs) ? parsed.configJobs : {};
155
+ const configJobs: Record<string, ConfigJobMeta> = {};
156
+ for (const [id, meta] of Object.entries(rawJobs)) {
157
+ const key = id.trim();
158
+ if (!key) continue;
159
+ configJobs[key] = normalizeConfigJobMeta(meta);
160
+ }
161
+ return {
162
+ version: SCHEDULER_STATE_VERSION,
163
+ updatedAt:
164
+ typeof parsed.updatedAt === 'string' && parsed.updatedAt.trim()
165
+ ? parsed.updatedAt
166
+ : new Date(0).toISOString(),
167
+ configJobs,
168
+ };
169
+ } catch (error) {
170
+ logger.warn(
171
+ { error },
172
+ 'Failed to load scheduler state file; starting with defaults',
173
+ );
174
+ return {
175
+ version: SCHEDULER_STATE_VERSION,
176
+ updatedAt: new Date(0).toISOString(),
177
+ configJobs: {},
178
+ };
179
+ }
180
+ }
181
+
182
+ function persistSchedulerState(): void {
183
+ try {
184
+ fs.mkdirSync(path.dirname(SCHEDULER_STATE_PATH), { recursive: true });
185
+ schedulerState.updatedAt = new Date().toISOString();
186
+ const payload = `${JSON.stringify(schedulerState, null, 2)}\n`;
187
+ const tmpPath = `${SCHEDULER_STATE_PATH}.tmp-${process.pid}-${Date.now()}`;
188
+ fs.writeFileSync(tmpPath, payload, 'utf-8');
189
+ fs.renameSync(tmpPath, SCHEDULER_STATE_PATH);
190
+ } catch (error) {
191
+ logger.warn({ error }, 'Failed to persist scheduler state file');
192
+ }
193
+ }
194
+
195
+ function getConfigJobMeta(jobId: string): ConfigJobMeta {
196
+ const existing = schedulerState.configJobs[jobId];
197
+ if (existing) return existing;
198
+ const created = defaultConfigJobMeta();
199
+ schedulerState.configJobs[jobId] = created;
200
+ return created;
201
+ }
202
+
203
+ function pruneConfigJobMeta(activeJobs: RuntimeSchedulerJob[]): void {
204
+ const activeIds = new Set(activeJobs.map((job) => job.id));
205
+ let changed = false;
206
+ for (const id of Object.keys(schedulerState.configJobs)) {
207
+ if (activeIds.has(id)) continue;
208
+ delete schedulerState.configJobs[id];
209
+ changed = true;
210
+ }
211
+ if (changed) persistSchedulerState();
212
+ }
213
+
214
+ function resolveConfigJobLabel(
215
+ job: Pick<RuntimeSchedulerJob, 'id' | 'name'>,
216
+ ): string {
217
+ const candidate = typeof job.name === 'string' ? job.name.trim() : '';
218
+ return candidate || job.id;
219
+ }
220
+
221
+ function parseCronExpression(
222
+ expr: string,
223
+ tz: string | undefined,
224
+ ): ReturnType<typeof CronExpressionParser.parse> {
225
+ const trimmedTz = tz?.trim();
226
+ if (trimmedTz) {
227
+ return CronExpressionParser.parse(expr, { tz: trimmedTz });
228
+ }
229
+ return CronExpressionParser.parse(expr);
230
+ }
231
+
232
+ function nextFireMsForDbTask(
233
+ task: ScheduledTask,
234
+ nowMs: number,
235
+ ): number | null {
236
+ if (task.run_at) {
237
+ if (task.last_run) return null;
238
+ const ms = new Date(task.run_at).getTime();
239
+ return Number.isFinite(ms) ? ms : null;
240
+ }
241
+
242
+ if (task.every_ms) {
243
+ const lastRunMs = task.last_run ? new Date(task.last_run).getTime() : 0;
244
+ return lastRunMs > 0 ? lastRunMs + task.every_ms : nowMs;
245
+ }
246
+
247
+ if (!task.cron_expr) return null;
248
+
249
+ try {
250
+ const ms = CronExpressionParser.parse(task.cron_expr)
251
+ .next()
252
+ .toDate()
253
+ .getTime();
254
+ return Number.isFinite(ms) ? ms : null;
255
+ } catch {
256
+ return null;
257
+ }
258
+ }
259
+
260
+ function toIsoTimestamp(ms: number | null): string | null {
261
+ if (ms == null || !Number.isFinite(ms)) return null;
262
+ return new Date(ms).toISOString();
263
+ }
264
+
265
+ function syncConfigJobNextRunAt(
266
+ job: RuntimeSchedulerJob,
267
+ nowMs: number,
268
+ ): boolean {
269
+ const meta = getConfigJobMeta(job.id);
270
+ const nextRunAt = toIsoTimestamp(nextFireMsForConfigJob(job, nowMs));
271
+ if (meta.nextRunAt === nextRunAt) return false;
272
+ meta.nextRunAt = nextRunAt;
273
+ return true;
274
+ }
275
+
276
+ function syncConfigJobsNextRunAt(
277
+ jobs: RuntimeSchedulerJob[],
278
+ nowMs: number,
279
+ ): boolean {
280
+ let changed = false;
281
+ for (const job of jobs) {
282
+ if (syncConfigJobNextRunAt(job, nowMs)) changed = true;
283
+ }
284
+ return changed;
285
+ }
286
+
287
+ function nextFireMsForConfigJob(
288
+ job: RuntimeSchedulerJob,
289
+ nowMs: number,
290
+ ): number | null {
291
+ if (!job.enabled) return null;
292
+ const meta = getConfigJobMeta(job.id);
293
+ if (meta.disabled) return null;
294
+
295
+ if (job.schedule.kind === 'at') {
296
+ if (meta.oneShotCompleted) return null;
297
+ if (!job.schedule.at) return null;
298
+ const atMs = new Date(job.schedule.at).getTime();
299
+ if (!Number.isFinite(atMs)) return null;
300
+ const lastRunMs = meta.lastRun ? new Date(meta.lastRun).getTime() : 0;
301
+ if (atMs > nowMs) return atMs;
302
+ if (lastRunMs <= 0) return atMs;
303
+ return lastRunMs + CONFIG_ONESHOT_RETRY_MS;
304
+ }
305
+
306
+ if (job.schedule.kind === 'every') {
307
+ if (!job.schedule.everyMs) return null;
308
+ const lastRunMs = meta.lastRun ? new Date(meta.lastRun).getTime() : 0;
309
+ return lastRunMs > 0 ? lastRunMs + job.schedule.everyMs : nowMs;
310
+ }
311
+
312
+ if (!job.schedule.expr) return null;
313
+ try {
314
+ const ms = parseCronExpression(
315
+ job.schedule.expr,
316
+ job.schedule.tz || undefined,
317
+ )
318
+ .next()
319
+ .toDate()
320
+ .getTime();
321
+ return Number.isFinite(ms) ? ms : null;
322
+ } catch {
323
+ return null;
324
+ }
325
+ }
326
+
327
+ function computeNextFireMs(nowMs = Date.now()): number | null {
328
+ const dbTasks = getAllEnabledTasks();
329
+ const cfgJobs = getConfigSnapshot().scheduler.jobs;
330
+ pruneConfigJobMeta(cfgJobs);
331
+ if (syncConfigJobsNextRunAt(cfgJobs, nowMs)) {
332
+ persistSchedulerState();
333
+ }
60
334
 
61
- if (!task.cron_expr) continue;
335
+ let earliest: number | null = null;
336
+
337
+ for (const task of dbTasks) {
338
+ const fireMs = nextFireMsForDbTask(task, nowMs);
339
+ if (fireMs === null) continue;
340
+ if (earliest === null || fireMs < earliest) earliest = fireMs;
341
+ }
62
342
 
63
- try {
64
- const ms = CronExpressionParser.parse(task.cron_expr).next().toDate().getTime();
65
- if (earliest === null || ms < earliest) earliest = ms;
66
- } catch { /* skip invalid */ }
343
+ for (const job of cfgJobs) {
344
+ const fireMs = nextFireMsForConfigJob(job, nowMs);
345
+ if (fireMs === null) continue;
346
+ if (earliest === null || fireMs < earliest) earliest = fireMs;
67
347
  }
68
348
 
69
349
  return earliest;
@@ -74,7 +354,7 @@ function arm(): void {
74
354
  timer = null;
75
355
 
76
356
  const nextFireMs = computeNextFireMs();
77
- if (nextFireMs === null) return; // nothing scheduled
357
+ if (nextFireMs === null) return;
78
358
 
79
359
  const delay = Math.max(nextFireMs - Date.now(), 0);
80
360
  const clamped = Math.min(delay, MAX_TIMER_DELAY_MS);
@@ -87,64 +367,172 @@ function arm(): void {
87
367
  timer = setTimeout(() => {
88
368
  void tick().catch((err) => {
89
369
  logger.error({ err }, 'Scheduler tick failed');
90
- arm(); // re-arm even on error
370
+ arm();
91
371
  });
92
372
  }, clamped);
93
373
  }
94
374
 
375
+ async function dispatchDbTask(task: ScheduledTask): Promise<void> {
376
+ if (!taskRunner) return;
377
+ const prompt = wrapCronPrompt(`#${task.id}`, task.prompt);
378
+ await taskRunner({
379
+ source: 'db-task',
380
+ taskId: task.id,
381
+ sessionId: task.session_id,
382
+ channelId: task.channel_id,
383
+ prompt,
384
+ actionKind: 'agent_turn',
385
+ delivery: {
386
+ kind: 'channel',
387
+ channelId: task.channel_id,
388
+ },
389
+ });
390
+ }
391
+
392
+ async function dispatchConfigJob(job: RuntimeSchedulerJob): Promise<void> {
393
+ if (!taskRunner) return;
394
+ const jobLabel = resolveConfigJobLabel(job);
395
+ const contextChannelId =
396
+ job.delivery.kind === 'channel' ? job.delivery.to : 'scheduler';
397
+ const prompt =
398
+ job.action.kind === 'agent_turn'
399
+ ? wrapCronPrompt(jobLabel, job.action.message)
400
+ : job.action.message;
401
+ await taskRunner({
402
+ source: 'config-job',
403
+ jobId: job.id,
404
+ sessionId: `scheduler:${job.id}`,
405
+ channelId: contextChannelId,
406
+ prompt,
407
+ actionKind: job.action.kind,
408
+ delivery:
409
+ job.delivery.kind === 'channel'
410
+ ? { kind: 'channel', channelId: job.delivery.to }
411
+ : job.delivery.kind === 'last-channel'
412
+ ? { kind: 'last-channel' }
413
+ : { kind: 'webhook', webhookUrl: job.delivery.webhookUrl },
414
+ });
415
+ }
416
+
417
+ function markConfigJobSuccess(
418
+ job: RuntimeSchedulerJob,
419
+ markOneShotDone = false,
420
+ ): void {
421
+ const meta = getConfigJobMeta(job.id);
422
+ meta.lastStatus = 'success';
423
+ meta.consecutiveErrors = 0;
424
+ if (markOneShotDone) meta.oneShotCompleted = true;
425
+ syncConfigJobNextRunAt(job, Date.now());
426
+ persistSchedulerState();
427
+ }
428
+
429
+ function markConfigJobFailure(job: RuntimeSchedulerJob): {
430
+ disabled: boolean;
431
+ consecutiveErrors: number;
432
+ } {
433
+ const meta = getConfigJobMeta(job.id);
434
+ meta.lastStatus = 'error';
435
+ meta.consecutiveErrors = Math.max(0, meta.consecutiveErrors) + 1;
436
+ if (meta.consecutiveErrors >= MAX_CONSECUTIVE_FAILURES) {
437
+ meta.disabled = true;
438
+ }
439
+ syncConfigJobNextRunAt(job, Date.now());
440
+ persistSchedulerState();
441
+ return {
442
+ disabled: meta.disabled,
443
+ consecutiveErrors: meta.consecutiveErrors,
444
+ };
445
+ }
446
+
95
447
  async function tick(): Promise<void> {
96
448
  if (ticking) {
97
- arm(); // re-check later
449
+ arm();
98
450
  return;
99
451
  }
100
452
  ticking = true;
101
453
 
102
454
  try {
103
- const tasks = getAllEnabledTasks();
455
+ const dbTasks = getAllEnabledTasks();
456
+ const cfgJobs = getConfigSnapshot().scheduler.jobs;
457
+ pruneConfigJobMeta(cfgJobs);
458
+
104
459
  const now = new Date();
460
+ const nowMs = now.getTime();
105
461
 
106
- for (const task of tasks) {
462
+ for (const task of dbTasks) {
107
463
  try {
108
- // --- One-shot task ---
109
464
  if (task.run_at) {
110
465
  const runAt = new Date(task.run_at);
111
- if (runAt.getTime() <= now.getTime() && !task.last_run) {
466
+ if (runAt.getTime() <= nowMs && !task.last_run) {
112
467
  logger.info(
113
468
  { taskId: task.id, runAt: task.run_at, prompt: task.prompt },
114
469
  'One-shot task firing',
115
470
  );
116
- updateTaskLastRun(task.id); // prevents re-fire
117
- const prompt = wrapCronPrompt(task.id, task.prompt, task.prompt);
118
- taskRunner!(task.session_id, task.channel_id, prompt, task.id)
119
- .then(() => deleteTask(task.id)) // cleanup on success
471
+ updateTaskLastRun(task.id);
472
+ dispatchDbTask(task)
473
+ .then(() => {
474
+ markTaskSuccess(task.id);
475
+ deleteTask(task.id);
476
+ })
120
477
  .catch((err) => {
121
- logger.error({ taskId: task.id, err }, 'One-shot task failed (task preserved)');
478
+ const failure = markTaskFailure(
479
+ task.id,
480
+ MAX_CONSECUTIVE_FAILURES,
481
+ );
482
+ logger.error(
483
+ { taskId: task.id, err },
484
+ 'One-shot task failed (task preserved)',
485
+ );
486
+ if (failure.disabled) {
487
+ logger.warn(
488
+ {
489
+ taskId: task.id,
490
+ consecutiveErrors: failure.consecutiveErrors,
491
+ },
492
+ 'Scheduled task auto-disabled after repeated failures',
493
+ );
494
+ }
122
495
  });
123
496
  }
124
497
  continue;
125
498
  }
126
499
 
127
- // --- Interval task ---
128
500
  if (task.every_ms) {
129
- const lastRunMs = task.last_run ? new Date(task.last_run).getTime() : 0;
130
- const dueAt = lastRunMs > 0 ? lastRunMs + task.every_ms : 0; // fire immediately if never run
131
- if (dueAt <= now.getTime()) {
501
+ const lastRunMs = task.last_run
502
+ ? new Date(task.last_run).getTime()
503
+ : 0;
504
+ const dueAt = lastRunMs > 0 ? lastRunMs + task.every_ms : 0;
505
+ if (dueAt <= nowMs) {
132
506
  logger.info(
133
507
  { taskId: task.id, everyMs: task.every_ms, prompt: task.prompt },
134
508
  'Interval task firing',
135
509
  );
136
510
  updateTaskLastRun(task.id);
137
- const prompt = wrapCronPrompt(task.id, task.prompt, task.prompt);
138
- taskRunner!(task.session_id, task.channel_id, prompt, task.id).catch((err) => {
139
- logger.error({ taskId: task.id, err }, 'Interval task failed');
140
- });
511
+ dispatchDbTask(task)
512
+ .then(() => {
513
+ markTaskSuccess(task.id);
514
+ })
515
+ .catch((err) => {
516
+ const failure = markTaskFailure(
517
+ task.id,
518
+ MAX_CONSECUTIVE_FAILURES,
519
+ );
520
+ logger.error({ taskId: task.id, err }, 'Interval task failed');
521
+ if (failure.disabled) {
522
+ logger.warn(
523
+ {
524
+ taskId: task.id,
525
+ consecutiveErrors: failure.consecutiveErrors,
526
+ },
527
+ 'Scheduled task auto-disabled after repeated failures',
528
+ );
529
+ }
530
+ });
141
531
  }
142
532
  continue;
143
533
  }
144
534
 
145
- // --- Recurring cron task ---
146
535
  if (!task.cron_expr) continue;
147
-
148
536
  const cron = CronExpressionParser.parse(task.cron_expr);
149
537
  const prev = cron.prev();
150
538
  const lastRun = task.last_run ? new Date(task.last_run) : new Date(0);
@@ -155,21 +543,257 @@ async function tick(): Promise<void> {
155
543
  'Cron task firing',
156
544
  );
157
545
  updateTaskLastRun(task.id);
158
- const prompt = wrapCronPrompt(task.id, task.prompt, task.prompt);
159
- taskRunner!(task.session_id, task.channel_id, prompt, task.id).catch((err) => {
160
- logger.error({ taskId: task.id, err }, 'Cron task failed');
161
- });
546
+ dispatchDbTask(task)
547
+ .then(() => {
548
+ markTaskSuccess(task.id);
549
+ })
550
+ .catch((err) => {
551
+ const failure = markTaskFailure(
552
+ task.id,
553
+ MAX_CONSECUTIVE_FAILURES,
554
+ );
555
+ logger.error({ taskId: task.id, err }, 'Cron task failed');
556
+ if (failure.disabled) {
557
+ logger.warn(
558
+ {
559
+ taskId: task.id,
560
+ consecutiveErrors: failure.consecutiveErrors,
561
+ },
562
+ 'Scheduled task auto-disabled after repeated failures',
563
+ );
564
+ }
565
+ });
566
+ }
567
+ } catch (err) {
568
+ logger.error(
569
+ { taskId: task.id, cron: task.cron_expr, err },
570
+ 'Scheduler error for DB task',
571
+ );
572
+ }
573
+ }
574
+
575
+ for (const job of cfgJobs) {
576
+ if (!job.enabled) continue;
577
+ const meta = getConfigJobMeta(job.id);
578
+ if (meta.disabled) continue;
579
+ const jobLabel = resolveConfigJobLabel(job);
580
+
581
+ try {
582
+ if (job.schedule.kind === 'at') {
583
+ if (meta.oneShotCompleted || !job.schedule.at) continue;
584
+ const runAtMs = new Date(job.schedule.at).getTime();
585
+ if (!Number.isFinite(runAtMs) || runAtMs > nowMs) continue;
586
+ const lastRunMs = meta.lastRun ? new Date(meta.lastRun).getTime() : 0;
587
+ if (lastRunMs > 0 && nowMs - lastRunMs < CONFIG_ONESHOT_RETRY_MS)
588
+ continue;
589
+ meta.lastRun = now.toISOString();
590
+ persistSchedulerState();
591
+ logger.info(
592
+ { jobId: job.id, jobLabel, runAt: job.schedule.at },
593
+ 'Config one-shot job firing',
594
+ );
595
+ dispatchConfigJob(job)
596
+ .then(() => {
597
+ markConfigJobSuccess(job, true);
598
+ })
599
+ .catch((err) => {
600
+ const failure = markConfigJobFailure(job);
601
+ logger.error(
602
+ { jobId: job.id, jobLabel, err },
603
+ 'Config one-shot job failed',
604
+ );
605
+ if (failure.disabled) {
606
+ logger.warn(
607
+ {
608
+ jobId: job.id,
609
+ jobLabel,
610
+ consecutiveErrors: failure.consecutiveErrors,
611
+ },
612
+ 'Config scheduler job auto-disabled after repeated failures',
613
+ );
614
+ }
615
+ });
616
+ continue;
617
+ }
618
+
619
+ if (job.schedule.kind === 'every') {
620
+ const everyMs = job.schedule.everyMs;
621
+ if (!everyMs) continue;
622
+ const lastRunMs = meta.lastRun ? new Date(meta.lastRun).getTime() : 0;
623
+ const dueAt = lastRunMs > 0 ? lastRunMs + everyMs : 0;
624
+ if (dueAt > nowMs) continue;
625
+ meta.lastRun = now.toISOString();
626
+ persistSchedulerState();
627
+ logger.info(
628
+ { jobId: job.id, jobLabel, everyMs },
629
+ 'Config interval job firing',
630
+ );
631
+ dispatchConfigJob(job)
632
+ .then(() => {
633
+ markConfigJobSuccess(job, false);
634
+ })
635
+ .catch((err) => {
636
+ const failure = markConfigJobFailure(job);
637
+ logger.error(
638
+ { jobId: job.id, jobLabel, err },
639
+ 'Config interval job failed',
640
+ );
641
+ if (failure.disabled) {
642
+ logger.warn(
643
+ {
644
+ jobId: job.id,
645
+ jobLabel,
646
+ consecutiveErrors: failure.consecutiveErrors,
647
+ },
648
+ 'Config scheduler job auto-disabled after repeated failures',
649
+ );
650
+ }
651
+ });
652
+ continue;
162
653
  }
654
+
655
+ if (!job.schedule.expr) continue;
656
+ const cron = parseCronExpression(
657
+ job.schedule.expr,
658
+ job.schedule.tz || undefined,
659
+ );
660
+ const prev = cron.prev().toDate();
661
+ const lastRun = meta.lastRun ? new Date(meta.lastRun) : new Date(0);
662
+ if (prev <= lastRun) continue;
663
+
664
+ meta.lastRun = now.toISOString();
665
+ persistSchedulerState();
666
+ logger.info(
667
+ {
668
+ jobId: job.id,
669
+ jobLabel,
670
+ expr: job.schedule.expr,
671
+ tz: job.schedule.tz,
672
+ },
673
+ 'Config cron job firing',
674
+ );
675
+ dispatchConfigJob(job)
676
+ .then(() => {
677
+ markConfigJobSuccess(job, false);
678
+ })
679
+ .catch((err) => {
680
+ const failure = markConfigJobFailure(job);
681
+ logger.error(
682
+ { jobId: job.id, jobLabel, err },
683
+ 'Config cron job failed',
684
+ );
685
+ if (failure.disabled) {
686
+ logger.warn(
687
+ {
688
+ jobId: job.id,
689
+ jobLabel,
690
+ consecutiveErrors: failure.consecutiveErrors,
691
+ },
692
+ 'Config scheduler job auto-disabled after repeated failures',
693
+ );
694
+ }
695
+ });
163
696
  } catch (err) {
164
- logger.error({ taskId: task.id, cron: task.cron_expr, err }, 'Scheduler error for task');
697
+ logger.error(
698
+ { jobId: job.id, jobLabel, err },
699
+ 'Scheduler error for config job',
700
+ );
165
701
  }
166
702
  }
167
703
  } finally {
168
704
  ticking = false;
169
- arm(); // re-arm for next task
705
+ arm();
170
706
  }
171
707
  }
172
708
 
709
+ function toRuntimeState(meta: ConfigJobMeta): ConfigJobRuntimeState {
710
+ return {
711
+ lastRun: meta.lastRun,
712
+ lastStatus: meta.lastStatus,
713
+ nextRunAt: meta.nextRunAt,
714
+ disabled: meta.disabled,
715
+ consecutiveErrors: meta.consecutiveErrors,
716
+ };
717
+ }
718
+
719
+ export function getConfigJobState(jobId: string): ConfigJobRuntimeState | null {
720
+ const normalizedJobId = jobId.trim();
721
+ if (!normalizedJobId) return null;
722
+ const jobs = getConfigSnapshot().scheduler.jobs;
723
+ pruneConfigJobMeta(jobs);
724
+ const job = jobs.find((candidate) => candidate.id === normalizedJobId);
725
+ if (!job) return null;
726
+ if (syncConfigJobNextRunAt(job, Date.now())) {
727
+ persistSchedulerState();
728
+ }
729
+ return toRuntimeState(getConfigJobMeta(normalizedJobId));
730
+ }
731
+
732
+ export function getSchedulerStatus(): SchedulerStatusJob[] {
733
+ const jobs = getConfigSnapshot().scheduler.jobs;
734
+ pruneConfigJobMeta(jobs);
735
+ if (syncConfigJobsNextRunAt(jobs, Date.now())) {
736
+ persistSchedulerState();
737
+ }
738
+ return jobs.map((job) => {
739
+ const meta = getConfigJobMeta(job.id);
740
+ const description =
741
+ typeof job.description === 'string' && job.description.trim()
742
+ ? job.description.trim()
743
+ : null;
744
+ return {
745
+ id: job.id,
746
+ name: resolveConfigJobLabel(job),
747
+ description,
748
+ enabled: job.enabled,
749
+ ...toRuntimeState(meta),
750
+ };
751
+ });
752
+ }
753
+
754
+ export function pauseConfigJob(jobId: string): boolean {
755
+ const normalizedJobId = jobId.trim();
756
+ if (!normalizedJobId) return false;
757
+ const jobs = getConfigSnapshot().scheduler.jobs;
758
+ pruneConfigJobMeta(jobs);
759
+ const job = jobs.find((candidate) => candidate.id === normalizedJobId);
760
+ if (!job) return false;
761
+
762
+ const meta = getConfigJobMeta(normalizedJobId);
763
+ meta.disabled = true;
764
+ meta.nextRunAt = null;
765
+ persistSchedulerState();
766
+ rearmScheduler();
767
+
768
+ logger.info(
769
+ { jobId: normalizedJobId, jobLabel: resolveConfigJobLabel(job) },
770
+ 'Config scheduler job paused',
771
+ );
772
+ return true;
773
+ }
774
+
775
+ export function resumeConfigJob(jobId: string): boolean {
776
+ const normalizedJobId = jobId.trim();
777
+ if (!normalizedJobId) return false;
778
+ const jobs = getConfigSnapshot().scheduler.jobs;
779
+ pruneConfigJobMeta(jobs);
780
+ const job = jobs.find((candidate) => candidate.id === normalizedJobId);
781
+ if (!job) return false;
782
+
783
+ const meta = getConfigJobMeta(normalizedJobId);
784
+ meta.disabled = false;
785
+ meta.consecutiveErrors = 0;
786
+ syncConfigJobNextRunAt(job, Date.now());
787
+ persistSchedulerState();
788
+ rearmScheduler();
789
+
790
+ logger.info(
791
+ { jobId: normalizedJobId, jobLabel: resolveConfigJobLabel(job) },
792
+ 'Config scheduler job resumed',
793
+ );
794
+ return true;
795
+ }
796
+
173
797
  // --- Public API ---
174
798
 
175
799
  export function startScheduler(runner: TaskRunner): void {
@@ -179,8 +803,7 @@ export function startScheduler(runner: TaskRunner): void {
179
803
  }
180
804
 
181
805
  /**
182
- * Re-arm the scheduler timer. Call after creating or deleting tasks
183
- * so newly scheduled work is picked up immediately.
806
+ * Re-arm the scheduler timer. Call after creating/deleting tasks or updating config scheduler jobs.
184
807
  */
185
808
  export function rearmScheduler(): void {
186
809
  if (taskRunner) arm();