foliko 2.0.8 → 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/package.json
CHANGED
|
@@ -1,5 +1,39 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2.0.2
|
|
4
|
+
|
|
5
|
+
### 修复 (bug fixes)
|
|
6
|
+
|
|
7
|
+
- **B1 [安全] shell 命令注入** — 移除 `child_process.exec` 兜底路径。shell 任务必须通过 `shell_exec` / `shell` 工具执行,工具不可用时返回错误而非 fallback 到任意命令执行
|
|
8
|
+
- **B2 [功能] enable: true 重新启用后调度失效** — 新增 `task.stopped` 字段显式跟踪 `stop()` 状态(croner 的 `stop()` 不清 handle 引用)。enable 路径检查 `stopped` 强制重建
|
|
9
|
+
- **B3 [功能] `_updateTask` 改坏 cron 表达式后任务半残化** — 改为二阶段提交:先 `new Cron(...)` 验证表达式合法,验证通过才 commit 到 `task`。避免半残状态
|
|
10
|
+
- **B4 [功能] cancel 取消不掉 catchup 触发** — catchup 路径从 `setTimeout` 改为 `new Cron(pastDate, ...)`,croner 的 `stop()` 能真正取消未来触发
|
|
11
|
+
- **B5 [功能] catchup + update 双触发** — 同上修复,统一走 croner handle;update 走 `stop()+new Cron()` 自然替换
|
|
12
|
+
- **B6 [并发] 共享 subagent 并发不安全** — LLM 任务改用 promise-chain 互斥(`_llmMutex`),不同任务排队执行,保护 subagent 内部状态
|
|
13
|
+
|
|
14
|
+
### 改进
|
|
15
|
+
|
|
16
|
+
- **M1 DRY** — `_createTask` 复用 `isCronString` helper,消除内联正则重复
|
|
17
|
+
- **M3** — `reload` 清掉未触发的 `_persistTimer` 和重置 `_llmMutex`,避免旧 timer 用空 `_tasks` 写盘
|
|
18
|
+
|
|
19
|
+
### 行为变更
|
|
20
|
+
|
|
21
|
+
- `_updateTask` 失败时不再修改 `task` 任何字段(包括 `task.cronExpression`),失败语义更安全
|
|
22
|
+
- `updated` 字段现在返回 `message` / `shell` 的真实值(之前是 `'[updated]'` 误导)
|
|
23
|
+
- `updated.task` 返回包含 `stopped` 字段
|
|
24
|
+
|
|
25
|
+
## 2.0.1
|
|
26
|
+
|
|
27
|
+
### 新增
|
|
28
|
+
|
|
29
|
+
- `schedule_get` 工具:按 ID 获取单个任务的完整详情(与 `schedule_list` 不同,会返回所有内部字段含 `running` / `createdAt` / `runAt` 等)
|
|
30
|
+
- `schedule_update` 工具:修改已存在的任务,可改 `name` / `message` / `scheduleTime` / `cronExpression` / `sessionId` / `llm` / `notify` / `shell` / `enabled`
|
|
31
|
+
- 改 `scheduleTime` / `cronExpression` 会自动 `stop()` 旧 croner handle 并重建
|
|
32
|
+
- 改 `shell` 会自动把 `llm` 置为 false(互斥)
|
|
33
|
+
- 跨类型切换(once → cron / cron → once)会被拒绝,提示先 cancel 再 create
|
|
34
|
+
- `runCount` / `lastRun` / `createdAt` / `id` 始终保留
|
|
35
|
+
- `enabled: false` 只停止调度不删除任务;`enabled: true` 会在必要时重建 handle
|
|
36
|
+
|
|
3
37
|
## 2.0.0
|
|
4
38
|
|
|
5
39
|
### Breaking Changes
|
|
@@ -138,6 +138,11 @@ function detectLLM(message, explicitFlag) {
|
|
|
138
138
|
return LLM_KEYWORDS.some((kw) => message.includes(kw) || lower.includes(kw.toLowerCase()));
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
+
function isCronString(str) {
|
|
142
|
+
if (!str) return false;
|
|
143
|
+
return /^[\d*,\/-\s]+$/.test(str) && str.trim().split(/\s+/).length >= 5;
|
|
144
|
+
}
|
|
145
|
+
|
|
141
146
|
// ============================================================
|
|
142
147
|
// SchedulerPlugin
|
|
143
148
|
// ============================================================
|
|
@@ -145,8 +150,8 @@ class SchedulerPlugin extends Plugin {
|
|
|
145
150
|
constructor(config = {}) {
|
|
146
151
|
super();
|
|
147
152
|
this.name = 'scheduler';
|
|
148
|
-
this.version = '2.0.
|
|
149
|
-
this.description = '定时任务调度 v2(croner 引擎,事件循环阻塞自愈 + 持久化 +
|
|
153
|
+
this.version = '2.0.2';
|
|
154
|
+
this.description = '定时任务调度 v2(croner 引擎,事件循环阻塞自愈 + 持久化 + 重叠保护 + 增删改查 + 并发安全)';
|
|
150
155
|
this.priority = 15;
|
|
151
156
|
this.system = true;
|
|
152
157
|
|
|
@@ -161,6 +166,8 @@ class SchedulerPlugin extends Plugin {
|
|
|
161
166
|
this._taskStore = new TaskStore(this.config.persistencePath);
|
|
162
167
|
// 复用的 LLM 执行 subagent(避免每次 tick 新建导致 _agents 数组漏内存)
|
|
163
168
|
this._schedulerSubAgent = null;
|
|
169
|
+
// LLM 任务互斥链:B6 修复,避免不同任务的 subagent.chat() 并发污染 subagent 内部状态
|
|
170
|
+
this._llmMutex = Promise.resolve();
|
|
164
171
|
// debounce 写入
|
|
165
172
|
this._persistTimer = null;
|
|
166
173
|
this._persistDirty = false;
|
|
@@ -260,6 +267,58 @@ class SchedulerPlugin extends Plugin {
|
|
|
260
267
|
execute: async (args) => this._cancelTask(args.taskId),
|
|
261
268
|
});
|
|
262
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 {}
|
|
279
|
+
return {
|
|
280
|
+
success: true,
|
|
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
|
+
});
|
|
303
|
+
|
|
304
|
+
framework.registerTool({
|
|
305
|
+
name: 'schedule_update',
|
|
306
|
+
description: '修改已存在的定时任务。可以修改:name/message/scheduleTime/cronExpression/sessionId/llm/notify/shell。如果改了 scheduleTime 或 cronExpression,会自动重新创建调度。runCount/lastRun/createdAt 保持不变。',
|
|
307
|
+
inputSchema: z.object({
|
|
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('启用/禁用任务(不删除)'),
|
|
318
|
+
}),
|
|
319
|
+
execute: async (args) => this._updateTask(args),
|
|
320
|
+
});
|
|
321
|
+
|
|
263
322
|
framework.registerTool({
|
|
264
323
|
name: 'cron_examples',
|
|
265
324
|
description: '获取常用 Cron 表达式示例',
|
|
@@ -293,7 +352,7 @@ class SchedulerPlugin extends Plugin {
|
|
|
293
352
|
const { scheduleTime, message, repeat, cronExpression, sessionId, shell } = args;
|
|
294
353
|
|
|
295
354
|
// 解析时间
|
|
296
|
-
const isCron =
|
|
355
|
+
const isCron = isCronString(scheduleTime);
|
|
297
356
|
let taskType; // 'cron' | 'once'
|
|
298
357
|
let runAt = null;
|
|
299
358
|
let cron = null;
|
|
@@ -316,9 +375,10 @@ class SchedulerPlugin extends Plugin {
|
|
|
316
375
|
runAt = new Date(Date.now() + delayMs);
|
|
317
376
|
}
|
|
318
377
|
|
|
319
|
-
// shell
|
|
378
|
+
// shell 任务的实际执行受 _runShell 内的安全策略控制:必须通过已注册的 shell 工具
|
|
320
379
|
if (shell && !this._hasShellTool()) {
|
|
321
|
-
//
|
|
380
|
+
// 创建时仍允许;执行时会被 _runShell 拒绝(fail-closed)
|
|
381
|
+
log.warn(` Shell 任务 ${args.name} 创建时未检测到 shell_exec/shell 工具,触发时将失败`);
|
|
322
382
|
}
|
|
323
383
|
|
|
324
384
|
// 解析 sessionId
|
|
@@ -360,6 +420,7 @@ class SchedulerPlugin extends Plugin {
|
|
|
360
420
|
llm: llmMode,
|
|
361
421
|
notify: args.notify !== false,
|
|
362
422
|
running: false,
|
|
423
|
+
stopped: false, // 显式跟踪被 stop() 的状态(croner stop() 不清 handle 引用)
|
|
363
424
|
scheduleHandle: null,
|
|
364
425
|
};
|
|
365
426
|
|
|
@@ -472,9 +533,26 @@ class SchedulerPlugin extends Plugin {
|
|
|
472
533
|
}
|
|
473
534
|
|
|
474
535
|
async _runShell(task) {
|
|
475
|
-
|
|
536
|
+
// 安全要求:shell 命令必须通过已注册的 shell 工具执行(这些工具应实现沙箱/白名单)
|
|
537
|
+
// 不再 fallback 到 child_process.exec,避免任意命令执行漏洞
|
|
476
538
|
const shellTool = this._hasShellTool() ? this._pickShellTool() : null;
|
|
477
|
-
if (shellTool) {
|
|
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 };
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
let result;
|
|
555
|
+
try {
|
|
478
556
|
const raw = await this._framework.executeTool(shellTool, { command: task.shell });
|
|
479
557
|
// 适配不同 shell 工具的返回格式
|
|
480
558
|
if (raw?.error) {
|
|
@@ -485,17 +563,8 @@ class SchedulerPlugin extends Plugin {
|
|
|
485
563
|
} else {
|
|
486
564
|
result = { output: raw.stdout || raw.output || '', stdout: raw.stdout, stderr: raw.stderr };
|
|
487
565
|
}
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
const { exec } = require('child_process');
|
|
491
|
-
const util = require('util');
|
|
492
|
-
const execAsync = util.promisify(exec);
|
|
493
|
-
try {
|
|
494
|
-
const { stdout, stderr } = await execAsync(task.shell, { timeout: 60000 });
|
|
495
|
-
result = { output: stdout.trim(), stdout: stdout.trim(), stderr: (stderr || '').trim() };
|
|
496
|
-
} catch (e) {
|
|
497
|
-
result = { error: e.message, stderr: e.stderr || '' };
|
|
498
|
-
}
|
|
566
|
+
} catch (e) {
|
|
567
|
+
result = { error: e.message || String(e) };
|
|
499
568
|
}
|
|
500
569
|
|
|
501
570
|
const output = result.error
|
|
@@ -521,28 +590,49 @@ class SchedulerPlugin extends Plugin {
|
|
|
521
590
|
if (!subAgent) {
|
|
522
591
|
return { error: '无法创建 scheduler subagent' };
|
|
523
592
|
}
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
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', {
|
|
601
|
+
title: task.name,
|
|
602
|
+
message: responseText,
|
|
603
|
+
source: 'scheduler',
|
|
604
|
+
level: 'info',
|
|
605
|
+
sessionId: task.sessionId,
|
|
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
|
+
});
|
|
613
|
+
}
|
|
614
|
+
this._framework?.emit('scheduler:task_completed', {
|
|
615
|
+
taskId: task.id,
|
|
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;
|
|
546
636
|
}
|
|
547
637
|
|
|
548
638
|
async _runNotification(task) {
|
|
@@ -580,6 +670,7 @@ class SchedulerPlugin extends Plugin {
|
|
|
580
670
|
task.scheduleHandle?.stop();
|
|
581
671
|
} catch {}
|
|
582
672
|
task.enabled = false;
|
|
673
|
+
task.stopped = true;
|
|
583
674
|
this._tasks.delete(taskId);
|
|
584
675
|
this._schedulePersist();
|
|
585
676
|
|
|
@@ -595,6 +686,169 @@ class SchedulerPlugin extends Plugin {
|
|
|
595
686
|
return { success: true, cancelled: taskId };
|
|
596
687
|
}
|
|
597
688
|
|
|
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
|
+
}
|
|
717
|
+
|
|
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
|
+
}
|
|
732
|
+
|
|
733
|
+
// 时间变更(cronExpression / scheduleTime)— 二阶段提交
|
|
734
|
+
let newCron = task.cronExpression;
|
|
735
|
+
let newRunAt = task.runAt;
|
|
736
|
+
let typeChanged = false;
|
|
737
|
+
let timeValidationError = null;
|
|
738
|
+
|
|
739
|
+
if (args.cronExpression !== undefined) {
|
|
740
|
+
// 显式 cron 表达式:必须是 cron 任务
|
|
741
|
+
if (task.type !== 'cron') {
|
|
742
|
+
timeValidationError = `无法把 ${task.type} 任务改为 cron 表达式(类型不兼容)。请先取消再创建。`;
|
|
743
|
+
} else {
|
|
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
|
+
}
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
if (timeValidationError) {
|
|
785
|
+
return { success: false, error: timeValidationError };
|
|
786
|
+
}
|
|
787
|
+
|
|
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}` };
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
|
|
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
|
+
}
|
|
823
|
+
|
|
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,
|
|
844
|
+
sessionId: task.sessionId,
|
|
845
|
+
cronExpression: task.cronExpression,
|
|
846
|
+
runAt: task.runAt,
|
|
847
|
+
nextRun,
|
|
848
|
+
},
|
|
849
|
+
};
|
|
850
|
+
}
|
|
851
|
+
|
|
598
852
|
_cleanupTask(taskId) {
|
|
599
853
|
const task = this._tasks.get(taskId);
|
|
600
854
|
if (!task) return;
|
|
@@ -638,7 +892,6 @@ class SchedulerPlugin extends Plugin {
|
|
|
638
892
|
const savedTasks = this._taskStore.loadTasks();
|
|
639
893
|
if (!savedTasks || savedTasks.length === 0) return;
|
|
640
894
|
const now = Date.now();
|
|
641
|
-
const expiredImmediate = []; // 需要立即补触发的过期任务
|
|
642
895
|
|
|
643
896
|
for (const saved of savedTasks) {
|
|
644
897
|
// 已完成的 once 任务不再恢复
|
|
@@ -650,6 +903,7 @@ class SchedulerPlugin extends Plugin {
|
|
|
650
903
|
lastRun: saved.lastRun ? new Date(saved.lastRun) : null,
|
|
651
904
|
runAt: saved.runAt ? new Date(saved.runAt) : null,
|
|
652
905
|
running: false,
|
|
906
|
+
stopped: false,
|
|
653
907
|
scheduleHandle: null,
|
|
654
908
|
};
|
|
655
909
|
|
|
@@ -661,16 +915,19 @@ class SchedulerPlugin extends Plugin {
|
|
|
661
915
|
if (!task.runAt) continue;
|
|
662
916
|
const fireAt = task.runAt.getTime();
|
|
663
917
|
if (fireAt <= now) {
|
|
664
|
-
// 已过期:超过 grace period
|
|
918
|
+
// 已过期:超过 grace period 就丢弃
|
|
665
919
|
if (now - fireAt > ONCE_EXPIRY_GRACE_MS) {
|
|
666
920
|
log.warn(` 丢弃过期任务 ${task.name}(已过期 ${Math.round((now - fireAt) / 1000)}s,超出 24h 容忍期)`);
|
|
667
921
|
continue;
|
|
668
922
|
}
|
|
669
|
-
//
|
|
670
|
-
|
|
671
|
-
//
|
|
672
|
-
|
|
673
|
-
|
|
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);
|
|
674
931
|
} else {
|
|
675
932
|
// 未来触发
|
|
676
933
|
task.scheduleHandle = new Cron(task.runAt, this._cronOptions(task), () => this._onFire(task));
|
|
@@ -713,6 +970,13 @@ class SchedulerPlugin extends Plugin {
|
|
|
713
970
|
}
|
|
714
971
|
this._tasks.clear();
|
|
715
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();
|
|
716
980
|
this._framework = framework;
|
|
717
981
|
this._agent = this._getAgent();
|
|
718
982
|
this._loadPersistedTasks();
|