foliko 2.0.6 → 2.0.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.
Files changed (53) hide show
  1. package/.claude/settings.local.json +2 -1
  2. package/docs/public-api.md +3 -3
  3. package/examples/workflow.js +62 -57
  4. package/package.json +2 -3
  5. package/plugins/core/scheduler/CHANGELOG.md +31 -0
  6. package/plugins/core/scheduler/index.js +576 -638
  7. package/plugins/core/workflow/context.js +941 -0
  8. package/plugins/core/workflow/engine.js +66 -0
  9. package/plugins/core/workflow/examples/01-basic.js +42 -0
  10. package/plugins/core/workflow/examples/01-basic.json +30 -0
  11. package/plugins/core/workflow/examples/02-choice.js +75 -0
  12. package/plugins/core/workflow/examples/02-choice.json +59 -0
  13. package/plugins/core/workflow/examples/03-chain-style.js +114 -0
  14. package/plugins/core/workflow/examples/03-each.json +41 -0
  15. package/plugins/core/workflow/examples/04-parallel.js +52 -0
  16. package/plugins/core/workflow/examples/04-parallel.json +51 -0
  17. package/plugins/core/workflow/examples/05-chain-style.json +68 -0
  18. package/plugins/core/workflow/examples/05-each.js +65 -0
  19. package/plugins/core/workflow/examples/06-script.js +82 -0
  20. package/plugins/core/workflow/examples/07-module-export.js +43 -0
  21. package/plugins/core/workflow/examples/08-default-export.js +29 -0
  22. package/plugins/core/workflow/examples/09-next-with-args.js +50 -0
  23. package/plugins/core/workflow/examples/10-logger.js +34 -0
  24. package/plugins/core/workflow/examples/11-storage.js +68 -0
  25. package/plugins/core/workflow/examples/simple.js +77 -0
  26. package/plugins/core/workflow/examples/simple.json +75 -0
  27. package/plugins/core/workflow/index.js +124 -58
  28. package/plugins/core/workflow/js-runner.js +318 -0
  29. package/plugins/core/workflow/json-runner.js +323 -0
  30. package/plugins/core/workflow/stages/action.js +211 -0
  31. package/plugins/core/workflow/stages/choice.js +74 -0
  32. package/plugins/core/workflow/stages/delay.js +73 -0
  33. package/plugins/core/workflow/stages/each.js +123 -0
  34. package/plugins/core/workflow/stages/parallel.js +69 -0
  35. package/plugins/core/workflow/stages/try.js +142 -0
  36. package/plugins/executors/data-splitter/index.js +3 -2
  37. package/plugins/memory/index.js +10 -3
  38. package/sandbox/check-context.js +5 -0
  39. package/sandbox/test-context.js +27 -0
  40. package/sandbox/test-fixes.js +40 -0
  41. package/sandbox/test-hello-js.js +46 -0
  42. package/skills/workflows/SKILL.md +715 -613
  43. package/src/agent/chat.js +8 -1
  44. package/src/agent/main.js +5 -1
  45. package/src/cli/ui/chat-ui.js +4 -3
  46. package/src/common/json-safe.js +20 -0
  47. package/src/framework/framework.js +58 -2
  48. package/src/index.js +10 -0
  49. package/src/plugin/base.js +7 -4
  50. package/src/plugin/manager.js +95 -1
  51. package/src/utils/sandbox.js +1 -1
  52. package/skills/workflows/workflow-troubleshooting/DEBUGGING.md +0 -197
  53. package/skills/workflows/workflow-troubleshooting/SKILL.md +0 -331
@@ -1,791 +1,729 @@
1
1
  /**
2
- * Scheduler 定时任务调度插件
3
- * 支持 Cron 表达式、绝对时间、相对时间调度
4
- * 任务触发时自动唤醒 Agent 发送消息
2
+ * Scheduler 定时任务调度插件 (v2)
3
+ *
4
+ * 基于 croner 重新实现,关键改进:
5
+ * - 用绝对时间戳校验,事件循环阻塞恢复后自动 catch-up(解决"漏触发")
6
+ * - 任务重叠保护(cron 短间隔 vs LLM 慢响应)
7
+ * - 原子化 + debounce 持久化(避免写盘半残、减少 IO)
8
+ * - 过期一次性任务恢复时立即补触发(catch-up 语义)
9
+ * - SubAgent 复用(避免长跑漏内存)
10
+ * - 统一 shell / 普通 任务模型(去除重复代码)
11
+ * - 删除死代码(_checkTasks / _taskStats / _startScheduler)
5
12
  */
6
13
 
7
- const { Plugin } = require('../../../src/plugin/base')
8
- const { logger } = require('../../../src/common/logger')
9
- const log = logger.child('Scheduler')
10
- const { z } = require('zod')
11
- const fs = require('fs')
12
- const path = require('path')
13
-
14
- let schedule = null
15
- try {
16
- schedule = require('node-schedule')
17
- } catch (e) {
18
- log.warn(' node-schedule not installed, cron tasks will not work')
19
- }
20
-
14
+ const { Plugin } = require('../../../src/plugin/base');
15
+ const { logger } = require('../../../src/common/logger');
16
+ const { Cron } = require('croner');
17
+ const { z } = require('zod');
18
+ const fs = require('fs');
19
+ const path = require('path');
20
+ const { randomUUID } = require('crypto');
21
+
22
+ const log = logger.child('Scheduler');
23
+
24
+ // 过期一次性任务的最大补触发容忍时间(24h);超过则直接丢弃
25
+ const ONCE_EXPIRY_GRACE_MS = 24 * 60 * 60 * 1000;
26
+ // 持久化 debounce 时间窗
27
+ const PERSIST_DEBOUNCE_MS = 500;
28
+
29
+ // 简单关键词检测:判断 reminder 消息是否需要 LLM 处理
30
+ const LLM_KEYWORDS = [
31
+ '分析', '查询', '查看', '检查', '总结', '搜索', '获取',
32
+ 'list', 'get', 'check', 'search', 'find', 'fetch',
33
+ '什么', '如何', '为什么', '什么时候', '多少', '谁',
34
+ '今天', '明天', '昨天', '这周', '这月', '今年',
35
+ ];
36
+
37
+ // ============================================================
38
+ // 持久化存储:原子写 + 加载
39
+ // ============================================================
21
40
  class TaskStore {
22
41
  constructor(persistencePath) {
23
- this._persistencePath = persistencePath
24
- this._ensureDir()
42
+ this._persistencePath = persistencePath;
43
+ this._ensureDir();
25
44
  }
26
45
 
27
46
  _ensureDir() {
28
47
  if (!fs.existsSync(this._persistencePath)) {
29
- fs.mkdirSync(this._persistencePath, { recursive: true })
48
+ fs.mkdirSync(this._persistencePath, { recursive: true });
30
49
  }
31
50
  }
32
51
 
33
52
  _getTasksPath() {
34
- return path.join(this._persistencePath, 'tasks.json')
53
+ return path.join(this._persistencePath, 'tasks.json');
35
54
  }
36
55
 
56
+ /**
57
+ * 原子写:先写 .tmp,再 rename(同卷下原子替换)
58
+ * 避免进程在 writeFileSync 中途崩溃导致 tasks.json 半残
59
+ */
37
60
  saveTasks(tasks) {
61
+ const finalPath = this._getTasksPath();
62
+ const tmpPath = finalPath + '.tmp';
38
63
  try {
39
- const serializable = tasks.map(t => ({
64
+ const serializable = tasks.map((t) => ({
40
65
  id: t.id,
41
66
  name: t.name,
42
67
  type: t.type,
43
68
  message: t.message,
69
+ shell: t.shell || null,
44
70
  enabled: t.enabled,
45
71
  createdAt: t.createdAt,
46
72
  lastRun: t.lastRun,
47
73
  runCount: t.runCount,
48
74
  runAt: t.runAt,
49
75
  cronExpression: t.cronExpression,
76
+ timezone: t.timezone,
50
77
  sessionId: t.sessionId,
51
78
  llm: t.llm,
52
- persistDelay: t.persistDelay,
53
- persistNextRun: t.persistNextRun,
54
79
  notify: t.notify,
55
- shell: t.shell
56
- }))
57
- fs.writeFileSync(this._getTasksPath(), JSON.stringify(serializable, null, 2))
80
+ }));
81
+ fs.writeFileSync(tmpPath, JSON.stringify(serializable, null, 2), 'utf-8');
82
+ fs.renameSync(tmpPath, finalPath);
58
83
  } catch (err) {
59
- log.error(' 保存任务失败:', err.message)
84
+ log.error(' 保存任务失败:', err.message);
60
85
  }
61
86
  }
62
87
 
63
88
  loadTasks() {
64
89
  try {
65
- const filePath = this._getTasksPath()
90
+ const filePath = this._getTasksPath();
66
91
  if (fs.existsSync(filePath)) {
67
- const data = fs.readFileSync(filePath, 'utf-8')
68
- return JSON.parse(data)
92
+ const data = fs.readFileSync(filePath, 'utf-8');
93
+ return JSON.parse(data);
69
94
  }
70
95
  } catch (err) {
71
- log.error(' 加载任务失败:', err.message)
96
+ log.error(' 加载任务失败:', err.message);
72
97
  }
73
- return []
98
+ return [];
74
99
  }
75
100
  }
76
101
 
102
+ // ============================================================
103
+ // 时间解析工具
104
+ // ============================================================
77
105
  function parseDelay(delayStr) {
78
- const match = delayStr.match(/^(\d+)\s*(second|minute|hour|day|week)s?$/i)
79
- if (!match) return null
80
- const value = parseInt(match[1])
81
- const unit = match[2].toLowerCase()
106
+ const match = delayStr.match(/^(\d+)\s*(second|minute|hour|day|week)s?$/i);
107
+ if (!match) return null;
108
+ const value = parseInt(match[1], 10);
109
+ const unit = match[2].toLowerCase();
82
110
  const multipliers = {
83
111
  second: 1000,
84
112
  minute: 60 * 1000,
85
113
  hour: 60 * 60 * 1000,
86
114
  day: 24 * 60 * 60 * 1000,
87
- week: 7 * 24 * 60 * 60 * 1000
88
- }
89
- return value * (multipliers[unit] || 1000)
115
+ week: 7 * 24 * 60 * 60 * 1000,
116
+ };
117
+ return value * (multipliers[unit] || 1000);
90
118
  }
91
119
 
92
120
  function parseAtTime(timeStr) {
93
- const now = new Date()
94
- const timeMatch = timeStr.match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/)
121
+ const now = new Date();
122
+ const timeMatch = timeStr.match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/);
95
123
  if (timeMatch) {
96
- const date = new Date(now)
97
- date.setHours(parseInt(timeMatch[1]), parseInt(timeMatch[2]), parseInt(timeMatch[3] || 0), 0)
124
+ const date = new Date(now);
125
+ date.setHours(parseInt(timeMatch[1], 10), parseInt(timeMatch[2], 10), parseInt(timeMatch[3] || 0, 10), 0);
98
126
  if (date <= now) {
99
- date.setDate(date.getDate() + 1)
127
+ date.setDate(date.getDate() + 1);
100
128
  }
101
- return date
129
+ return date;
102
130
  }
103
- return new Date(timeStr)
131
+ return new Date(timeStr);
104
132
  }
105
133
 
106
- function generateId() {
107
- if (require('crypto').randomUUID) {
108
- return require('crypto').randomUUID()
109
- }
110
- return `task_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
134
+ function detectLLM(message, explicitFlag) {
135
+ if (explicitFlag === true) return true;
136
+ if (explicitFlag === false) return false;
137
+ const lower = message.toLowerCase();
138
+ return LLM_KEYWORDS.some((kw) => message.includes(kw) || lower.includes(kw.toLowerCase()));
111
139
  }
112
140
 
141
+ // ============================================================
142
+ // SchedulerPlugin
143
+ // ============================================================
113
144
  class SchedulerPlugin extends Plugin {
114
145
  constructor(config = {}) {
115
- super()
116
- this.name = 'scheduler'
117
- this.version = '1.0.0'
118
- this.description = '定时任务调度插件,支持 Cron 表达式、绝对时间、相对时间'
119
- this.priority = 15
120
- this.system = true
121
- this.config = {
122
- checkInterval: config.checkInterval || 1000,
123
- persistencePath: config.persistencePath || '.foliko/data/scheduler'
124
- }
146
+ super();
147
+ this.name = 'scheduler';
148
+ this.version = '2.0.0';
149
+ this.description = '定时任务调度 v2(croner 引擎,事件循环阻塞自愈 + 持久化 + 重叠保护)';
150
+ this.priority = 15;
151
+ this.system = true;
125
152
 
126
- this._framework = null
127
- this._agent = null
128
- this._tasks = new Map()
129
- this._timer = null
130
- this._taskStore = null
131
- this._taskStats = {
132
- total: 0,
133
- running: 0,
134
- completed: 0,
135
- failed: 0
136
- }
153
+ this.config = {
154
+ persistencePath: config.persistencePath || '.foliko/data/scheduler',
155
+ timezone: config.timezone || process.env.TZ || 'Asia/Shanghai',
156
+ };
157
+
158
+ this._framework = null;
159
+ this._agent = null;
160
+ this._tasks = new Map();
161
+ this._taskStore = new TaskStore(this.config.persistencePath);
162
+ // 复用的 LLM 执行 subagent(避免每次 tick 新建导致 _agents 数组漏内存)
163
+ this._schedulerSubAgent = null;
164
+ // debounce 写入
165
+ this._persistTimer = null;
166
+ this._persistDirty = false;
137
167
  }
138
168
 
139
169
  install(framework) {
140
- this._framework = framework
141
- this._taskStore = new TaskStore(this.config.persistencePath)
142
- return this
170
+ this._framework = framework;
171
+ return this;
143
172
  }
144
173
 
145
174
  _getAgent() {
146
- if (this._agent) return this._agent
175
+ if (this._agent) return this._agent;
176
+ if (!this._framework) return null;
147
177
  if (this._framework._mainAgent) {
148
- this._agent = this._framework._mainAgent
178
+ this._agent = this._framework._mainAgent;
149
179
  } else {
150
- const agents = this._framework._agents || []
151
- this._agent = agents.length > 0 ? agents[agents.length - 1] : null
180
+ const agents = this._framework._agents || [];
181
+ this._agent = agents.length > 0 ? agents[agents.length - 1] : null;
152
182
  }
153
- return this._agent
183
+ return this._agent;
154
184
  }
155
185
 
186
+ _getSchedulerSubAgent() {
187
+ if (this._schedulerSubAgent) return this._schedulerSubAgent;
188
+ if (!this._framework) return null;
189
+ this._schedulerSubAgent = this._framework.createSubAgent({
190
+ name: 'scheduler_task',
191
+ role: '定时任务执行助手,专注于处理定时提醒和任务执行',
192
+ hidden: true,
193
+ });
194
+ return this._schedulerSubAgent;
195
+ }
196
+
197
+ // ============================================================
198
+ // 工具注册
199
+ // ============================================================
156
200
  start(framework) {
157
- this._agent = this._getAgent()
201
+ this._agent = this._getAgent();
158
202
 
159
203
  framework.registerTool({
160
204
  name: 'schedule_task',
161
205
  description: '设置定时提醒任务。支持多种时间格式:相对时间(1 minute, 2 hours)、具体时间(12:00)、Cron表达式(* * * * *)。系统会自动判断任务是否需要 LLM 处理。',
162
206
  inputSchema: z.object({
163
207
  name: z.string().optional().describe('任务名称'),
164
- scheduleTime: z.string().describe('执行时间。支持格式:\n- 相对时间: "1 minute", "2 hours", "1 day"\n- 具体时间: "12:00", "14:30"\n- Cron表达式: "*/5 * * * *" (每5分钟)'),
165
- message: z.string().describe('提醒消息内容。系统会自动判断:\n- 简单提醒(喝水、吃饭)直接显示\n- 需要查询/分析的任务(查看列表、分析数据)自动启用 LLM'),
208
+ scheduleTime: z.string().describe(
209
+ '执行时间。支持格式:\n- 相对时间: "1 minute", "2 hours", "1 day"\n- 具体时间: "12:00", "14:30"\n- Cron表达式: "*/5 * * * *" (每5分钟)'
210
+ ),
211
+ message: z.string().describe('提醒消息内容(shell 模式下作为命令说明)'),
166
212
  repeat: z.boolean().optional().describe('是否重复执行 (默认 false)'),
167
213
  cronExpression: z.string().optional().describe('Cron 表达式 (当 repeat 为 true 时使用)'),
168
214
  sessionId: z.string().optional().describe('会话 ID(提醒将发送到该会话,不填则使用默认会话)'),
169
215
  llm: z.boolean().optional().describe('是否需要 LLM 处理(自动检测,可手动覆盖)'),
170
- notify: z.boolean().optional().describe('任务完成后是否发送通知(默认 true)。设为 false 时,任务将在后台静默执行,不发送任何通知'),
171
- shell: z.string().optional().describe('要执行的 Shell/Bash 命令(如 echo hello, ls -la)')
216
+ notify: z.boolean().optional().describe('任务完成后是否发送通知(默认 true'),
217
+ shell: z.string().optional().describe('要执行的 Shell/Bash 命令(如 echo hello, ls -la)'),
172
218
  }),
173
- execute: async (args) => {
174
- try {
175
- const { scheduleTime, message, repeat, cronExpression, sessionId, shell } = args
176
- const agent = this._getAgent()
177
- if (!agent) {
178
- return { success: false, error: 'Agent not available' }
179
- }
180
-
181
- if (shell) {
182
- const taskId = `task_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
183
- const isCron = /^[\d*,\/-\s]+$/.test(scheduleTime) && scheduleTime.split(' ').length >= 5
184
-
185
- if (isCron || repeat) {
186
- if (!schedule) {
187
- return { success: false, error: 'node-schedule not installed' }
188
- }
189
- const task = {
190
- id: taskId,
191
- name: args.name || 'ShellTask',
192
- type: 'shell',
193
- cronExpression: cronExpression || scheduleTime,
194
- message: message || shell,
195
- enabled: true,
196
- createdAt: new Date(),
197
- lastRun: null,
198
- runCount: 0,
199
- timer: null,
200
- cronTask: null,
201
- sessionId: sessionId || null,
202
- shell,
203
- notify: args.notify !== false
204
- }
205
- task.cronTask = schedule.scheduleJob(task.cronExpression, async () => {
206
- await this._executeTask(task)
207
- })
208
- this._tasks.set(task.id, task)
209
- this._taskStats.total++
210
- this._saveTasks()
211
- return {
212
- success: true,
213
- data: repeat ? 'Shell 定时任务已创建 (重复执行)' : 'Shell 任务已创建',
214
- metadata: {
215
- taskId: task.id,
216
- name: task.name,
217
- type: 'shell',
218
- shell,
219
- scheduleTime,
220
- cronExpression: task.cronExpression,
221
- sessionId: sessionId || 'default',
222
- notify: task.notify
223
- }
224
- }
225
- } else {
226
- const delayMs = parseDelay(scheduleTime)
227
- if (!delayMs && !scheduleTime.includes(':')) {
228
- return { success: false, error: '无效的时间格式' }
229
- }
230
- let runAt = null
231
- let delay = 0
232
- if (scheduleTime.includes(':')) {
233
- runAt = parseAtTime(scheduleTime)
234
- delay = runAt.getTime() - Date.now()
235
- } else {
236
- delay = parseDelay(scheduleTime)
237
- runAt = new Date(Date.now() + delay)
238
- }
239
- const task = {
240
- id: taskId,
241
- name: args.name || 'ShellTask',
242
- type: 'shell',
243
- runAt,
244
- message: message || shell,
245
- enabled: true,
246
- createdAt: new Date(),
247
- lastRun: null,
248
- runCount: 0,
249
- timer: null,
250
- sessionId: sessionId || null,
251
- shell,
252
- persistDelay: delay,
253
- notify: args.notify !== false
254
- }
255
- task.timer = setTimeout(async () => {
256
- await this._executeTask(task)
257
- }, delay)
258
- this._tasks.set(task.id, task)
259
- this._taskStats.total++
260
- this._saveTasks()
261
- return {
262
- success: true,
263
- data: 'Shell 任务已创建',
264
- metadata: {
265
- taskId: task.id,
266
- name: task.name,
267
- type: 'shell',
268
- shell,
269
- scheduleTime,
270
- executeAt: runAt.toISOString(),
271
- sessionId: sessionId || 'default',
272
- notify: task.notify
273
- }
274
- }
275
- }
276
- }
277
-
278
- let targetSessionId = sessionId
279
- if (!targetSessionId) {
280
- const ctx = this._framework.getExecutionContext()
281
- if (ctx?.sessionId) {
282
- targetSessionId = ctx.sessionId
283
- }
284
- }
285
- if (!targetSessionId) {
286
- const sessionPlugin = this._framework.pluginManager.get('session')
287
- if (sessionPlugin) {
288
- const sessions = sessionPlugin.listSessions()
289
- if (sessions.length > 0) {
290
- sessions.sort((a, b) => {
291
- const aTime = a.lastActive ? new Date(a.lastActive).getTime() : 0
292
- const bTime = b.lastActive ? new Date(b.lastActive).getTime() : 0
293
- return bTime - aTime
294
- })
295
- targetSessionId = sessions[0].id
296
- }
297
- }
298
- }
299
-
300
- const taskId = `task_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
301
- let task
302
-
303
- const LLM_KEYWORDS = [
304
- '分析', '查询', '查看', '检查', '总结', '搜索', '获取',
305
- 'list', 'get', 'check', 'search', 'find', 'fetch',
306
- '什么', '如何', '为什么', '什么时候', '多少', '谁',
307
- '今天', '明天', '昨天', '这周', '这月', '今年'
308
- ]
309
- const messageLower = message.toLowerCase()
310
- const needsLLM = args.llm === true || LLM_KEYWORDS.some(kw =>
311
- message.includes(kw) || messageLower.includes(kw.toLowerCase())
312
- )
313
- const llmMode = needsLLM
314
-
315
- const isCron = /^[\d*,\/-\s]+$/.test(scheduleTime) && scheduleTime.split(' ').length >= 5
316
-
317
- if (isCron || repeat) {
318
- if (!schedule) {
319
- return { success: false, error: 'node-schedule not installed' }
320
- }
321
- task = {
322
- id: taskId,
323
- name: args.name || 'CronTask',
324
- type: 'cron',
325
- cronExpression: cronExpression || scheduleTime,
326
- message,
327
- enabled: true,
328
- createdAt: new Date(),
329
- lastRun: null,
330
- runCount: 0,
331
- timer: null,
332
- cronTask: null,
333
- sessionId: targetSessionId || null,
334
- llm: llmMode,
335
- notify: args.notify !== false
336
- }
337
-
338
- task.cronTask = schedule.scheduleJob(task.cronExpression, async () => {
339
- await this._executeTask(task)
340
- })
341
- } else if (scheduleTime.includes(':')) {
342
- const runAt = parseAtTime(scheduleTime)
343
- task = {
344
- id: taskId,
345
- name: args.name || 'Reminder',
346
- type: 'once',
347
- runAt,
348
- message,
349
- enabled: true,
350
- createdAt: new Date(),
351
- lastRun: null,
352
- runCount: 0,
353
- timer: null,
354
- sessionId: targetSessionId || null,
355
- llm: llmMode,
356
- notify: args.notify !== false
357
- }
358
- task.timer = setTimeout(async () => {
359
- await this._executeTask(task)
360
- }, runAt.getTime() - Date.now())
361
- } else {
362
- const delayMs = parseDelay(scheduleTime)
363
- if (!delayMs) {
364
- return { success: false, error: '无效的时间格式' }
365
- }
366
- const runAt = new Date(Date.now() + delayMs)
367
- task = {
368
- id: taskId,
369
- name: args.name || 'Reminder',
370
- type: 'once',
371
- runAt,
372
- message,
373
- enabled: true,
374
- createdAt: new Date(),
375
- lastRun: null,
376
- runCount: 0,
377
- timer: null,
378
- sessionId: targetSessionId || null,
379
- llm: llmMode,
380
- persistDelay: delayMs,
381
- notify: args.notify !== false
382
- }
383
- task.timer = setTimeout(async () => {
384
- await this._executeTask(task)
385
- }, delayMs)
386
- }
387
-
388
- this._tasks.set(task.id, task)
389
- this._taskStats.total++
390
- this._saveTasks()
391
-
392
- this._framework.emit('scheduler:task_created', {
393
- taskId: task.id,
394
- taskName: task.name,
395
- type: task.type,
396
- scheduleTime,
397
- cronExpression: task.cronExpression
398
- })
399
-
400
- return {
401
- success: true,
402
- taskId: task.id,
403
- name: task.name,
404
- scheduleTime,
405
- executeAt: task.runAt ? task.runAt.toISOString() : null,
406
- cronExpression: task.cronExpression,
407
- message: repeat ? '定时任务已创建 (重复执行)' : '提醒已设置',
408
- sessionId: sessionId || 'default',
409
- llm: llmMode,
410
- notify: task.notify
411
- }
412
- } catch (err) {
413
- return { success: false, error: err.message }
414
- }
415
- }
416
- })
219
+ execute: async (args) => this._createTask(args),
220
+ });
417
221
 
418
222
  framework.registerTool({
419
223
  name: 'schedule_list',
420
224
  description: '列出所有定时任务',
421
225
  inputSchema: z.object({}),
422
226
  execute: async () => {
423
- const tasks = Array.from(this._tasks.values()).map(t => ({
424
- id: t.id,
425
- name: t.name,
426
- type: t.type,
427
- message: t.message,
428
- enabled: t.enabled,
429
- nextRun: t.nextRun || t.runAt,
430
- runCount: t.runCount,
431
- lastRun: t.lastRun,
432
- cronExpression: t.cronExpression,
433
- llm: t.llm,
434
- notify: t.notify,
435
- sessionId: t.sessionId,
436
- shell: t.shell
437
- }))
438
-
439
- return {
440
- success: true,
441
- tasks,
442
- total: tasks.length,
443
- stats: { ...this._taskStats }
444
- }
445
- }
446
- })
227
+ const tasks = Array.from(this._tasks.values()).map((t) => {
228
+ // croner 提供 nextRun() getter:cron 任务下一触发时间;once 任务固定 runAt
229
+ let nextRun = null;
230
+ try {
231
+ nextRun = t.scheduleHandle?.nextRun?.() || t.runAt || null;
232
+ } catch {
233
+ nextRun = t.runAt || null;
234
+ }
235
+ return {
236
+ id: t.id,
237
+ name: t.name,
238
+ type: t.type,
239
+ message: t.message,
240
+ shell: t.shell || null,
241
+ enabled: t.enabled,
242
+ nextRun,
243
+ runCount: t.runCount,
244
+ lastRun: t.lastRun,
245
+ cronExpression: t.cronExpression,
246
+ timezone: t.timezone,
247
+ llm: t.llm,
248
+ notify: t.notify,
249
+ sessionId: t.sessionId,
250
+ };
251
+ });
252
+ return { success: true, tasks, total: tasks.length };
253
+ },
254
+ });
447
255
 
448
256
  framework.registerTool({
449
257
  name: 'schedule_cancel',
450
258
  description: '取消定时任务',
451
- inputSchema: z.object({
452
- taskId: z.string().describe('任务 ID')
453
- }),
454
- execute: async (args) => {
455
- const task = this._tasks.get(args.taskId)
456
- if (!task) {
457
- return { success: false, error: 'Task not found' }
458
- }
459
-
460
- const taskName = task.name
461
- this._cancelTask(task)
462
- this._saveTasks()
463
-
464
- this._framework.emit('notification', {
465
- title: '任务已取消',
466
- message: `定时任务 "${taskName}" 已取消`,
467
- source: 'scheduler',
468
- level: 'info',
469
- sessionId: task.sessionId,
470
- timestamp: new Date()
471
- })
472
-
473
- return { success: true, cancelled: args.taskId }
474
- }
475
- })
259
+ inputSchema: z.object({ taskId: z.string().describe('任务 ID') }),
260
+ execute: async (args) => this._cancelTask(args.taskId),
261
+ });
476
262
 
477
263
  framework.registerTool({
478
264
  name: 'cron_examples',
479
265
  description: '获取常用 Cron 表达式示例',
480
266
  inputSchema: z.object({}),
481
- execute: async () => {
482
- return {
483
- examples: [
484
- { expression: '* * * * *', description: '每分钟' },
485
- { expression: '*/5 * * * *', description: '每5分钟' },
486
- { expression: '*/15 * * * *', description: '每15分钟' },
487
- { expression: '0 * * * *', description: '每小时' },
488
- { expression: '0 9 * * *', description: '每天早上9点' },
489
- { expression: '0 12 * * *', description: '每天中午12点' },
490
- { expression: '0 18 * * *', description: '每天下午6点' },
491
- { expression: '0 9 * * 1-5', description: '工作日上午9点' },
492
- { expression: '0 9 * * 0,6', description: '周末上午9点' },
493
- { expression: '0 9 * * 1', description: '每周一上午9点' },
494
- { expression: '0 */2 * * *', description: '每2小时' }
495
- ]
496
- }
497
- }
498
- })
499
-
500
- this._startScheduler()
501
-
502
- this._loadPersistedTasks()
503
-
504
- return this
505
- }
506
-
507
- _startScheduler() {
508
- if (this._timer) {
509
- clearInterval(this._timer)
510
- }
511
-
512
- this._timer = setInterval(() => {
513
- this._checkTasks()
514
- }, this.config.checkInterval)
515
- }
267
+ execute: async () => ({
268
+ examples: [
269
+ { expression: '* * * * *', description: '每分钟' },
270
+ { expression: '*/5 * * * *', description: '每5分钟' },
271
+ { expression: '*/15 * * * *', description: '每15分钟' },
272
+ { expression: '0 * * * *', description: '每小时' },
273
+ { expression: '0 9 * * *', description: '每天早上9点' },
274
+ { expression: '0 12 * * *', description: '每天中午12点' },
275
+ { expression: '0 18 * * *', description: '每天下午6点' },
276
+ { expression: '0 9 * * 1-5', description: '工作日上午9点' },
277
+ { expression: '0 9 * * 0,6', description: '周末上午9点' },
278
+ { expression: '0 9 * * 1', description: '每周一上午9点' },
279
+ { expression: '0 */2 * * *', description: '每2小时' },
280
+ ],
281
+ }),
282
+ });
516
283
 
517
- _checkTasks() {
518
- for (const [id, task] of this._tasks) {
519
- if (!task.enabled) continue
520
- }
284
+ this._loadPersistedTasks();
285
+ return this;
521
286
  }
522
287
 
523
- _saveTasks() {
524
- if (this._taskStore) {
525
- this._taskStore.saveTasks(Array.from(this._tasks.values()))
526
- }
527
- }
528
-
529
- _loadPersistedTasks() {
530
- if (!this._taskStore) return
531
-
532
- const savedTasks = this._taskStore.loadTasks()
533
- if (!savedTasks || savedTasks.length === 0) return
534
-
535
- for (const saved of savedTasks) {
536
- if (saved.type === 'once' && saved.runCount > 0) {
537
- continue
288
+ // ============================================================
289
+ // 任务创建(统一入口,shell 与普通合并)
290
+ // ============================================================
291
+ async _createTask(args) {
292
+ try {
293
+ const { scheduleTime, message, repeat, cronExpression, sessionId, shell } = args;
294
+
295
+ // 解析时间
296
+ const isCron = /^[\d*,\/-\s]+$/.test(scheduleTime) && scheduleTime.split(/\s+/).length >= 5;
297
+ let taskType; // 'cron' | 'once'
298
+ let runAt = null;
299
+ let cron = null;
300
+
301
+ if (isCron || repeat) {
302
+ taskType = 'cron';
303
+ cron = cronExpression || scheduleTime;
304
+ } else if (scheduleTime.includes(':')) {
305
+ taskType = 'once';
306
+ runAt = parseAtTime(scheduleTime);
307
+ if (isNaN(runAt.getTime())) {
308
+ return { success: false, error: `无法解析时间: ${scheduleTime}` };
309
+ }
310
+ } else {
311
+ const delayMs = parseDelay(scheduleTime);
312
+ if (!delayMs) {
313
+ return { success: false, error: '无效的时间格式' };
314
+ }
315
+ taskType = 'once';
316
+ runAt = new Date(Date.now() + delayMs);
538
317
  }
539
318
 
540
- let task = { ...saved }
541
-
542
- if (task.type === 'cron' && task.enabled && schedule) {
543
- try {
544
- task.cronTask = schedule.scheduleJob(task.cronExpression, async () => {
545
- await this._executeTask(task)
546
- })
547
- this._tasks.set(task.id, task)
548
- this._taskStats.total++
549
- } catch (err) {
550
- log.error(` 恢复 Cron 任务失败: ${task.name}`, err.message)
551
- }
319
+ // shell 任务必须能用 shell 工具,否则提示
320
+ if (shell && !this._hasShellTool()) {
321
+ // 不阻塞:fallback child_process 执行
552
322
  }
553
- else if (task.type === 'shell' && task.enabled && task.cronExpression && schedule) {
554
- try {
555
- task.cronTask = schedule.scheduleJob(task.cronExpression, async () => {
556
- await this._executeTask(task)
557
- })
558
- this._tasks.set(task.id, task)
559
- this._taskStats.total++
560
- } catch (err) {
561
- log.error(` 恢复 Shell Cron 任务失败: ${task.name}`, err.message)
562
- }
323
+
324
+ // 解析 sessionId
325
+ let targetSessionId = sessionId;
326
+ if (!targetSessionId) {
327
+ const ctx = this._framework?.getExecutionContext?.();
328
+ if (ctx?.sessionId) targetSessionId = ctx.sessionId;
563
329
  }
564
- else if (task.type === 'once' && task.enabled && task.persistDelay) {
565
- const newDelay = task.persistDelay
566
- if (newDelay > 0) {
567
- task.runAt = new Date(Date.now() + newDelay)
568
- task.timer = setTimeout(async () => {
569
- await this._executeTask(task)
570
- }, newDelay)
571
- this._tasks.set(task.id, task)
572
- this._taskStats.total++
330
+ if (!targetSessionId) {
331
+ const sessionPlugin = this._framework?.pluginManager?.get('session');
332
+ if (sessionPlugin) {
333
+ const sessions = sessionPlugin.listSessions();
334
+ if (sessions.length > 0) {
335
+ sessions.sort((a, b) => {
336
+ const aTime = a.lastActive ? new Date(a.lastActive).getTime() : 0;
337
+ const bTime = b.lastActive ? new Date(b.lastActive).getTime() : 0;
338
+ return bTime - aTime;
339
+ });
340
+ targetSessionId = sessions[0].id;
341
+ }
573
342
  }
574
343
  }
575
- else if (task.type === 'once' && task.enabled && task.runAt) {
576
- const runAt = new Date(task.runAt)
577
- if (runAt > new Date()) {
578
- const delay = runAt.getTime() - Date.now()
579
- task.timer = setTimeout(async () => {
580
- await this._executeTask(task)
581
- }, delay)
582
- this._tasks.set(task.id, task)
583
- this._taskStats.total++
344
+
345
+ const llmMode = shell ? false : detectLLM(message, args.llm);
346
+ const task = {
347
+ id: `task_${randomUUID()}`,
348
+ name: args.name || (shell ? 'ShellTask' : taskType === 'cron' ? 'CronTask' : 'Reminder'),
349
+ type: taskType,
350
+ message,
351
+ shell: shell || null,
352
+ enabled: true,
353
+ createdAt: new Date(),
354
+ lastRun: null,
355
+ runCount: 0,
356
+ cronExpression: cron,
357
+ runAt,
358
+ timezone: this.config.timezone,
359
+ sessionId: targetSessionId || null,
360
+ llm: llmMode,
361
+ notify: args.notify !== false,
362
+ running: false,
363
+ scheduleHandle: null,
364
+ };
365
+
366
+ // 创建 croner 调度
367
+ try {
368
+ if (taskType === 'cron') {
369
+ task.scheduleHandle = new Cron(cron, this._cronOptions(task), () => this._onFire(task));
370
+ } else {
371
+ // once:startAt 限定未来触发
372
+ task.scheduleHandle = new Cron(runAt, this._cronOptions(task), () => this._onFire(task));
584
373
  }
374
+ } catch (err) {
375
+ return { success: false, error: `调度创建失败: ${err.message}` };
585
376
  }
377
+
378
+ this._tasks.set(task.id, task);
379
+ this._schedulePersist();
380
+
381
+ this._framework?.emit('scheduler:task_created', {
382
+ taskId: task.id,
383
+ taskName: task.name,
384
+ type: task.type,
385
+ scheduleTime,
386
+ cronExpression: task.cronExpression,
387
+ });
388
+
389
+ return {
390
+ success: true,
391
+ taskId: task.id,
392
+ name: task.name,
393
+ scheduleTime,
394
+ executeAt: task.runAt ? task.runAt.toISOString() : null,
395
+ cronExpression: task.cronExpression,
396
+ message: task.type === 'cron' ? '定时任务已创建 (重复执行)' : '提醒已设置',
397
+ sessionId: targetSessionId || 'default',
398
+ llm: llmMode,
399
+ notify: task.notify,
400
+ };
401
+ } catch (err) {
402
+ return { success: false, error: err.message };
586
403
  }
587
404
  }
588
405
 
589
- _cancelTask(task) {
590
- if (task.timer) {
591
- clearTimeout(task.timer)
592
- task.timer = null
406
+ _cronOptions(task) {
407
+ return {
408
+ timezone: this.config.timezone,
409
+ protect: true, // 防重叠:上一次还没跑完,跳过本次
410
+ catch: true, // catch-up:进程恢复后补触发错过的触发
411
+ name: `scheduler:${task.id}`,
412
+ unref: true, // 不阻止进程退出
413
+ };
414
+ }
415
+
416
+ // ============================================================
417
+ // 触发回调
418
+ // ============================================================
419
+ async _onFire(task) {
420
+ if (!task.enabled) return;
421
+ if (task.running) {
422
+ // 理论上 protect:true 已经处理过;这里再保险一次
423
+ log.warn(` 任务 ${task.name} 仍在执行中,跳过本次触发`);
424
+ return;
593
425
  }
594
- if (task.cronTask) {
595
- task.cronTask.cancel()
596
- task.cronTask = null
426
+ task.running = true;
427
+ task.lastRun = new Date();
428
+ task.runCount++;
429
+ try {
430
+ await this._executeTask(task);
431
+ } finally {
432
+ task.running = false;
433
+ }
434
+
435
+ // once 任务触发一次后清理
436
+ if (task.type === 'once') {
437
+ this._cleanupTask(task.id);
438
+ } else {
439
+ // cron 任务只更新持久化(不写 runAt)
440
+ this._schedulePersist();
597
441
  }
598
- task.enabled = false
599
442
  }
600
443
 
601
444
  async _executeTask(task) {
602
- task.lastRun = new Date()
603
- task.runCount++
604
- this._taskStats.running++
605
-
606
445
  try {
607
- if (task.type === 'shell') {
608
- let result;
609
- if (this._framework.toolRegistry?.has?.('shell_exec') || this._framework.toolRegistry?.has?.('shell')) {
610
- const toolName = this._framework.toolRegistry.has('shell_exec') ? 'shell_exec' : 'shell';
611
- result = await this._framework.executeTool(toolName, { command: task.shell });
612
- // 适配 shell_exec 的返回格式({success, stdout, stderr})到统一格式
613
- if (result.success === false && result.error) {
614
- // shell_exec 已捕获错误
615
- } else if (result.success === false && result.exitCode !== 0) {
616
- result = { error: `退出码 ${result.exitCode}: ${result.stderr}`, stdout: result.stdout, stderr: result.stderr };
617
- } else {
618
- result = { output: result.stdout, stdout: result.stdout, stderr: result.stderr };
619
- }
620
- } else {
621
- // 兜底:shell 工具不存在时直接用 child_process 执行
622
- const { exec } = require('child_process');
623
- const util = require('util');
624
- const execAsync = util.promisify(exec);
625
- try {
626
- const { stdout, stderr } = await execAsync(task.shell, { timeout: 60000 });
627
- result = { output: stdout.trim(), stdout: stdout.trim(), stderr: stderr.trim() };
628
- } catch (e) {
629
- result = { error: e.message, stderr: e.stderr || '' };
630
- }
631
- }
632
- this._taskStats.completed++
633
-
634
- const output = result.error ? `执行失败: ${result.error}` : (result.output || result.stdout || JSON.stringify(result))
635
- if (task.notify !== false) {
636
- this._framework.emit('notification', {
637
- title: task.name,
638
- message: output,
639
- source: 'scheduler',
640
- level: result.error ? 'error' : 'info',
641
- sessionId: task.sessionId,
642
- timestamp: new Date()
643
- })
644
- }
645
-
646
- this._framework.emit('scheduler:task_completed', {
647
- taskId: task.id,
648
- result: output
649
- })
650
-
651
- if (task.type === 'once' && !task.cronTask) {
652
- this._cleanupTask(task.id)
653
- }
654
-
655
- return { success: true }
656
- } else if (task.llm) {
657
- const schedulerAgent = this._framework.createSubAgent({
658
- name: 'scheduler_task',
659
- role: '定时任务执行助手,专注于处理定时提醒和任务执行',
660
- hidden: true
661
- })
662
-
663
- const result = await schedulerAgent.chat(task.message)
664
-
665
- this._taskStats.completed++
666
-
667
- const responseText = result.message
668
-
669
- if (task.notify !== false && responseText && responseText.trim()) {
670
- this._framework.emit('notification', {
671
- title: task.name,
672
- message: responseText,
673
- source: 'scheduler',
674
- level: 'info',
675
- sessionId: task.sessionId,
676
- timestamp: new Date()
677
- })
678
- this._framework.emit('scheduler:reminder', {
679
- taskId: task.id,
680
- message: task.message,
681
- time: task.runAt || task.nextRun
682
- })
683
- }
684
-
685
- this._framework.emit('scheduler:task_completed', {
686
- taskId: task.id,
687
- result: responseText || task.message
688
- })
689
- } else {
690
- if (task.notify !== false) {
691
- this._framework.emit('notification', {
692
- title: task.name,
693
- message: task.message,
694
- source: 'scheduler',
695
- level: 'info',
696
- sessionId: task.sessionId,
697
- timestamp: new Date()
698
- })
699
- this._framework.emit('scheduler:reminder', {
700
- taskId: task.id,
701
- message: task.message,
702
- time: task.runAt || task.nextRun
703
- })
704
- }
705
-
706
- this._framework.emit('scheduler:task_completed', {
707
- taskId: task.id,
708
- result: task.message
709
- })
710
- this._taskStats.completed++
446
+ // shell 执行
447
+ if (task.shell) {
448
+ return await this._runShell(task);
711
449
  }
712
-
713
- if (task.type === 'once' && !task.cronTask) {
714
- this._cleanupTask(task.id)
450
+ // LLM 执行
451
+ if (task.llm) {
452
+ return await this._runLLM(task);
715
453
  }
716
-
717
- return { success: true }
454
+ // 普通提醒(直接发通知)
455
+ return await this._runNotification(task);
718
456
  } catch (err) {
719
- this._taskStats.failed++
720
- log.error(` Task ${task.name} failed: ${err.message}`)
721
-
722
- this._framework.emit('notification', {
457
+ log.error(` Task ${task.name} failed:`, err.message);
458
+ this._framework?.emit('notification', {
723
459
  title: `任务失败: ${task.name}`,
724
460
  message: err.message,
725
461
  source: 'scheduler',
726
462
  level: 'error',
727
463
  sessionId: task.sessionId,
728
- timestamp: new Date()
729
- })
730
-
731
- this._framework.emit('scheduler:task_failed', {
464
+ timestamp: new Date(),
465
+ });
466
+ this._framework?.emit('scheduler:task_failed', {
732
467
  taskId: task.id,
733
- error: err.message
734
- })
468
+ error: err.message,
469
+ });
470
+ return { error: err.message };
471
+ }
472
+ }
735
473
 
736
- if (task.type === 'once' && !task.cronTask) {
737
- this._cleanupTask(task.id)
474
+ async _runShell(task) {
475
+ let result;
476
+ const shellTool = this._hasShellTool() ? this._pickShellTool() : null;
477
+ if (shellTool) {
478
+ const raw = await this._framework.executeTool(shellTool, { command: task.shell });
479
+ // 适配不同 shell 工具的返回格式
480
+ if (raw?.error) {
481
+ result = { error: raw.error, stdout: raw.stdout, stderr: raw.stderr };
482
+ } else if (raw?.success === false) {
483
+ const msg = raw.exitCode !== undefined ? `退出码 ${raw.exitCode}: ${raw.stderr || ''}` : (raw.error || 'shell 执行失败');
484
+ result = { error: msg, stdout: raw.stdout, stderr: raw.stderr };
485
+ } else {
486
+ result = { output: raw.stdout || raw.output || '', stdout: raw.stdout, stderr: raw.stderr };
738
487
  }
488
+ } else {
489
+ // 兜底:直接 child_process
490
+ const { exec } = require('child_process');
491
+ const util = require('util');
492
+ const execAsync = util.promisify(exec);
493
+ try {
494
+ const { stdout, stderr } = await execAsync(task.shell, { timeout: 60000 });
495
+ result = { output: stdout.trim(), stdout: stdout.trim(), stderr: (stderr || '').trim() };
496
+ } catch (e) {
497
+ result = { error: e.message, stderr: e.stderr || '' };
498
+ }
499
+ }
739
500
 
740
- return { error: err.message }
741
- } finally {
742
- this._taskStats.running--
501
+ const output = result.error
502
+ ? `执行失败: ${result.error}`
503
+ : (result.output || result.stdout || JSON.stringify(result));
504
+
505
+ if (task.notify !== false) {
506
+ this._framework?.emit('notification', {
507
+ title: task.name,
508
+ message: output,
509
+ source: 'scheduler',
510
+ level: result.error ? 'error' : 'info',
511
+ sessionId: task.sessionId,
512
+ timestamp: new Date(),
513
+ });
743
514
  }
515
+ this._framework?.emit('scheduler:task_completed', { taskId: task.id, result: output });
516
+ return { success: true };
517
+ }
518
+
519
+ async _runLLM(task) {
520
+ const subAgent = this._getSchedulerSubAgent();
521
+ if (!subAgent) {
522
+ return { error: '无法创建 scheduler subagent' };
523
+ }
524
+ const result = await subAgent.chat(task.message);
525
+ const responseText = result?.message;
526
+ if (task.notify !== false && responseText && responseText.trim()) {
527
+ this._framework?.emit('notification', {
528
+ title: task.name,
529
+ message: responseText,
530
+ source: 'scheduler',
531
+ level: 'info',
532
+ sessionId: task.sessionId,
533
+ timestamp: new Date(),
534
+ });
535
+ this._framework?.emit('scheduler:reminder', {
536
+ taskId: task.id,
537
+ message: task.message,
538
+ time: task.runAt || null,
539
+ });
540
+ }
541
+ this._framework?.emit('scheduler:task_completed', {
542
+ taskId: task.id,
543
+ result: responseText || task.message,
544
+ });
545
+ return { success: true };
546
+ }
547
+
548
+ async _runNotification(task) {
549
+ if (task.notify !== false) {
550
+ this._framework?.emit('notification', {
551
+ title: task.name,
552
+ message: task.message,
553
+ source: 'scheduler',
554
+ level: 'info',
555
+ sessionId: task.sessionId,
556
+ timestamp: new Date(),
557
+ });
558
+ this._framework?.emit('scheduler:reminder', {
559
+ taskId: task.id,
560
+ message: task.message,
561
+ time: task.runAt || null,
562
+ });
563
+ }
564
+ this._framework?.emit('scheduler:task_completed', {
565
+ taskId: task.id,
566
+ result: task.message,
567
+ });
568
+ return { success: true };
569
+ }
570
+
571
+ // ============================================================
572
+ // 取消 & 清理
573
+ // ============================================================
574
+ _cancelTask(taskId) {
575
+ const task = this._tasks.get(taskId);
576
+ if (!task) return { success: false, error: 'Task not found' };
577
+
578
+ const taskName = task.name;
579
+ try {
580
+ task.scheduleHandle?.stop();
581
+ } catch {}
582
+ task.enabled = false;
583
+ this._tasks.delete(taskId);
584
+ this._schedulePersist();
585
+
586
+ this._framework?.emit('notification', {
587
+ title: '任务已取消',
588
+ message: `定时任务 "${taskName}" 已取消`,
589
+ source: 'scheduler',
590
+ level: 'info',
591
+ sessionId: task.sessionId,
592
+ timestamp: new Date(),
593
+ });
594
+
595
+ return { success: true, cancelled: taskId };
744
596
  }
745
597
 
746
598
  _cleanupTask(taskId) {
747
- const task = this._tasks.get(taskId)
748
- if (task) {
749
- if (task.timer) {
750
- clearTimeout(task.timer)
751
- task.timer = null
752
- }
753
- if (task.cronTask) {
754
- task.cronTask.cancel()
755
- task.cronTask = null
599
+ const task = this._tasks.get(taskId);
600
+ if (!task) return;
601
+ try {
602
+ task.scheduleHandle?.stop();
603
+ } catch {}
604
+ this._tasks.delete(taskId);
605
+ this._schedulePersist();
606
+ }
607
+
608
+ // ============================================================
609
+ // 持久化(debounce 500ms + 原子写)
610
+ // ============================================================
611
+ _schedulePersist() {
612
+ this._persistDirty = true;
613
+ if (this._persistTimer) return;
614
+ this._persistTimer = setTimeout(() => {
615
+ this._persistTimer = null;
616
+ if (!this._persistDirty) return;
617
+ this._persistDirty = false;
618
+ this._taskStore.saveTasks(Array.from(this._tasks.values()));
619
+ }, PERSIST_DEBOUNCE_MS);
620
+ this._persistTimer.unref?.();
621
+ }
622
+
623
+ _flushPersist() {
624
+ if (this._persistTimer) {
625
+ clearTimeout(this._persistTimer);
626
+ this._persistTimer = null;
627
+ }
628
+ if (this._persistDirty) {
629
+ this._persistDirty = false;
630
+ this._taskStore.saveTasks(Array.from(this._tasks.values()));
631
+ }
632
+ }
633
+
634
+ // ============================================================
635
+ // 启动时从持久化恢复(含过期补触发)
636
+ // ============================================================
637
+ _loadPersistedTasks() {
638
+ const savedTasks = this._taskStore.loadTasks();
639
+ if (!savedTasks || savedTasks.length === 0) return;
640
+ const now = Date.now();
641
+ const expiredImmediate = []; // 需要立即补触发的过期任务
642
+
643
+ for (const saved of savedTasks) {
644
+ // 已完成的 once 任务不再恢复
645
+ if (saved.type === 'once' && (saved.runCount || 0) > 0) continue;
646
+
647
+ const task = {
648
+ ...saved,
649
+ createdAt: saved.createdAt ? new Date(saved.createdAt) : new Date(),
650
+ lastRun: saved.lastRun ? new Date(saved.lastRun) : null,
651
+ runAt: saved.runAt ? new Date(saved.runAt) : null,
652
+ running: false,
653
+ scheduleHandle: null,
654
+ };
655
+
656
+ try {
657
+ if (task.type === 'cron' && task.enabled && task.cronExpression) {
658
+ task.scheduleHandle = new Cron(task.cronExpression, this._cronOptions(task), () => this._onFire(task));
659
+ this._tasks.set(task.id, task);
660
+ } else if (task.type === 'once' && task.enabled) {
661
+ if (!task.runAt) continue;
662
+ const fireAt = task.runAt.getTime();
663
+ if (fireAt <= now) {
664
+ // 已过期:超过 grace period 就丢弃,否则排队立即补触发
665
+ if (now - fireAt > ONCE_EXPIRY_GRACE_MS) {
666
+ log.warn(` 丢弃过期任务 ${task.name}(已过期 ${Math.round((now - fireAt) / 1000)}s,超出 24h 容忍期)`);
667
+ continue;
668
+ }
669
+ // 安排一个微小延迟的补触发(避免与启动逻辑抢资源)
670
+ setTimeout(() => this._onFire(task), 100).unref?.();
671
+ // 不放入 _tasks 的 scheduleHandle 路径(因为是一次性补触发),
672
+ // 但保留在 _tasks 里供 schedule_list 查看
673
+ this._tasks.set(task.id, { ...task, expiredCatchup: true });
674
+ } else {
675
+ // 未来触发
676
+ task.scheduleHandle = new Cron(task.runAt, this._cronOptions(task), () => this._onFire(task));
677
+ this._tasks.set(task.id, task);
678
+ }
679
+ }
680
+ } catch (err) {
681
+ log.error(` 恢复任务失败: ${task.name}`, err.message);
756
682
  }
757
- this._tasks.delete(taskId)
758
- this._saveTasks()
759
683
  }
684
+ log.info(` 已恢复 ${this._tasks.size} 个定时任务`);
685
+ }
686
+
687
+ // ============================================================
688
+ // 辅助
689
+ // ============================================================
690
+ _hasShellTool() {
691
+ if (!this._framework?.toolRegistry) return false;
692
+ return this._framework.toolRegistry.has?.('shell_exec') || this._framework.toolRegistry.has?.('shell');
693
+ }
694
+
695
+ _pickShellTool() {
696
+ const reg = this._framework.toolRegistry;
697
+ if (reg.has?.('shell_exec')) return 'shell_exec';
698
+ if (reg.has?.('shell')) return 'shell';
699
+ return null;
760
700
  }
761
701
 
762
702
  stopAll() {
763
703
  for (const task of this._tasks.values()) {
764
- this._cancelTask(task)
704
+ try { task.scheduleHandle?.stop(); } catch {}
765
705
  }
766
-
767
- if (this._timer) {
768
- clearInterval(this._timer)
769
- this._timer = null
770
- }
771
-
772
- this._saveTasks()
706
+ this._tasks.clear();
707
+ this._flushPersist();
773
708
  }
774
709
 
775
710
  reload(framework) {
776
- this._framework = framework
777
- this._agent = this._getAgent()
778
- this._startScheduler()
779
- this._loadPersistedTasks()
711
+ for (const task of this._tasks.values()) {
712
+ try { task.scheduleHandle?.stop(); } catch {}
713
+ }
714
+ this._tasks.clear();
715
+ this._schedulerSubAgent = null;
716
+ this._framework = framework;
717
+ this._agent = this._getAgent();
718
+ this._loadPersistedTasks();
780
719
  }
781
720
 
782
721
  uninstall(framework) {
783
- this.stopAll()
784
- this._tasks.clear()
785
- this._taskStats = { total: 0, running: 0, completed: 0, failed: 0 }
786
- this._framework = null
787
- this._agent = null
722
+ this.stopAll();
723
+ this._schedulerSubAgent = null;
724
+ this._framework = null;
725
+ this._agent = null;
788
726
  }
789
727
  }
790
728
 
791
- module.exports = SchedulerPlugin
729
+ module.exports = SchedulerPlugin;