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.
- package/.claude/settings.local.json +2 -1
- package/.editorconfig +56 -56
- package/.lintstagedrc +7 -7
- package/.prettierignore +29 -29
- package/.prettierrc +11 -11
- package/Dockerfile +63 -63
- package/docs/public-api.md +3 -3
- package/examples/workflow.js +62 -57
- package/install.ps1 +129 -129
- package/install.sh +121 -121
- package/package.json +2 -3
- package/plugins/core/scheduler/CHANGELOG.md +65 -0
- package/plugins/core/scheduler/index.js +822 -621
- package/plugins/core/workflow/context.js +941 -941
- package/plugins/core/workflow/engine.js +66 -66
- package/plugins/core/workflow/index.js +2 -2
- package/plugins/core/workflow/js-runner.js +318 -318
- package/plugins/core/workflow/json-runner.js +323 -323
- package/plugins/core/workflow/stages/choice.js +74 -74
- package/plugins/core/workflow/stages/each.js +123 -123
- package/plugins/core/workflow/stages/parallel.js +69 -69
- package/plugins/memory/index.js +10 -3
- package/skills/find-skills/SKILL.md +133 -133
- package/src/framework/framework.js +3 -1
- package/src/index.js +10 -0
- package/src/plugin/base.js +7 -4
|
@@ -1,792 +1,993 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Scheduler 定时任务调度插件
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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
|
|
10
|
-
const { z } = require('zod')
|
|
11
|
-
const fs = require('fs')
|
|
12
|
-
const path = require('path')
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
fs.
|
|
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
|
|
107
|
-
if (
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
return
|
|
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 = '
|
|
118
|
-
this.description = '
|
|
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.
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
this.
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
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(
|
|
165
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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: '
|
|
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('
|
|
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
|
-
|
|
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
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
508
|
-
|
|
509
|
-
|
|
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
|
-
|
|
513
|
-
|
|
514
|
-
|
|
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
|
-
|
|
518
|
-
|
|
519
|
-
|
|
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
|
-
|
|
524
|
-
if (
|
|
525
|
-
this.
|
|
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
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
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
|
-
|
|
576
|
-
|
|
577
|
-
|
|
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
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
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
|
-
|
|
595
|
-
|
|
596
|
-
|
|
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
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
this._taskStats.running++
|
|
570
|
+
const output = result.error
|
|
571
|
+
? `执行失败: ${result.error}`
|
|
572
|
+
: (result.output || result.stdout || JSON.stringify(result));
|
|
605
573
|
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
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
|
-
|
|
635
|
-
|
|
636
|
-
|
|
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:
|
|
602
|
+
message: responseText,
|
|
639
603
|
source: 'scheduler',
|
|
640
|
-
level:
|
|
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:
|
|
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
|
-
|
|
652
|
-
|
|
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
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
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
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
}
|
|
739
|
+
if (args.cronExpression !== undefined) {
|
|
740
|
+
// 显式 cron 表达式:必须是 cron 任务
|
|
741
|
+
if (task.type !== 'cron') {
|
|
742
|
+
timeValidationError = `无法把 ${task.type} 任务改为 cron 表达式(类型不兼容)。请先取消再创建。`;
|
|
689
743
|
} else {
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
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
|
-
|
|
714
|
-
|
|
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
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
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
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
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
|
-
|
|
729
|
-
|
|
845
|
+
cronExpression: task.cronExpression,
|
|
846
|
+
runAt: task.runAt,
|
|
847
|
+
nextRun,
|
|
848
|
+
},
|
|
849
|
+
};
|
|
850
|
+
}
|
|
730
851
|
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
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
|
-
|
|
737
|
-
|
|
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
|
-
|
|
741
|
-
|
|
742
|
-
this.
|
|
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
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
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
|
-
|
|
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.
|
|
963
|
+
this._tasks.clear();
|
|
964
|
+
this._flushPersist();
|
|
773
965
|
}
|
|
774
966
|
|
|
775
967
|
reload(framework) {
|
|
776
|
-
this._tasks.
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
this.
|
|
780
|
-
this.
|
|
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.
|
|
786
|
-
this.
|
|
787
|
-
this.
|
|
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;
|