@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/dist/scheduler.js CHANGED
@@ -1,18 +1,24 @@
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
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
8
  import { CronExpressionParser } from 'cron-parser';
9
- import { deleteTask, getAllEnabledTasks, updateTaskLastRun } from './db.js';
9
+ import { DATA_DIR, getConfigSnapshot } from './config.js';
10
+ import { deleteTask, getAllEnabledTasks, markTaskFailure, markTaskSuccess, updateTaskLastRun, } from './db.js';
10
11
  import { logger } from './logger.js';
11
12
  const MAX_TIMER_DELAY_MS = 300_000; // 5 min safety net for clock drift
13
+ const MAX_CONSECUTIVE_FAILURES = 5;
14
+ const CONFIG_ONESHOT_RETRY_MS = 60_000;
15
+ const SCHEDULER_STATE_VERSION = 1;
16
+ const SCHEDULER_STATE_PATH = path.join(DATA_DIR, 'scheduler-jobs-state.json');
12
17
  let timer = null;
13
18
  let taskRunner = null;
14
19
  let ticking = false;
15
- // --- Prompt framing (OpenClaw style) ---
20
+ const schedulerState = loadSchedulerState();
21
+ // --- Prompt framing ---
16
22
  function formatFireTime() {
17
23
  return new Date().toLocaleString('en-US', {
18
24
  weekday: 'short',
@@ -23,38 +29,236 @@ function formatFireTime() {
23
29
  timeZoneName: 'short',
24
30
  });
25
31
  }
26
- export function wrapCronPrompt(taskId, taskName, message) {
32
+ export function wrapCronPrompt(jobLabel, message) {
27
33
  const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
28
- 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.`;
34
+ 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.`;
29
35
  }
30
- // --- Timer logic ---
31
- function computeNextFireMs() {
32
- const tasks = getAllEnabledTasks();
33
- let earliest = null;
34
- for (const task of tasks) {
35
- if (task.run_at) {
36
- if (!task.last_run) {
37
- const ms = new Date(task.run_at).getTime();
38
- if (earliest === null || ms < earliest)
39
- earliest = ms;
40
- }
41
- continue;
36
+ function defaultConfigJobMeta() {
37
+ return {
38
+ lastRun: null,
39
+ lastStatus: null,
40
+ nextRunAt: null,
41
+ consecutiveErrors: 0,
42
+ disabled: false,
43
+ oneShotCompleted: false,
44
+ };
45
+ }
46
+ function isRecord(value) {
47
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
48
+ }
49
+ function normalizeConfigJobMeta(value) {
50
+ if (!isRecord(value))
51
+ return defaultConfigJobMeta();
52
+ const lastRun = typeof value.lastRun === 'string' && value.lastRun.trim()
53
+ ? value.lastRun.trim()
54
+ : null;
55
+ const lastStatus = value.lastStatus === 'success' || value.lastStatus === 'error'
56
+ ? value.lastStatus
57
+ : null;
58
+ const nextRunAt = typeof value.nextRunAt === 'string' && value.nextRunAt.trim()
59
+ ? value.nextRunAt.trim()
60
+ : null;
61
+ const consecutiveErrors = typeof value.consecutiveErrors === 'number' &&
62
+ Number.isFinite(value.consecutiveErrors)
63
+ ? Math.max(0, Math.floor(value.consecutiveErrors))
64
+ : 0;
65
+ return {
66
+ lastRun,
67
+ lastStatus,
68
+ nextRunAt,
69
+ consecutiveErrors,
70
+ disabled: Boolean(value.disabled),
71
+ oneShotCompleted: Boolean(value.oneShotCompleted),
72
+ };
73
+ }
74
+ function loadSchedulerState() {
75
+ try {
76
+ if (!fs.existsSync(SCHEDULER_STATE_PATH)) {
77
+ return {
78
+ version: SCHEDULER_STATE_VERSION,
79
+ updatedAt: new Date(0).toISOString(),
80
+ configJobs: {},
81
+ };
42
82
  }
43
- if (task.every_ms) {
44
- const lastRunMs = task.last_run ? new Date(task.last_run).getTime() : 0;
45
- const nextMs = lastRunMs > 0 ? lastRunMs + task.every_ms : Date.now();
46
- if (earliest === null || nextMs < earliest)
47
- earliest = nextMs;
48
- continue;
83
+ const raw = fs.readFileSync(SCHEDULER_STATE_PATH, 'utf-8');
84
+ const parsed = JSON.parse(raw);
85
+ if (!isRecord(parsed))
86
+ throw new Error('state file root must be object');
87
+ const rawJobs = isRecord(parsed.configJobs) ? parsed.configJobs : {};
88
+ const configJobs = {};
89
+ for (const [id, meta] of Object.entries(rawJobs)) {
90
+ const key = id.trim();
91
+ if (!key)
92
+ continue;
93
+ configJobs[key] = normalizeConfigJobMeta(meta);
49
94
  }
50
- if (!task.cron_expr)
95
+ return {
96
+ version: SCHEDULER_STATE_VERSION,
97
+ updatedAt: typeof parsed.updatedAt === 'string' && parsed.updatedAt.trim()
98
+ ? parsed.updatedAt
99
+ : new Date(0).toISOString(),
100
+ configJobs,
101
+ };
102
+ }
103
+ catch (error) {
104
+ logger.warn({ error }, 'Failed to load scheduler state file; starting with defaults');
105
+ return {
106
+ version: SCHEDULER_STATE_VERSION,
107
+ updatedAt: new Date(0).toISOString(),
108
+ configJobs: {},
109
+ };
110
+ }
111
+ }
112
+ function persistSchedulerState() {
113
+ try {
114
+ fs.mkdirSync(path.dirname(SCHEDULER_STATE_PATH), { recursive: true });
115
+ schedulerState.updatedAt = new Date().toISOString();
116
+ const payload = `${JSON.stringify(schedulerState, null, 2)}\n`;
117
+ const tmpPath = `${SCHEDULER_STATE_PATH}.tmp-${process.pid}-${Date.now()}`;
118
+ fs.writeFileSync(tmpPath, payload, 'utf-8');
119
+ fs.renameSync(tmpPath, SCHEDULER_STATE_PATH);
120
+ }
121
+ catch (error) {
122
+ logger.warn({ error }, 'Failed to persist scheduler state file');
123
+ }
124
+ }
125
+ function getConfigJobMeta(jobId) {
126
+ const existing = schedulerState.configJobs[jobId];
127
+ if (existing)
128
+ return existing;
129
+ const created = defaultConfigJobMeta();
130
+ schedulerState.configJobs[jobId] = created;
131
+ return created;
132
+ }
133
+ function pruneConfigJobMeta(activeJobs) {
134
+ const activeIds = new Set(activeJobs.map((job) => job.id));
135
+ let changed = false;
136
+ for (const id of Object.keys(schedulerState.configJobs)) {
137
+ if (activeIds.has(id))
51
138
  continue;
52
- try {
53
- const ms = CronExpressionParser.parse(task.cron_expr).next().toDate().getTime();
54
- if (earliest === null || ms < earliest)
55
- earliest = ms;
56
- }
57
- catch { /* skip invalid */ }
139
+ delete schedulerState.configJobs[id];
140
+ changed = true;
141
+ }
142
+ if (changed)
143
+ persistSchedulerState();
144
+ }
145
+ function resolveConfigJobLabel(job) {
146
+ const candidate = typeof job.name === 'string' ? job.name.trim() : '';
147
+ return candidate || job.id;
148
+ }
149
+ function parseCronExpression(expr, tz) {
150
+ const trimmedTz = tz?.trim();
151
+ if (trimmedTz) {
152
+ return CronExpressionParser.parse(expr, { tz: trimmedTz });
153
+ }
154
+ return CronExpressionParser.parse(expr);
155
+ }
156
+ function nextFireMsForDbTask(task, nowMs) {
157
+ if (task.run_at) {
158
+ if (task.last_run)
159
+ return null;
160
+ const ms = new Date(task.run_at).getTime();
161
+ return Number.isFinite(ms) ? ms : null;
162
+ }
163
+ if (task.every_ms) {
164
+ const lastRunMs = task.last_run ? new Date(task.last_run).getTime() : 0;
165
+ return lastRunMs > 0 ? lastRunMs + task.every_ms : nowMs;
166
+ }
167
+ if (!task.cron_expr)
168
+ return null;
169
+ try {
170
+ const ms = CronExpressionParser.parse(task.cron_expr)
171
+ .next()
172
+ .toDate()
173
+ .getTime();
174
+ return Number.isFinite(ms) ? ms : null;
175
+ }
176
+ catch {
177
+ return null;
178
+ }
179
+ }
180
+ function toIsoTimestamp(ms) {
181
+ if (ms == null || !Number.isFinite(ms))
182
+ return null;
183
+ return new Date(ms).toISOString();
184
+ }
185
+ function syncConfigJobNextRunAt(job, nowMs) {
186
+ const meta = getConfigJobMeta(job.id);
187
+ const nextRunAt = toIsoTimestamp(nextFireMsForConfigJob(job, nowMs));
188
+ if (meta.nextRunAt === nextRunAt)
189
+ return false;
190
+ meta.nextRunAt = nextRunAt;
191
+ return true;
192
+ }
193
+ function syncConfigJobsNextRunAt(jobs, nowMs) {
194
+ let changed = false;
195
+ for (const job of jobs) {
196
+ if (syncConfigJobNextRunAt(job, nowMs))
197
+ changed = true;
198
+ }
199
+ return changed;
200
+ }
201
+ function nextFireMsForConfigJob(job, nowMs) {
202
+ if (!job.enabled)
203
+ return null;
204
+ const meta = getConfigJobMeta(job.id);
205
+ if (meta.disabled)
206
+ return null;
207
+ if (job.schedule.kind === 'at') {
208
+ if (meta.oneShotCompleted)
209
+ return null;
210
+ if (!job.schedule.at)
211
+ return null;
212
+ const atMs = new Date(job.schedule.at).getTime();
213
+ if (!Number.isFinite(atMs))
214
+ return null;
215
+ const lastRunMs = meta.lastRun ? new Date(meta.lastRun).getTime() : 0;
216
+ if (atMs > nowMs)
217
+ return atMs;
218
+ if (lastRunMs <= 0)
219
+ return atMs;
220
+ return lastRunMs + CONFIG_ONESHOT_RETRY_MS;
221
+ }
222
+ if (job.schedule.kind === 'every') {
223
+ if (!job.schedule.everyMs)
224
+ return null;
225
+ const lastRunMs = meta.lastRun ? new Date(meta.lastRun).getTime() : 0;
226
+ return lastRunMs > 0 ? lastRunMs + job.schedule.everyMs : nowMs;
227
+ }
228
+ if (!job.schedule.expr)
229
+ return null;
230
+ try {
231
+ const ms = parseCronExpression(job.schedule.expr, job.schedule.tz || undefined)
232
+ .next()
233
+ .toDate()
234
+ .getTime();
235
+ return Number.isFinite(ms) ? ms : null;
236
+ }
237
+ catch {
238
+ return null;
239
+ }
240
+ }
241
+ function computeNextFireMs(nowMs = Date.now()) {
242
+ const dbTasks = getAllEnabledTasks();
243
+ const cfgJobs = getConfigSnapshot().scheduler.jobs;
244
+ pruneConfigJobMeta(cfgJobs);
245
+ if (syncConfigJobsNextRunAt(cfgJobs, nowMs)) {
246
+ persistSchedulerState();
247
+ }
248
+ let earliest = null;
249
+ for (const task of dbTasks) {
250
+ const fireMs = nextFireMsForDbTask(task, nowMs);
251
+ if (fireMs === null)
252
+ continue;
253
+ if (earliest === null || fireMs < earliest)
254
+ earliest = fireMs;
255
+ }
256
+ for (const job of cfgJobs) {
257
+ const fireMs = nextFireMsForConfigJob(job, nowMs);
258
+ if (fireMs === null)
259
+ continue;
260
+ if (earliest === null || fireMs < earliest)
261
+ earliest = fireMs;
58
262
  }
59
263
  return earliest;
60
264
  }
@@ -64,58 +268,141 @@ function arm() {
64
268
  timer = null;
65
269
  const nextFireMs = computeNextFireMs();
66
270
  if (nextFireMs === null)
67
- return; // nothing scheduled
271
+ return;
68
272
  const delay = Math.max(nextFireMs - Date.now(), 0);
69
273
  const clamped = Math.min(delay, MAX_TIMER_DELAY_MS);
70
274
  logger.debug({ delayMs: clamped, nextFire: new Date(nextFireMs).toISOString() }, 'Scheduler armed');
71
275
  timer = setTimeout(() => {
72
276
  void tick().catch((err) => {
73
277
  logger.error({ err }, 'Scheduler tick failed');
74
- arm(); // re-arm even on error
278
+ arm();
75
279
  });
76
280
  }, clamped);
77
281
  }
282
+ async function dispatchDbTask(task) {
283
+ if (!taskRunner)
284
+ return;
285
+ const prompt = wrapCronPrompt(`#${task.id}`, task.prompt);
286
+ await taskRunner({
287
+ source: 'db-task',
288
+ taskId: task.id,
289
+ sessionId: task.session_id,
290
+ channelId: task.channel_id,
291
+ prompt,
292
+ actionKind: 'agent_turn',
293
+ delivery: {
294
+ kind: 'channel',
295
+ channelId: task.channel_id,
296
+ },
297
+ });
298
+ }
299
+ async function dispatchConfigJob(job) {
300
+ if (!taskRunner)
301
+ return;
302
+ const jobLabel = resolveConfigJobLabel(job);
303
+ const contextChannelId = job.delivery.kind === 'channel' ? job.delivery.to : 'scheduler';
304
+ const prompt = job.action.kind === 'agent_turn'
305
+ ? wrapCronPrompt(jobLabel, job.action.message)
306
+ : job.action.message;
307
+ await taskRunner({
308
+ source: 'config-job',
309
+ jobId: job.id,
310
+ sessionId: `scheduler:${job.id}`,
311
+ channelId: contextChannelId,
312
+ prompt,
313
+ actionKind: job.action.kind,
314
+ delivery: job.delivery.kind === 'channel'
315
+ ? { kind: 'channel', channelId: job.delivery.to }
316
+ : job.delivery.kind === 'last-channel'
317
+ ? { kind: 'last-channel' }
318
+ : { kind: 'webhook', webhookUrl: job.delivery.webhookUrl },
319
+ });
320
+ }
321
+ function markConfigJobSuccess(job, markOneShotDone = false) {
322
+ const meta = getConfigJobMeta(job.id);
323
+ meta.lastStatus = 'success';
324
+ meta.consecutiveErrors = 0;
325
+ if (markOneShotDone)
326
+ meta.oneShotCompleted = true;
327
+ syncConfigJobNextRunAt(job, Date.now());
328
+ persistSchedulerState();
329
+ }
330
+ function markConfigJobFailure(job) {
331
+ const meta = getConfigJobMeta(job.id);
332
+ meta.lastStatus = 'error';
333
+ meta.consecutiveErrors = Math.max(0, meta.consecutiveErrors) + 1;
334
+ if (meta.consecutiveErrors >= MAX_CONSECUTIVE_FAILURES) {
335
+ meta.disabled = true;
336
+ }
337
+ syncConfigJobNextRunAt(job, Date.now());
338
+ persistSchedulerState();
339
+ return {
340
+ disabled: meta.disabled,
341
+ consecutiveErrors: meta.consecutiveErrors,
342
+ };
343
+ }
78
344
  async function tick() {
79
345
  if (ticking) {
80
- arm(); // re-check later
346
+ arm();
81
347
  return;
82
348
  }
83
349
  ticking = true;
84
350
  try {
85
- const tasks = getAllEnabledTasks();
351
+ const dbTasks = getAllEnabledTasks();
352
+ const cfgJobs = getConfigSnapshot().scheduler.jobs;
353
+ pruneConfigJobMeta(cfgJobs);
86
354
  const now = new Date();
87
- for (const task of tasks) {
355
+ const nowMs = now.getTime();
356
+ for (const task of dbTasks) {
88
357
  try {
89
- // --- One-shot task ---
90
358
  if (task.run_at) {
91
359
  const runAt = new Date(task.run_at);
92
- if (runAt.getTime() <= now.getTime() && !task.last_run) {
360
+ if (runAt.getTime() <= nowMs && !task.last_run) {
93
361
  logger.info({ taskId: task.id, runAt: task.run_at, prompt: task.prompt }, 'One-shot task firing');
94
- updateTaskLastRun(task.id); // prevents re-fire
95
- const prompt = wrapCronPrompt(task.id, task.prompt, task.prompt);
96
- taskRunner(task.session_id, task.channel_id, prompt, task.id)
97
- .then(() => deleteTask(task.id)) // cleanup on success
362
+ updateTaskLastRun(task.id);
363
+ dispatchDbTask(task)
364
+ .then(() => {
365
+ markTaskSuccess(task.id);
366
+ deleteTask(task.id);
367
+ })
98
368
  .catch((err) => {
369
+ const failure = markTaskFailure(task.id, MAX_CONSECUTIVE_FAILURES);
99
370
  logger.error({ taskId: task.id, err }, 'One-shot task failed (task preserved)');
371
+ if (failure.disabled) {
372
+ logger.warn({
373
+ taskId: task.id,
374
+ consecutiveErrors: failure.consecutiveErrors,
375
+ }, 'Scheduled task auto-disabled after repeated failures');
376
+ }
100
377
  });
101
378
  }
102
379
  continue;
103
380
  }
104
- // --- Interval task ---
105
381
  if (task.every_ms) {
106
- const lastRunMs = task.last_run ? new Date(task.last_run).getTime() : 0;
107
- const dueAt = lastRunMs > 0 ? lastRunMs + task.every_ms : 0; // fire immediately if never run
108
- if (dueAt <= now.getTime()) {
382
+ const lastRunMs = task.last_run
383
+ ? new Date(task.last_run).getTime()
384
+ : 0;
385
+ const dueAt = lastRunMs > 0 ? lastRunMs + task.every_ms : 0;
386
+ if (dueAt <= nowMs) {
109
387
  logger.info({ taskId: task.id, everyMs: task.every_ms, prompt: task.prompt }, 'Interval task firing');
110
388
  updateTaskLastRun(task.id);
111
- const prompt = wrapCronPrompt(task.id, task.prompt, task.prompt);
112
- taskRunner(task.session_id, task.channel_id, prompt, task.id).catch((err) => {
389
+ dispatchDbTask(task)
390
+ .then(() => {
391
+ markTaskSuccess(task.id);
392
+ })
393
+ .catch((err) => {
394
+ const failure = markTaskFailure(task.id, MAX_CONSECUTIVE_FAILURES);
113
395
  logger.error({ taskId: task.id, err }, 'Interval task failed');
396
+ if (failure.disabled) {
397
+ logger.warn({
398
+ taskId: task.id,
399
+ consecutiveErrors: failure.consecutiveErrors,
400
+ }, 'Scheduled task auto-disabled after repeated failures');
401
+ }
114
402
  });
115
403
  }
116
404
  continue;
117
405
  }
118
- // --- Recurring cron task ---
119
406
  if (!task.cron_expr)
120
407
  continue;
121
408
  const cron = CronExpressionParser.parse(task.cron_expr);
@@ -124,22 +411,210 @@ async function tick() {
124
411
  if (prev.toDate() > lastRun) {
125
412
  logger.info({ taskId: task.id, cron: task.cron_expr, prompt: task.prompt }, 'Cron task firing');
126
413
  updateTaskLastRun(task.id);
127
- const prompt = wrapCronPrompt(task.id, task.prompt, task.prompt);
128
- taskRunner(task.session_id, task.channel_id, prompt, task.id).catch((err) => {
414
+ dispatchDbTask(task)
415
+ .then(() => {
416
+ markTaskSuccess(task.id);
417
+ })
418
+ .catch((err) => {
419
+ const failure = markTaskFailure(task.id, MAX_CONSECUTIVE_FAILURES);
129
420
  logger.error({ taskId: task.id, err }, 'Cron task failed');
421
+ if (failure.disabled) {
422
+ logger.warn({
423
+ taskId: task.id,
424
+ consecutiveErrors: failure.consecutiveErrors,
425
+ }, 'Scheduled task auto-disabled after repeated failures');
426
+ }
130
427
  });
131
428
  }
132
429
  }
133
430
  catch (err) {
134
- logger.error({ taskId: task.id, cron: task.cron_expr, err }, 'Scheduler error for task');
431
+ logger.error({ taskId: task.id, cron: task.cron_expr, err }, 'Scheduler error for DB task');
432
+ }
433
+ }
434
+ for (const job of cfgJobs) {
435
+ if (!job.enabled)
436
+ continue;
437
+ const meta = getConfigJobMeta(job.id);
438
+ if (meta.disabled)
439
+ continue;
440
+ const jobLabel = resolveConfigJobLabel(job);
441
+ try {
442
+ if (job.schedule.kind === 'at') {
443
+ if (meta.oneShotCompleted || !job.schedule.at)
444
+ continue;
445
+ const runAtMs = new Date(job.schedule.at).getTime();
446
+ if (!Number.isFinite(runAtMs) || runAtMs > nowMs)
447
+ continue;
448
+ const lastRunMs = meta.lastRun ? new Date(meta.lastRun).getTime() : 0;
449
+ if (lastRunMs > 0 && nowMs - lastRunMs < CONFIG_ONESHOT_RETRY_MS)
450
+ continue;
451
+ meta.lastRun = now.toISOString();
452
+ persistSchedulerState();
453
+ logger.info({ jobId: job.id, jobLabel, runAt: job.schedule.at }, 'Config one-shot job firing');
454
+ dispatchConfigJob(job)
455
+ .then(() => {
456
+ markConfigJobSuccess(job, true);
457
+ })
458
+ .catch((err) => {
459
+ const failure = markConfigJobFailure(job);
460
+ logger.error({ jobId: job.id, jobLabel, err }, 'Config one-shot job failed');
461
+ if (failure.disabled) {
462
+ logger.warn({
463
+ jobId: job.id,
464
+ jobLabel,
465
+ consecutiveErrors: failure.consecutiveErrors,
466
+ }, 'Config scheduler job auto-disabled after repeated failures');
467
+ }
468
+ });
469
+ continue;
470
+ }
471
+ if (job.schedule.kind === 'every') {
472
+ const everyMs = job.schedule.everyMs;
473
+ if (!everyMs)
474
+ continue;
475
+ const lastRunMs = meta.lastRun ? new Date(meta.lastRun).getTime() : 0;
476
+ const dueAt = lastRunMs > 0 ? lastRunMs + everyMs : 0;
477
+ if (dueAt > nowMs)
478
+ continue;
479
+ meta.lastRun = now.toISOString();
480
+ persistSchedulerState();
481
+ logger.info({ jobId: job.id, jobLabel, everyMs }, 'Config interval job firing');
482
+ dispatchConfigJob(job)
483
+ .then(() => {
484
+ markConfigJobSuccess(job, false);
485
+ })
486
+ .catch((err) => {
487
+ const failure = markConfigJobFailure(job);
488
+ logger.error({ jobId: job.id, jobLabel, err }, 'Config interval job failed');
489
+ if (failure.disabled) {
490
+ logger.warn({
491
+ jobId: job.id,
492
+ jobLabel,
493
+ consecutiveErrors: failure.consecutiveErrors,
494
+ }, 'Config scheduler job auto-disabled after repeated failures');
495
+ }
496
+ });
497
+ continue;
498
+ }
499
+ if (!job.schedule.expr)
500
+ continue;
501
+ const cron = parseCronExpression(job.schedule.expr, job.schedule.tz || undefined);
502
+ const prev = cron.prev().toDate();
503
+ const lastRun = meta.lastRun ? new Date(meta.lastRun) : new Date(0);
504
+ if (prev <= lastRun)
505
+ continue;
506
+ meta.lastRun = now.toISOString();
507
+ persistSchedulerState();
508
+ logger.info({
509
+ jobId: job.id,
510
+ jobLabel,
511
+ expr: job.schedule.expr,
512
+ tz: job.schedule.tz,
513
+ }, 'Config cron job firing');
514
+ dispatchConfigJob(job)
515
+ .then(() => {
516
+ markConfigJobSuccess(job, false);
517
+ })
518
+ .catch((err) => {
519
+ const failure = markConfigJobFailure(job);
520
+ logger.error({ jobId: job.id, jobLabel, err }, 'Config cron job failed');
521
+ if (failure.disabled) {
522
+ logger.warn({
523
+ jobId: job.id,
524
+ jobLabel,
525
+ consecutiveErrors: failure.consecutiveErrors,
526
+ }, 'Config scheduler job auto-disabled after repeated failures');
527
+ }
528
+ });
529
+ }
530
+ catch (err) {
531
+ logger.error({ jobId: job.id, jobLabel, err }, 'Scheduler error for config job');
135
532
  }
136
533
  }
137
534
  }
138
535
  finally {
139
536
  ticking = false;
140
- arm(); // re-arm for next task
537
+ arm();
141
538
  }
142
539
  }
540
+ function toRuntimeState(meta) {
541
+ return {
542
+ lastRun: meta.lastRun,
543
+ lastStatus: meta.lastStatus,
544
+ nextRunAt: meta.nextRunAt,
545
+ disabled: meta.disabled,
546
+ consecutiveErrors: meta.consecutiveErrors,
547
+ };
548
+ }
549
+ export function getConfigJobState(jobId) {
550
+ const normalizedJobId = jobId.trim();
551
+ if (!normalizedJobId)
552
+ return null;
553
+ const jobs = getConfigSnapshot().scheduler.jobs;
554
+ pruneConfigJobMeta(jobs);
555
+ const job = jobs.find((candidate) => candidate.id === normalizedJobId);
556
+ if (!job)
557
+ return null;
558
+ if (syncConfigJobNextRunAt(job, Date.now())) {
559
+ persistSchedulerState();
560
+ }
561
+ return toRuntimeState(getConfigJobMeta(normalizedJobId));
562
+ }
563
+ export function getSchedulerStatus() {
564
+ const jobs = getConfigSnapshot().scheduler.jobs;
565
+ pruneConfigJobMeta(jobs);
566
+ if (syncConfigJobsNextRunAt(jobs, Date.now())) {
567
+ persistSchedulerState();
568
+ }
569
+ return jobs.map((job) => {
570
+ const meta = getConfigJobMeta(job.id);
571
+ const description = typeof job.description === 'string' && job.description.trim()
572
+ ? job.description.trim()
573
+ : null;
574
+ return {
575
+ id: job.id,
576
+ name: resolveConfigJobLabel(job),
577
+ description,
578
+ enabled: job.enabled,
579
+ ...toRuntimeState(meta),
580
+ };
581
+ });
582
+ }
583
+ export function pauseConfigJob(jobId) {
584
+ const normalizedJobId = jobId.trim();
585
+ if (!normalizedJobId)
586
+ return false;
587
+ const jobs = getConfigSnapshot().scheduler.jobs;
588
+ pruneConfigJobMeta(jobs);
589
+ const job = jobs.find((candidate) => candidate.id === normalizedJobId);
590
+ if (!job)
591
+ return false;
592
+ const meta = getConfigJobMeta(normalizedJobId);
593
+ meta.disabled = true;
594
+ meta.nextRunAt = null;
595
+ persistSchedulerState();
596
+ rearmScheduler();
597
+ logger.info({ jobId: normalizedJobId, jobLabel: resolveConfigJobLabel(job) }, 'Config scheduler job paused');
598
+ return true;
599
+ }
600
+ export function resumeConfigJob(jobId) {
601
+ const normalizedJobId = jobId.trim();
602
+ if (!normalizedJobId)
603
+ return false;
604
+ const jobs = getConfigSnapshot().scheduler.jobs;
605
+ pruneConfigJobMeta(jobs);
606
+ const job = jobs.find((candidate) => candidate.id === normalizedJobId);
607
+ if (!job)
608
+ return false;
609
+ const meta = getConfigJobMeta(normalizedJobId);
610
+ meta.disabled = false;
611
+ meta.consecutiveErrors = 0;
612
+ syncConfigJobNextRunAt(job, Date.now());
613
+ persistSchedulerState();
614
+ rearmScheduler();
615
+ logger.info({ jobId: normalizedJobId, jobLabel: resolveConfigJobLabel(job) }, 'Config scheduler job resumed');
616
+ return true;
617
+ }
143
618
  // --- Public API ---
144
619
  export function startScheduler(runner) {
145
620
  logger.info('Scheduler started');
@@ -147,8 +622,7 @@ export function startScheduler(runner) {
147
622
  arm();
148
623
  }
149
624
  /**
150
- * Re-arm the scheduler timer. Call after creating or deleting tasks
151
- * so newly scheduled work is picked up immediately.
625
+ * Re-arm the scheduler timer. Call after creating/deleting tasks or updating config scheduler jobs.
152
626
  */
153
627
  export function rearmScheduler() {
154
628
  if (taskRunner)