foliko 2.0.7 → 2.0.9

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.
@@ -1,792 +1,993 @@
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
+ function isCronString(str) {
142
+ if (!str) return false;
143
+ return /^[\d*,\/-\s]+$/.test(str) && str.trim().split(/\s+/).length >= 5;
144
+ }
145
+
146
+ // ============================================================
147
+ // SchedulerPlugin
148
+ // ============================================================
113
149
  class SchedulerPlugin extends Plugin {
114
150
  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
- }
151
+ super();
152
+ this.name = 'scheduler';
153
+ this.version = '2.0.2';
154
+ this.description = '定时任务调度 v2(croner 引擎,事件循环阻塞自愈 + 持久化 + 重叠保护 + 增删改查 + 并发安全)';
155
+ this.priority = 15;
156
+ this.system = true;
125
157
 
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
- }
158
+ this.config = {
159
+ persistencePath: config.persistencePath || '.foliko/data/scheduler',
160
+ timezone: config.timezone || process.env.TZ || 'Asia/Shanghai',
161
+ };
162
+
163
+ this._framework = null;
164
+ this._agent = null;
165
+ this._tasks = new Map();
166
+ this._taskStore = new TaskStore(this.config.persistencePath);
167
+ // 复用的 LLM 执行 subagent(避免每次 tick 新建导致 _agents 数组漏内存)
168
+ this._schedulerSubAgent = null;
169
+ // LLM 任务互斥链:B6 修复,避免不同任务的 subagent.chat() 并发污染 subagent 内部状态
170
+ this._llmMutex = Promise.resolve();
171
+ // debounce 写入
172
+ this._persistTimer = null;
173
+ this._persistDirty = false;
137
174
  }
138
175
 
139
176
  install(framework) {
140
- this._framework = framework
141
- this._taskStore = new TaskStore(this.config.persistencePath)
142
- return this
177
+ this._framework = framework;
178
+ return this;
143
179
  }
144
180
 
145
181
  _getAgent() {
146
- if (this._agent) return this._agent
182
+ if (this._agent) return this._agent;
183
+ if (!this._framework) return null;
147
184
  if (this._framework._mainAgent) {
148
- this._agent = this._framework._mainAgent
185
+ this._agent = this._framework._mainAgent;
149
186
  } else {
150
- const agents = this._framework._agents || []
151
- this._agent = agents.length > 0 ? agents[agents.length - 1] : null
187
+ const agents = this._framework._agents || [];
188
+ this._agent = agents.length > 0 ? agents[agents.length - 1] : null;
152
189
  }
153
- return this._agent
190
+ return this._agent;
191
+ }
192
+
193
+ _getSchedulerSubAgent() {
194
+ if (this._schedulerSubAgent) return this._schedulerSubAgent;
195
+ if (!this._framework) return null;
196
+ this._schedulerSubAgent = this._framework.createSubAgent({
197
+ name: 'scheduler_task',
198
+ role: '定时任务执行助手,专注于处理定时提醒和任务执行',
199
+ hidden: true,
200
+ });
201
+ return this._schedulerSubAgent;
154
202
  }
155
203
 
204
+ // ============================================================
205
+ // 工具注册
206
+ // ============================================================
156
207
  start(framework) {
157
- this._agent = this._getAgent()
208
+ this._agent = this._getAgent();
158
209
 
159
210
  framework.registerTool({
160
211
  name: 'schedule_task',
161
212
  description: '设置定时提醒任务。支持多种时间格式:相对时间(1 minute, 2 hours)、具体时间(12:00)、Cron表达式(* * * * *)。系统会自动判断任务是否需要 LLM 处理。',
162
213
  inputSchema: z.object({
163
214
  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'),
215
+ scheduleTime: z.string().describe(
216
+ '执行时间。支持格式:\n- 相对时间: "1 minute", "2 hours", "1 day"\n- 具体时间: "12:00", "14:30"\n- Cron表达式: "*/5 * * * *" (每5分钟)'
217
+ ),
218
+ message: z.string().describe('提醒消息内容(shell 模式下作为命令说明)'),
166
219
  repeat: z.boolean().optional().describe('是否重复执行 (默认 false)'),
167
220
  cronExpression: z.string().optional().describe('Cron 表达式 (当 repeat 为 true 时使用)'),
168
221
  sessionId: z.string().optional().describe('会话 ID(提醒将发送到该会话,不填则使用默认会话)'),
169
222
  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)')
223
+ notify: z.boolean().optional().describe('任务完成后是否发送通知(默认 true'),
224
+ shell: z.string().optional().describe('要执行的 Shell/Bash 命令(如 echo hello, ls -la)'),
172
225
  }),
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
- })
226
+ execute: async (args) => this._createTask(args),
227
+ });
417
228
 
418
229
  framework.registerTool({
419
230
  name: 'schedule_list',
420
231
  description: '列出所有定时任务',
421
232
  inputSchema: z.object({}),
422
233
  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
- }))
234
+ const tasks = Array.from(this._tasks.values()).map((t) => {
235
+ // croner 提供 nextRun() getter:cron 任务下一触发时间;once 任务固定 runAt
236
+ let nextRun = null;
237
+ try {
238
+ nextRun = t.scheduleHandle?.nextRun?.() || t.runAt || null;
239
+ } catch {
240
+ nextRun = t.runAt || null;
241
+ }
242
+ return {
243
+ id: t.id,
244
+ name: t.name,
245
+ type: t.type,
246
+ message: t.message,
247
+ shell: t.shell || null,
248
+ enabled: t.enabled,
249
+ nextRun,
250
+ runCount: t.runCount,
251
+ lastRun: t.lastRun,
252
+ cronExpression: t.cronExpression,
253
+ timezone: t.timezone,
254
+ llm: t.llm,
255
+ notify: t.notify,
256
+ sessionId: t.sessionId,
257
+ };
258
+ });
259
+ return { success: true, tasks, total: tasks.length };
260
+ },
261
+ });
262
+
263
+ framework.registerTool({
264
+ name: 'schedule_cancel',
265
+ description: '取消定时任务',
266
+ inputSchema: z.object({ taskId: z.string().describe('任务 ID') }),
267
+ execute: async (args) => this._cancelTask(args.taskId),
268
+ });
438
269
 
270
+ framework.registerTool({
271
+ name: 'schedule_get',
272
+ description: '按 ID 获取单个定时任务的完整详情',
273
+ inputSchema: z.object({ taskId: z.string().describe('任务 ID') }),
274
+ execute: async (args) => {
275
+ const task = this._tasks.get(args.taskId);
276
+ if (!task) return { success: false, error: 'Task not found' };
277
+ let nextRun = null;
278
+ try { nextRun = task.scheduleHandle?.nextRun?.() || task.runAt || null; } catch {}
439
279
  return {
440
280
  success: true,
441
- tasks,
442
- total: tasks.length,
443
- stats: { ...this._taskStats }
444
- }
445
- }
446
- })
281
+ task: {
282
+ id: task.id,
283
+ name: task.name,
284
+ type: task.type,
285
+ message: task.message,
286
+ shell: task.shell || null,
287
+ enabled: task.enabled,
288
+ running: task.running,
289
+ nextRun,
290
+ runCount: task.runCount,
291
+ lastRun: task.lastRun,
292
+ createdAt: task.createdAt,
293
+ cronExpression: task.cronExpression,
294
+ runAt: task.runAt,
295
+ timezone: task.timezone,
296
+ llm: task.llm,
297
+ notify: task.notify,
298
+ sessionId: task.sessionId,
299
+ },
300
+ };
301
+ },
302
+ });
447
303
 
448
304
  framework.registerTool({
449
- name: 'schedule_cancel',
450
- description: '取消定时任务',
305
+ name: 'schedule_update',
306
+ description: '修改已存在的定时任务。可以修改:name/message/scheduleTime/cronExpression/sessionId/llm/notify/shell。如果改了 scheduleTime 或 cronExpression,会自动重新创建调度。runCount/lastRun/createdAt 保持不变。',
451
307
  inputSchema: z.object({
452
- taskId: z.string().describe('任务 ID')
308
+ taskId: z.string().describe('要修改的任务 ID'),
309
+ name: z.string().optional().describe('新任务名称'),
310
+ message: z.string().optional().describe('新提醒消息'),
311
+ scheduleTime: z.string().optional().describe('新时间(覆盖原 scheduleTime)。格式同 schedule_task'),
312
+ cronExpression: z.string().optional().describe('新 Cron 表达式(仅 cron 任务有效,优先级高于 scheduleTime)'),
313
+ sessionId: z.string().optional().describe('新会话 ID'),
314
+ llm: z.boolean().optional().describe('是否需要 LLM 处理'),
315
+ notify: z.boolean().optional().describe('是否发送通知'),
316
+ shell: z.string().optional().describe('新的 shell 命令(覆盖旧值)'),
317
+ enabled: z.boolean().optional().describe('启用/禁用任务(不删除)'),
453
318
  }),
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
- })
319
+ execute: async (args) => this._updateTask(args),
320
+ });
476
321
 
477
322
  framework.registerTool({
478
323
  name: 'cron_examples',
479
324
  description: '获取常用 Cron 表达式示例',
480
325
  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
- ]
326
+ execute: async () => ({
327
+ examples: [
328
+ { expression: '* * * * *', description: '每分钟' },
329
+ { expression: '*/5 * * * *', description: '每5分钟' },
330
+ { expression: '*/15 * * * *', description: '每15分钟' },
331
+ { expression: '0 * * * *', description: '每小时' },
332
+ { expression: '0 9 * * *', description: '每天早上9点' },
333
+ { expression: '0 12 * * *', description: '每天中午12点' },
334
+ { expression: '0 18 * * *', description: '每天下午6点' },
335
+ { expression: '0 9 * * 1-5', description: '工作日上午9点' },
336
+ { expression: '0 9 * * 0,6', description: '周末上午9点' },
337
+ { expression: '0 9 * * 1', description: '每周一上午9点' },
338
+ { expression: '0 */2 * * *', description: '每2小时' },
339
+ ],
340
+ }),
341
+ });
342
+
343
+ this._loadPersistedTasks();
344
+ return this;
345
+ }
346
+
347
+ // ============================================================
348
+ // 任务创建(统一入口,shell 与普通合并)
349
+ // ============================================================
350
+ async _createTask(args) {
351
+ try {
352
+ const { scheduleTime, message, repeat, cronExpression, sessionId, shell } = args;
353
+
354
+ // 解析时间
355
+ const isCron = isCronString(scheduleTime);
356
+ let taskType; // 'cron' | 'once'
357
+ let runAt = null;
358
+ let cron = null;
359
+
360
+ if (isCron || repeat) {
361
+ taskType = 'cron';
362
+ cron = cronExpression || scheduleTime;
363
+ } else if (scheduleTime.includes(':')) {
364
+ taskType = 'once';
365
+ runAt = parseAtTime(scheduleTime);
366
+ if (isNaN(runAt.getTime())) {
367
+ return { success: false, error: `无法解析时间: ${scheduleTime}` };
368
+ }
369
+ } else {
370
+ const delayMs = parseDelay(scheduleTime);
371
+ if (!delayMs) {
372
+ return { success: false, error: '无效的时间格式' };
496
373
  }
374
+ taskType = 'once';
375
+ runAt = new Date(Date.now() + delayMs);
497
376
  }
498
- })
499
377
 
500
- this._startScheduler()
378
+ // shell 任务的实际执行受 _runShell 内的安全策略控制:必须通过已注册的 shell 工具
379
+ if (shell && !this._hasShellTool()) {
380
+ // 创建时仍允许;执行时会被 _runShell 拒绝(fail-closed)
381
+ log.warn(` Shell 任务 ${args.name} 创建时未检测到 shell_exec/shell 工具,触发时将失败`);
382
+ }
501
383
 
502
- this._loadPersistedTasks()
384
+ // 解析 sessionId
385
+ let targetSessionId = sessionId;
386
+ if (!targetSessionId) {
387
+ const ctx = this._framework?.getExecutionContext?.();
388
+ if (ctx?.sessionId) targetSessionId = ctx.sessionId;
389
+ }
390
+ if (!targetSessionId) {
391
+ const sessionPlugin = this._framework?.pluginManager?.get('session');
392
+ if (sessionPlugin) {
393
+ const sessions = sessionPlugin.listSessions();
394
+ if (sessions.length > 0) {
395
+ sessions.sort((a, b) => {
396
+ const aTime = a.lastActive ? new Date(a.lastActive).getTime() : 0;
397
+ const bTime = b.lastActive ? new Date(b.lastActive).getTime() : 0;
398
+ return bTime - aTime;
399
+ });
400
+ targetSessionId = sessions[0].id;
401
+ }
402
+ }
403
+ }
503
404
 
504
- return this
505
- }
405
+ const llmMode = shell ? false : detectLLM(message, args.llm);
406
+ const task = {
407
+ id: `task_${randomUUID()}`,
408
+ name: args.name || (shell ? 'ShellTask' : taskType === 'cron' ? 'CronTask' : 'Reminder'),
409
+ type: taskType,
410
+ message,
411
+ shell: shell || null,
412
+ enabled: true,
413
+ createdAt: new Date(),
414
+ lastRun: null,
415
+ runCount: 0,
416
+ cronExpression: cron,
417
+ runAt,
418
+ timezone: this.config.timezone,
419
+ sessionId: targetSessionId || null,
420
+ llm: llmMode,
421
+ notify: args.notify !== false,
422
+ running: false,
423
+ stopped: false, // 显式跟踪被 stop() 的状态(croner stop() 不清 handle 引用)
424
+ scheduleHandle: null,
425
+ };
426
+
427
+ // 创建 croner 调度
428
+ try {
429
+ if (taskType === 'cron') {
430
+ task.scheduleHandle = new Cron(cron, this._cronOptions(task), () => this._onFire(task));
431
+ } else {
432
+ // once:startAt 限定未来触发
433
+ task.scheduleHandle = new Cron(runAt, this._cronOptions(task), () => this._onFire(task));
434
+ }
435
+ } catch (err) {
436
+ return { success: false, error: `调度创建失败: ${err.message}` };
437
+ }
438
+
439
+ this._tasks.set(task.id, task);
440
+ this._schedulePersist();
506
441
 
507
- _startScheduler() {
508
- if (this._timer) {
509
- clearInterval(this._timer)
442
+ this._framework?.emit('scheduler:task_created', {
443
+ taskId: task.id,
444
+ taskName: task.name,
445
+ type: task.type,
446
+ scheduleTime,
447
+ cronExpression: task.cronExpression,
448
+ });
449
+
450
+ return {
451
+ success: true,
452
+ taskId: task.id,
453
+ name: task.name,
454
+ scheduleTime,
455
+ executeAt: task.runAt ? task.runAt.toISOString() : null,
456
+ cronExpression: task.cronExpression,
457
+ message: task.type === 'cron' ? '定时任务已创建 (重复执行)' : '提醒已设置',
458
+ sessionId: targetSessionId || 'default',
459
+ llm: llmMode,
460
+ notify: task.notify,
461
+ };
462
+ } catch (err) {
463
+ return { success: false, error: err.message };
510
464
  }
465
+ }
511
466
 
512
- this._timer = setInterval(() => {
513
- this._checkTasks()
514
- }, this.config.checkInterval)
467
+ _cronOptions(task) {
468
+ return {
469
+ timezone: this.config.timezone,
470
+ protect: true, // 防重叠:上一次还没跑完,跳过本次
471
+ catch: true, // catch-up:进程恢复后补触发错过的触发
472
+ name: `scheduler:${task.id}`,
473
+ unref: true, // 不阻止进程退出
474
+ };
515
475
  }
516
476
 
517
- _checkTasks() {
518
- for (const [id, task] of this._tasks) {
519
- if (!task.enabled) continue
477
+ // ============================================================
478
+ // 触发回调
479
+ // ============================================================
480
+ async _onFire(task) {
481
+ if (!task.enabled) return;
482
+ if (task.running) {
483
+ // 理论上 protect:true 已经处理过;这里再保险一次
484
+ log.warn(` 任务 ${task.name} 仍在执行中,跳过本次触发`);
485
+ return;
486
+ }
487
+ task.running = true;
488
+ task.lastRun = new Date();
489
+ task.runCount++;
490
+ try {
491
+ await this._executeTask(task);
492
+ } finally {
493
+ task.running = false;
520
494
  }
521
- }
522
495
 
523
- _saveTasks() {
524
- if (this._taskStore) {
525
- this._taskStore.saveTasks(Array.from(this._tasks.values()))
496
+ // once 任务触发一次后清理
497
+ if (task.type === 'once') {
498
+ this._cleanupTask(task.id);
499
+ } else {
500
+ // cron 任务只更新持久化(不写 runAt)
501
+ this._schedulePersist();
526
502
  }
527
503
  }
528
504
 
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
538
- }
539
-
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
- }
552
- }
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
- }
563
- }
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++
573
- }
505
+ async _executeTask(task) {
506
+ try {
507
+ // shell 执行
508
+ if (task.shell) {
509
+ return await this._runShell(task);
574
510
  }
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++
584
- }
511
+ // LLM 执行
512
+ if (task.llm) {
513
+ return await this._runLLM(task);
585
514
  }
515
+ // 普通提醒(直接发通知)
516
+ return await this._runNotification(task);
517
+ } catch (err) {
518
+ log.error(` Task ${task.name} failed:`, err.message);
519
+ this._framework?.emit('notification', {
520
+ title: `任务失败: ${task.name}`,
521
+ message: err.message,
522
+ source: 'scheduler',
523
+ level: 'error',
524
+ sessionId: task.sessionId,
525
+ timestamp: new Date(),
526
+ });
527
+ this._framework?.emit('scheduler:task_failed', {
528
+ taskId: task.id,
529
+ error: err.message,
530
+ });
531
+ return { error: err.message };
586
532
  }
587
533
  }
588
534
 
589
- _cancelTask(task) {
590
- if (task.timer) {
591
- clearTimeout(task.timer)
592
- task.timer = null
535
+ async _runShell(task) {
536
+ // 安全要求:shell 命令必须通过已注册的 shell 工具执行(这些工具应实现沙箱/白名单)
537
+ // 不再 fallback 到 child_process.exec,避免任意命令执行漏洞
538
+ const shellTool = this._hasShellTool() ? this._pickShellTool() : null;
539
+ if (!shellTool) {
540
+ const errMsg = '未检测到 shell_exec/shell 工具,拒绝执行 shell 任务(安全策略)';
541
+ log.error(` ${task.name}: ${errMsg}`);
542
+ this._framework?.emit('notification', {
543
+ title: `任务失败: ${task.name}`,
544
+ message: errMsg,
545
+ source: 'scheduler',
546
+ level: 'error',
547
+ sessionId: task.sessionId,
548
+ timestamp: new Date(),
549
+ });
550
+ this._framework?.emit('scheduler:task_failed', { taskId: task.id, error: errMsg });
551
+ return { error: errMsg };
593
552
  }
594
- if (task.cronTask) {
595
- task.cronTask.cancel()
596
- task.cronTask = null
553
+
554
+ let result;
555
+ try {
556
+ const raw = await this._framework.executeTool(shellTool, { command: task.shell });
557
+ // 适配不同 shell 工具的返回格式
558
+ if (raw?.error) {
559
+ result = { error: raw.error, stdout: raw.stdout, stderr: raw.stderr };
560
+ } else if (raw?.success === false) {
561
+ const msg = raw.exitCode !== undefined ? `退出码 ${raw.exitCode}: ${raw.stderr || ''}` : (raw.error || 'shell 执行失败');
562
+ result = { error: msg, stdout: raw.stdout, stderr: raw.stderr };
563
+ } else {
564
+ result = { output: raw.stdout || raw.output || '', stdout: raw.stdout, stderr: raw.stderr };
565
+ }
566
+ } catch (e) {
567
+ result = { error: e.message || String(e) };
597
568
  }
598
- task.enabled = false
599
- }
600
569
 
601
- async _executeTask(task) {
602
- task.lastRun = new Date()
603
- task.runCount++
604
- this._taskStats.running++
570
+ const output = result.error
571
+ ? `执行失败: ${result.error}`
572
+ : (result.output || result.stdout || JSON.stringify(result));
605
573
 
606
- 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++
574
+ if (task.notify !== false) {
575
+ this._framework?.emit('notification', {
576
+ title: task.name,
577
+ message: output,
578
+ source: 'scheduler',
579
+ level: result.error ? 'error' : 'info',
580
+ sessionId: task.sessionId,
581
+ timestamp: new Date(),
582
+ });
583
+ }
584
+ this._framework?.emit('scheduler:task_completed', { taskId: task.id, result: output });
585
+ return { success: true };
586
+ }
633
587
 
634
- const output = result.error ? `执行失败: ${result.error}` : (result.output || result.stdout || JSON.stringify(result))
635
- if (task.notify !== false) {
636
- this._framework.emit('notification', {
588
+ async _runLLM(task) {
589
+ const subAgent = this._getSchedulerSubAgent();
590
+ if (!subAgent) {
591
+ return { error: '无法创建 scheduler subagent' };
592
+ }
593
+ // B6 修复:用 promise-chain 互斥,把 LLM 任务串行化
594
+ // 即使两个不同任务同时触发 _onFire,它们的 subagent.chat() 也会排队执行
595
+ const work = async () => {
596
+ try {
597
+ const result = await subAgent.chat(task.message);
598
+ const responseText = result?.message;
599
+ if (task.notify !== false && responseText && responseText.trim()) {
600
+ this._framework?.emit('notification', {
637
601
  title: task.name,
638
- message: output,
602
+ message: responseText,
639
603
  source: 'scheduler',
640
- level: result.error ? 'error' : 'info',
604
+ level: 'info',
641
605
  sessionId: task.sessionId,
642
- timestamp: new Date()
643
- })
606
+ timestamp: new Date(),
607
+ });
608
+ this._framework?.emit('scheduler:reminder', {
609
+ taskId: task.id,
610
+ message: task.message,
611
+ time: task.runAt || null,
612
+ });
644
613
  }
645
-
646
- this._framework.emit('scheduler:task_completed', {
614
+ this._framework?.emit('scheduler:task_completed', {
647
615
  taskId: task.id,
648
- result: output
649
- })
616
+ result: responseText || task.message,
617
+ });
618
+ return { success: true };
619
+ } catch (e) {
620
+ const errMsg = e?.message || String(e);
621
+ log.error(` _runLLM ${task.name}:`, errMsg);
622
+ this._framework?.emit('notification', {
623
+ title: `任务失败: ${task.name}`,
624
+ message: errMsg,
625
+ source: 'scheduler',
626
+ level: 'error',
627
+ sessionId: task.sessionId,
628
+ timestamp: new Date(),
629
+ });
630
+ this._framework?.emit('scheduler:task_failed', { taskId: task.id, error: errMsg });
631
+ return { error: errMsg };
632
+ }
633
+ };
634
+ this._llmMutex = this._llmMutex.then(work, work);
635
+ return this._llmMutex;
636
+ }
650
637
 
651
- if (task.type === 'once' && !task.cronTask) {
652
- this._cleanupTask(task.id)
653
- }
638
+ async _runNotification(task) {
639
+ if (task.notify !== false) {
640
+ this._framework?.emit('notification', {
641
+ title: task.name,
642
+ message: task.message,
643
+ source: 'scheduler',
644
+ level: 'info',
645
+ sessionId: task.sessionId,
646
+ timestamp: new Date(),
647
+ });
648
+ this._framework?.emit('scheduler:reminder', {
649
+ taskId: task.id,
650
+ message: task.message,
651
+ time: task.runAt || null,
652
+ });
653
+ }
654
+ this._framework?.emit('scheduler:task_completed', {
655
+ taskId: task.id,
656
+ result: task.message,
657
+ });
658
+ return { success: true };
659
+ }
654
660
 
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
- })
661
+ // ============================================================
662
+ // 取消 & 清理
663
+ // ============================================================
664
+ _cancelTask(taskId) {
665
+ const task = this._tasks.get(taskId);
666
+ if (!task) return { success: false, error: 'Task not found' };
662
667
 
663
- const result = await schedulerAgent.chat(task.message)
668
+ const taskName = task.name;
669
+ try {
670
+ task.scheduleHandle?.stop();
671
+ } catch {}
672
+ task.enabled = false;
673
+ task.stopped = true;
674
+ this._tasks.delete(taskId);
675
+ this._schedulePersist();
676
+
677
+ this._framework?.emit('notification', {
678
+ title: '任务已取消',
679
+ message: `定时任务 "${taskName}" 已取消`,
680
+ source: 'scheduler',
681
+ level: 'info',
682
+ sessionId: task.sessionId,
683
+ timestamp: new Date(),
684
+ });
685
+
686
+ return { success: true, cancelled: taskId };
687
+ }
664
688
 
665
- this._taskStats.completed++
689
+ /**
690
+ * 修改任务。
691
+ * 关键规则:
692
+ * 1. 改 scheduleTime / cronExpression → 二阶段提交:先构造验证新 handle 成功,再 commit
693
+ * 2. 改 message / shell / llm / notify / sessionId / name / enabled → 仅更新字段
694
+ * 3. runCount / lastRun / createdAt / id 永远不变
695
+ * 4. type 字段(cron/once)不能跨类型切换——例如不能把 once 改成 cron(语义差异大),
696
+ * 这种情况下提示用户先 cancel 再 create
697
+ * 5. enable: false → set stopped=true + stop() handle;enable: true → 检查 stopped 重建 handle
698
+ */
699
+ _updateTask(args) {
700
+ const { taskId } = args;
701
+ const task = this._tasks.get(taskId);
702
+ if (!task) return { success: false, error: 'Task not found' };
703
+
704
+ const updated = {};
705
+ // 收集"非时间"变更(这些变更先做;时间变更走二阶段提交)
706
+ if (args.name !== undefined) { task.name = args.name; updated.name = args.name; }
707
+ if (args.message !== undefined) { task.message = args.message; updated.message = task.message; }
708
+ if (args.sessionId !== undefined) { task.sessionId = args.sessionId || null; updated.sessionId = task.sessionId; }
709
+ if (args.notify !== undefined) { task.notify = !!args.notify; updated.notify = task.notify; }
710
+ if (args.llm !== undefined) { task.llm = !!args.llm; updated.llm = task.llm; }
711
+ if (args.shell !== undefined) {
712
+ task.shell = args.shell || null;
713
+ // shell 与 llm 互斥
714
+ if (task.shell) { task.llm = false; updated.llm = false; }
715
+ updated.shell = task.shell;
716
+ }
666
717
 
667
- const responseText = result.message
718
+ // enabled 处理(影响 stopped 状态和后续重建)
719
+ if (args.enabled !== undefined) {
720
+ const wasEnabled = task.enabled;
721
+ task.enabled = !!args.enabled;
722
+ updated.enabled = task.enabled;
723
+ if (!task.enabled) {
724
+ // 暂停:stop handle,标记 stopped=true
725
+ try { task.scheduleHandle?.stop(); } catch {}
726
+ task.stopped = true;
727
+ } else if (!wasEnabled) {
728
+ // 从暂停恢复:标记为需要重建(在时间变更之后统一重建)
729
+ task.stopped = true; // 强制让下面"重建"分支执行
730
+ }
731
+ }
668
732
 
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
- }
733
+ // 时间变更(cronExpression / scheduleTime)— 二阶段提交
734
+ let newCron = task.cronExpression;
735
+ let newRunAt = task.runAt;
736
+ let typeChanged = false;
737
+ let timeValidationError = null;
684
738
 
685
- this._framework.emit('scheduler:task_completed', {
686
- taskId: task.id,
687
- result: responseText || task.message
688
- })
739
+ if (args.cronExpression !== undefined) {
740
+ // 显式 cron 表达式:必须是 cron 任务
741
+ if (task.type !== 'cron') {
742
+ timeValidationError = `无法把 ${task.type} 任务改为 cron 表达式(类型不兼容)。请先取消再创建。`;
689
743
  } 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
- })
744
+ newCron = args.cronExpression;
745
+ }
746
+ } else if (args.scheduleTime !== undefined) {
747
+ const st = args.scheduleTime;
748
+ const isCron = isCronString(st);
749
+ if (isCron) {
750
+ if (task.type !== 'cron') {
751
+ timeValidationError = `无法把 ${task.type} 任务改为 cron 表达式(类型不兼容)。请先取消再创建。`;
752
+ } else {
753
+ newCron = st;
754
+ }
755
+ } else {
756
+ if (task.type !== 'once') {
757
+ timeValidationError = `无法把 ${task.type} 任务改为一次性时间(类型不兼容)。请先取消再创建。`;
758
+ } else {
759
+ // 解析 runAt
760
+ let parsed;
761
+ if (st.includes(':')) {
762
+ parsed = parseAtTime(st);
763
+ } else {
764
+ const delayMs = parseDelay(st);
765
+ if (!delayMs) {
766
+ timeValidationError = `无效的时间格式: ${st}`;
767
+ } else {
768
+ parsed = new Date(Date.now() + delayMs);
769
+ }
770
+ }
771
+ if (parsed && isNaN(parsed.getTime())) {
772
+ timeValidationError = `无法解析时间: ${st}`;
773
+ } else if (parsed) {
774
+ if (task.runCount > 0) {
775
+ timeValidationError = '一次性任务已执行过,无法修改时间。请取消后重建。';
776
+ } else {
777
+ newRunAt = parsed;
778
+ }
779
+ }
704
780
  }
705
-
706
- this._framework.emit('scheduler:task_completed', {
707
- taskId: task.id,
708
- result: task.message
709
- })
710
- this._taskStats.completed++
711
781
  }
782
+ }
783
+
784
+ if (timeValidationError) {
785
+ return { success: false, error: timeValidationError };
786
+ }
712
787
 
713
- if (task.type === 'once' && !task.cronTask) {
714
- this._cleanupTask(task.id)
788
+ // 二阶段提交:先尝试构造新 handle 验证
789
+ let newHandle = null;
790
+ const handleChanged =
791
+ newCron !== task.cronExpression ||
792
+ newRunAt !== task.runAt ||
793
+ (args.enabled !== undefined && task.enabled === true && task.stopped);
794
+
795
+ if (handleChanged && task.enabled) {
796
+ try {
797
+ if (task.type === 'cron') {
798
+ newHandle = new Cron(newCron, this._cronOptions(task), () => this._onFire(task));
799
+ } else {
800
+ newHandle = new Cron(newRunAt, this._cronOptions(task), () => this._onFire(task));
801
+ }
802
+ } catch (err) {
803
+ return { success: false, error: `新调度表达式无效: ${err.message}` };
715
804
  }
805
+ }
716
806
 
717
- return { success: true }
718
- } catch (err) {
719
- this._taskStats.failed++
720
- log.error(` Task ${task.name} failed: ${err.message}`)
807
+ // 验证通过,commit
808
+ if (newHandle) {
809
+ try { task.scheduleHandle?.stop(); } catch {}
810
+ task.scheduleHandle = newHandle;
811
+ task.cronExpression = newCron;
812
+ task.runAt = newRunAt;
813
+ task.stopped = false;
814
+ if (args.cronExpression !== undefined || (args.scheduleTime !== undefined && isCronString(args.scheduleTime))) {
815
+ updated.cronExpression = task.cronExpression;
816
+ }
817
+ if (args.scheduleTime !== undefined && !isCronString(args.scheduleTime)) {
818
+ updated.runAt = task.runAt.toISOString();
819
+ }
820
+ } else if (args.enabled !== undefined && task.enabled) {
821
+ // enable: true 但 handle 没变(任务本来就是 enabled);无需操作
822
+ }
721
823
 
722
- this._framework.emit('notification', {
723
- title: `任务失败: ${task.name}`,
724
- message: err.message,
725
- source: 'scheduler',
726
- level: 'error',
824
+ this._tasks.set(task.id, task);
825
+ this._schedulePersist();
826
+
827
+ let nextRun = null;
828
+ try { nextRun = task.scheduleHandle?.nextRun?.() || task.runAt || null; } catch {}
829
+
830
+ return {
831
+ success: true,
832
+ taskId: task.id,
833
+ updated,
834
+ task: {
835
+ id: task.id,
836
+ name: task.name,
837
+ type: task.type,
838
+ message: task.message,
839
+ shell: task.shell || null,
840
+ enabled: task.enabled,
841
+ stopped: task.stopped,
842
+ llm: task.llm,
843
+ notify: task.notify,
727
844
  sessionId: task.sessionId,
728
- timestamp: new Date()
729
- })
845
+ cronExpression: task.cronExpression,
846
+ runAt: task.runAt,
847
+ nextRun,
848
+ },
849
+ };
850
+ }
730
851
 
731
- this._framework.emit('scheduler:task_failed', {
732
- taskId: task.id,
733
- error: err.message
734
- })
852
+ _cleanupTask(taskId) {
853
+ const task = this._tasks.get(taskId);
854
+ if (!task) return;
855
+ try {
856
+ task.scheduleHandle?.stop();
857
+ } catch {}
858
+ this._tasks.delete(taskId);
859
+ this._schedulePersist();
860
+ }
735
861
 
736
- if (task.type === 'once' && !task.cronTask) {
737
- this._cleanupTask(task.id)
738
- }
862
+ // ============================================================
863
+ // 持久化(debounce 500ms + 原子写)
864
+ // ============================================================
865
+ _schedulePersist() {
866
+ this._persistDirty = true;
867
+ if (this._persistTimer) return;
868
+ this._persistTimer = setTimeout(() => {
869
+ this._persistTimer = null;
870
+ if (!this._persistDirty) return;
871
+ this._persistDirty = false;
872
+ this._taskStore.saveTasks(Array.from(this._tasks.values()));
873
+ }, PERSIST_DEBOUNCE_MS);
874
+ this._persistTimer.unref?.();
875
+ }
739
876
 
740
- return { error: err.message }
741
- } finally {
742
- this._taskStats.running--
877
+ _flushPersist() {
878
+ if (this._persistTimer) {
879
+ clearTimeout(this._persistTimer);
880
+ this._persistTimer = null;
881
+ }
882
+ if (this._persistDirty) {
883
+ this._persistDirty = false;
884
+ this._taskStore.saveTasks(Array.from(this._tasks.values()));
743
885
  }
744
886
  }
745
887
 
746
- _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
888
+ // ============================================================
889
+ // 启动时从持久化恢复(含过期补触发)
890
+ // ============================================================
891
+ _loadPersistedTasks() {
892
+ const savedTasks = this._taskStore.loadTasks();
893
+ if (!savedTasks || savedTasks.length === 0) return;
894
+ const now = Date.now();
895
+
896
+ for (const saved of savedTasks) {
897
+ // 已完成的 once 任务不再恢复
898
+ if (saved.type === 'once' && (saved.runCount || 0) > 0) continue;
899
+
900
+ const task = {
901
+ ...saved,
902
+ createdAt: saved.createdAt ? new Date(saved.createdAt) : new Date(),
903
+ lastRun: saved.lastRun ? new Date(saved.lastRun) : null,
904
+ runAt: saved.runAt ? new Date(saved.runAt) : null,
905
+ running: false,
906
+ stopped: false,
907
+ scheduleHandle: null,
908
+ };
909
+
910
+ try {
911
+ if (task.type === 'cron' && task.enabled && task.cronExpression) {
912
+ task.scheduleHandle = new Cron(task.cronExpression, this._cronOptions(task), () => this._onFire(task));
913
+ this._tasks.set(task.id, task);
914
+ } else if (task.type === 'once' && task.enabled) {
915
+ if (!task.runAt) continue;
916
+ const fireAt = task.runAt.getTime();
917
+ if (fireAt <= now) {
918
+ // 已过期:超过 grace period 就丢弃
919
+ if (now - fireAt > ONCE_EXPIRY_GRACE_MS) {
920
+ log.warn(` 丢弃过期任务 ${task.name}(已过期 ${Math.round((now - fireAt) / 1000)}s,超出 24h 容忍期)`);
921
+ continue;
922
+ }
923
+ // B4/B5 修复:catchup 也用 croner 调度过去时间,croner 会立即触发;
924
+ // stop() 能真正取消,update 走 stop()+new Cron() 不会双触发
925
+ // protect:false 是因为"补触发"不应被 startup 期间的并发阻塞
926
+ task.scheduleHandle = new Cron(task.runAt, {
927
+ ...this._cronOptions(task),
928
+ protect: false,
929
+ }, () => this._onFire(task));
930
+ this._tasks.set(task.id, task);
931
+ } else {
932
+ // 未来触发
933
+ task.scheduleHandle = new Cron(task.runAt, this._cronOptions(task), () => this._onFire(task));
934
+ this._tasks.set(task.id, task);
935
+ }
936
+ }
937
+ } catch (err) {
938
+ log.error(` 恢复任务失败: ${task.name}`, err.message);
756
939
  }
757
- this._tasks.delete(taskId)
758
- this._saveTasks()
759
940
  }
941
+ log.info(` 已恢复 ${this._tasks.size} 个定时任务`);
942
+ }
943
+
944
+ // ============================================================
945
+ // 辅助
946
+ // ============================================================
947
+ _hasShellTool() {
948
+ if (!this._framework?.toolRegistry) return false;
949
+ return this._framework.toolRegistry.has?.('shell_exec') || this._framework.toolRegistry.has?.('shell');
950
+ }
951
+
952
+ _pickShellTool() {
953
+ const reg = this._framework.toolRegistry;
954
+ if (reg.has?.('shell_exec')) return 'shell_exec';
955
+ if (reg.has?.('shell')) return 'shell';
956
+ return null;
760
957
  }
761
958
 
762
959
  stopAll() {
763
960
  for (const task of this._tasks.values()) {
764
- this._cancelTask(task)
765
- }
766
-
767
- if (this._timer) {
768
- clearInterval(this._timer)
769
- this._timer = null
961
+ try { task.scheduleHandle?.stop(); } catch {}
770
962
  }
771
-
772
- this._saveTasks()
963
+ this._tasks.clear();
964
+ this._flushPersist();
773
965
  }
774
966
 
775
967
  reload(framework) {
776
- this._tasks.clear()
777
- this._framework = framework
778
- this._agent = this._getAgent()
779
- this._startScheduler()
780
- this._loadPersistedTasks()
968
+ for (const task of this._tasks.values()) {
969
+ try { task.scheduleHandle?.stop(); } catch {}
970
+ }
971
+ this._tasks.clear();
972
+ this._schedulerSubAgent = null;
973
+ // 清掉未触发的 debounce,避免 reload 后旧 timer 用空 _tasks 写盘
974
+ if (this._persistTimer) {
975
+ clearTimeout(this._persistTimer);
976
+ this._persistTimer = null;
977
+ }
978
+ this._persistDirty = false;
979
+ this._llmMutex = Promise.resolve();
980
+ this._framework = framework;
981
+ this._agent = this._getAgent();
982
+ this._loadPersistedTasks();
781
983
  }
782
984
 
783
985
  uninstall(framework) {
784
- this.stopAll()
785
- this._tasks.clear()
786
- this._taskStats = { total: 0, running: 0, completed: 0, failed: 0 }
787
- this._framework = null
788
- this._agent = null
986
+ this.stopAll();
987
+ this._schedulerSubAgent = null;
988
+ this._framework = null;
989
+ this._agent = null;
789
990
  }
790
991
  }
791
992
 
792
- module.exports = SchedulerPlugin
993
+ module.exports = SchedulerPlugin;