metame-cli 1.4.12 → 1.4.13

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.
@@ -0,0 +1,539 @@
1
+ 'use strict';
2
+
3
+ function createTaskScheduler(deps) {
4
+ const {
5
+ fs,
6
+ path,
7
+ HOME,
8
+ CLAUDE_BIN,
9
+ spawn,
10
+ execSync,
11
+ execFileSync,
12
+ parseInterval,
13
+ loadState,
14
+ saveState,
15
+ checkBudget,
16
+ recordTokens,
17
+ buildProfilePreamble,
18
+ getDaemonProviderEnv,
19
+ log,
20
+ physiologicalHeartbeat,
21
+ isUserIdle,
22
+ isInSleepMode,
23
+ setSleepMode,
24
+ spawnSessionSummaries,
25
+ skillEvolution,
26
+ } = deps;
27
+
28
+ function checkPrecondition(task) {
29
+ if (!task.precondition) return { pass: true, context: '' };
30
+
31
+ try {
32
+ const output = execSync(task.precondition, {
33
+ encoding: 'utf8',
34
+ timeout: 15000,
35
+ maxBuffer: 64 * 1024,
36
+ }).trim();
37
+
38
+ if (!output) {
39
+ log('INFO', `Precondition empty for ${task.name}, skipping (zero tokens)`);
40
+ return { pass: false, context: '' };
41
+ }
42
+
43
+ log('INFO', `Precondition passed for ${task.name} (${output.split('\n').length} lines)`);
44
+ return { pass: true, context: output };
45
+ } catch (e) {
46
+ // Non-zero exit = precondition failed
47
+ log('INFO', `Precondition failed for ${task.name}: ${e.message.slice(0, 100)}`);
48
+ return { pass: false, context: '' };
49
+ }
50
+ }
51
+
52
+ // Timeout compatibility:
53
+ // - numeric values <= 10000 are treated as seconds (recommended)
54
+ // - numeric values > 10000 are treated as legacy milliseconds
55
+ // - string values like "500ms", "30s", "5m", "1h" are supported
56
+ function resolveTimeoutMs(raw, defaultSeconds) {
57
+ if (typeof raw === 'string') {
58
+ const m = raw.trim().match(/^(\d+(?:\.\d+)?)(ms|s|m|h)$/i);
59
+ if (m) {
60
+ const v = Number(m[1]);
61
+ const u = m[2].toLowerCase();
62
+ if (u === 'ms') return Math.max(1, Math.floor(v));
63
+ if (u === 's') return Math.max(1, Math.floor(v * 1000));
64
+ if (u === 'm') return Math.max(1, Math.floor(v * 60 * 1000));
65
+ if (u === 'h') return Math.max(1, Math.floor(v * 60 * 60 * 1000));
66
+ }
67
+ }
68
+ const n = Number(raw);
69
+ if (!Number.isFinite(n) || n <= 0) return defaultSeconds * 1000;
70
+ if (n > 10000) return Math.floor(n); // legacy ms
71
+ return Math.floor(n * 1000); // default seconds
72
+ }
73
+
74
+ function maybeSaveTaskMemory(task, output, tokenCost = 0, sessionId = '') {
75
+ if (!task || !task.memory_log) return;
76
+ try {
77
+ const memory = require('./memory');
78
+ const nowIso = new Date().toISOString();
79
+ const projectKey = (task._project && task._project.key) || 'heartbeat';
80
+ const memoryId = `${task.name}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
81
+ const summaryText = String(output || '(no output)').trim() || '(no output)';
82
+ const summary = [
83
+ `[heartbeat task] ${task.name}`,
84
+ sessionId ? `session: ${sessionId}` : '',
85
+ summaryText,
86
+ ].filter(Boolean).join('\n').slice(0, 8000);
87
+ const keywords = [task.name, 'heartbeat', 'evolution', nowIso.slice(0, 10)].join(',');
88
+ memory.saveSession({
89
+ sessionId: memoryId,
90
+ project: projectKey,
91
+ summary,
92
+ keywords,
93
+ mood: '',
94
+ tokenCost: Number(tokenCost) || 0,
95
+ });
96
+ memory.close();
97
+ log('INFO', `Task ${task.name}: memory_log saved (${memoryId})`);
98
+ } catch (e) {
99
+ log('WARN', `Task ${task.name}: memory_log failed: ${e.message}`);
100
+ }
101
+ }
102
+
103
+ function executeTask(task, config) {
104
+ if (task.enabled === false) {
105
+ log('INFO', `Skipping disabled task: ${task.name}`);
106
+ return { success: true, output: '(disabled)', skipped: true };
107
+ }
108
+
109
+ const state = loadState();
110
+
111
+ if (!checkBudget(config, state)) {
112
+ log('WARN', `Budget exceeded, skipping task: ${task.name}`);
113
+ return { success: false, error: 'budget_exceeded', output: '' };
114
+ }
115
+
116
+ // Precondition gate: run a cheap shell check before burning tokens
117
+ const precheck = checkPrecondition(task);
118
+ if (!precheck.pass) {
119
+ state.tasks[task.name] = {
120
+ last_run: new Date().toISOString(),
121
+ status: 'skipped',
122
+ output_preview: 'Precondition not met — no activity',
123
+ };
124
+ saveState(state);
125
+ return { success: true, output: '(skipped — no activity)', skipped: true };
126
+ }
127
+
128
+ // Workflow tasks: multi-step skill chain via --resume session
129
+ if (task.type === 'workflow') {
130
+ return executeWorkflow(task, config);
131
+ }
132
+
133
+ // Script tasks: run a local script directly (e.g. distill.js), no claude -p
134
+ if (task.type === 'script') {
135
+ log('INFO', `Executing script task: ${task.name} → ${task.command}`);
136
+ try {
137
+ const scriptEnv = { ...process.env, METAME_ROOT: process.env.METAME_ROOT || '' };
138
+ delete scriptEnv.CLAUDECODE;
139
+ const output = execSync(task.command, {
140
+ encoding: 'utf8',
141
+ timeout: resolveTimeoutMs(task.timeout, 120),
142
+ maxBuffer: 1024 * 1024,
143
+ env: scriptEnv,
144
+ }).trim();
145
+
146
+ state.tasks[task.name] = {
147
+ last_run: new Date().toISOString(),
148
+ status: 'success',
149
+ output_preview: output.slice(0, 200),
150
+ };
151
+ saveState(state);
152
+ if (output) log('INFO', `Script task ${task.name} completed: ${output.slice(0, 300)}`);
153
+ else log('INFO', `Script task ${task.name} completed`);
154
+ return { success: true, output, tokens: 0 };
155
+ } catch (e) {
156
+ log('ERROR', `Script task ${task.name} failed: ${e.message}`);
157
+ state.tasks[task.name] = {
158
+ last_run: new Date().toISOString(),
159
+ status: 'error',
160
+ error: e.message.slice(0, 200),
161
+ };
162
+ saveState(state);
163
+ return { success: false, error: e.message, output: '' };
164
+ }
165
+ }
166
+
167
+ const preamble = buildProfilePreamble();
168
+ const model = task.model || 'haiku';
169
+ // If precondition returned context data, append it to the prompt
170
+ let taskPrompt = task.prompt;
171
+ if (precheck.context) {
172
+ taskPrompt += `\n\n以下是相关原始数据:\n\`\`\`\n${precheck.context}\n\`\`\``;
173
+ }
174
+ const fullPrompt = preamble + taskPrompt;
175
+
176
+ const claudeArgs = ['-p', '--model', model, '--dangerously-skip-permissions'];
177
+ for (const t of (task.allowedTools || [])) claudeArgs.push('--allowedTools', t);
178
+ // Auto-detect MCP config in task cwd or project directory
179
+ const cwd = task.cwd ? task.cwd.replace(/^~/, HOME) : undefined;
180
+ const mcpConfig = task.mcp_config
181
+ ? path.resolve(task.mcp_config.replace(/^~/, HOME))
182
+ : cwd && fs.existsSync(path.join(cwd, '.mcp.json'))
183
+ ? path.join(cwd, '.mcp.json')
184
+ : null;
185
+ if (mcpConfig) claudeArgs.push('--mcp-config', mcpConfig);
186
+
187
+ // Persistent session: reuse same session across runs (for tasks like weekly-review)
188
+ if (task.persistent_session) {
189
+ const meta = state.tasks[task.name] || {};
190
+ const savedSessionId = meta.session_id;
191
+ const rotateDays = Number(task.persistent_session_rotate_days || 0);
192
+ const rotateMs = Number.isFinite(rotateDays) && rotateDays > 0
193
+ ? rotateDays * 24 * 60 * 60 * 1000
194
+ : 0;
195
+ let createdAtIso = meta.session_created_at || '';
196
+ // Backfill legacy state so old persistent sessions don't rotate immediately after upgrade.
197
+ if (!createdAtIso && savedSessionId) {
198
+ createdAtIso = meta.last_run || new Date().toISOString();
199
+ if (!state.tasks[task.name]) state.tasks[task.name] = {};
200
+ state.tasks[task.name].session_created_at = createdAtIso;
201
+ saveState(state);
202
+ }
203
+ const createdAtMs = createdAtIso ? new Date(createdAtIso).getTime() : 0;
204
+ const shouldRotate = !!(
205
+ savedSessionId &&
206
+ rotateMs > 0 &&
207
+ (!createdAtMs || (Date.now() - createdAtMs) >= rotateMs)
208
+ );
209
+
210
+ if (savedSessionId && !shouldRotate) {
211
+ claudeArgs.push('--resume', savedSessionId);
212
+ log('INFO', `Executing task: ${task.name} (model: ${model}, resuming session ${savedSessionId.slice(0, 8)}${mcpConfig ? ', mcp: ' + path.basename(mcpConfig) : ''})`);
213
+ } else {
214
+ const newSessionId = crypto.randomUUID();
215
+ claudeArgs.push('--session-id', newSessionId);
216
+ if (!state.tasks[task.name]) state.tasks[task.name] = {};
217
+ state.tasks[task.name].session_id = newSessionId;
218
+ state.tasks[task.name].session_created_at = new Date().toISOString();
219
+ saveState(state);
220
+ if (savedSessionId && shouldRotate) {
221
+ log('INFO', `Executing task: ${task.name} (model: ${model}, rotated session ${savedSessionId.slice(0, 8)} -> ${newSessionId.slice(0, 8)}${mcpConfig ? ', mcp: ' + path.basename(mcpConfig) : ''})`);
222
+ } else {
223
+ log('INFO', `Executing task: ${task.name} (model: ${model}, new session ${newSessionId.slice(0, 8)}${mcpConfig ? ', mcp: ' + path.basename(mcpConfig) : ''})`);
224
+ }
225
+ }
226
+ } else {
227
+ log('INFO', `Executing task: ${task.name} (model: ${model}${mcpConfig ? ', mcp: ' + path.basename(mcpConfig) : ''})`);
228
+ }
229
+
230
+ // Use spawnClaudeAsync (non-blocking spawn with process-group kill) instead of
231
+ // execFileSync (sync, blocks event loop, can't kill sub-agents).
232
+ // executeTask now returns a Promise — callers must handle it with .then() or await.
233
+ const timeoutMs = resolveTimeoutMs(task.timeout, 120);
234
+ const asyncArgs = [...claudeArgs];
235
+ const asyncEnv = { ...process.env, ...getDaemonProviderEnv(), CLAUDECODE: undefined };
236
+
237
+ return new Promise((resolve) => {
238
+ const child = spawn(CLAUDE_BIN, asyncArgs, {
239
+ cwd: cwd || undefined,
240
+ stdio: ['pipe', 'pipe', 'pipe'],
241
+ detached: true, // own process group — kills sub-agents on timeout too
242
+ env: asyncEnv,
243
+ });
244
+
245
+ let stdout = '';
246
+ let stderr = '';
247
+ let timedOut = false;
248
+
249
+ const timer = setTimeout(() => {
250
+ timedOut = true;
251
+ log('WARN', `Task ${task.name} timeout (${timeoutMs / 1000}s) — killing process group`);
252
+ try { process.kill(-child.pid, 'SIGTERM'); } catch { child.kill('SIGTERM'); }
253
+ setTimeout(() => {
254
+ try { process.kill(-child.pid, 'SIGKILL'); } catch { try { child.kill('SIGKILL'); } catch { } }
255
+ }, 5000);
256
+ }, timeoutMs);
257
+
258
+ child.stdin.write(fullPrompt);
259
+ child.stdin.end();
260
+ child.stdout.on('data', (d) => { stdout += d.toString(); });
261
+ child.stderr.on('data', (d) => { stderr += d.toString(); });
262
+
263
+ child.on('close', (code) => {
264
+ clearTimeout(timer);
265
+ const output = stdout.trim();
266
+ if (timedOut) {
267
+ const prevSid = state.tasks[task.name]?.session_id;
268
+ const prevCreatedAt = state.tasks[task.name]?.session_created_at;
269
+ state.tasks[task.name] = {
270
+ last_run: new Date().toISOString(),
271
+ status: 'timeout',
272
+ error: 'Task exceeded timeout',
273
+ ...(prevSid && { session_id: prevSid }),
274
+ ...(prevCreatedAt && { session_created_at: prevCreatedAt }),
275
+ };
276
+ saveState(state);
277
+ return resolve({ success: false, error: 'timeout', output: '' });
278
+ }
279
+ if (code !== 0) {
280
+ const errMsg = (stderr || `Exit code ${code}`).slice(0, 200);
281
+ // Persistent session expired: reset so next run creates a new one
282
+ if (task.persistent_session && (errMsg.includes('not found') || errMsg.includes('No session'))) {
283
+ log('WARN', `Persistent session for ${task.name} expired, will create new on next run`);
284
+ state.tasks[task.name] = { last_run: new Date().toISOString(), status: 'session_reset', error: 'Session expired' };
285
+ saveState(state);
286
+ return resolve({ success: false, error: 'session_expired', output: '' });
287
+ }
288
+ log('ERROR', `Task ${task.name} failed (exit ${code}): ${errMsg}`);
289
+ const prevSid = state.tasks[task.name]?.session_id;
290
+ const prevCreatedAt = state.tasks[task.name]?.session_created_at;
291
+ state.tasks[task.name] = {
292
+ last_run: new Date().toISOString(),
293
+ status: 'error',
294
+ error: errMsg,
295
+ ...(prevSid && { session_id: prevSid }),
296
+ ...(prevCreatedAt && { session_created_at: prevCreatedAt }),
297
+ };
298
+ saveState(state);
299
+ return resolve({ success: false, error: errMsg, output: '' });
300
+ }
301
+ const estimatedTokens = Math.ceil((fullPrompt.length + output.length) / 4);
302
+ recordTokens(state, estimatedTokens);
303
+ const prevSessionId = state.tasks[task.name]?.session_id;
304
+ const prevCreatedAt = state.tasks[task.name]?.session_created_at;
305
+ state.tasks[task.name] = {
306
+ last_run: new Date().toISOString(),
307
+ status: 'success',
308
+ output_preview: output.slice(0, 200),
309
+ ...(prevSessionId && { session_id: prevSessionId }),
310
+ ...(prevCreatedAt && { session_created_at: prevCreatedAt }),
311
+ };
312
+ saveState(state);
313
+ maybeSaveTaskMemory(task, output, estimatedTokens, prevSessionId || '');
314
+ log('INFO', `Task ${task.name} completed (est. ${estimatedTokens} tokens)`);
315
+ resolve({ success: true, output, tokens: estimatedTokens });
316
+ });
317
+
318
+ child.on('error', (err) => {
319
+ clearTimeout(timer);
320
+ log('ERROR', `Task ${task.name} spawn error: ${err.message}`);
321
+ resolve({ success: false, error: err.message, output: '' });
322
+ });
323
+ });
324
+ }
325
+
326
+ // parseInterval — imported from ./utils
327
+
328
+ function executeWorkflow(task, config) {
329
+ const state = loadState();
330
+ if (!checkBudget(config, state)) {
331
+ log('WARN', `Budget exceeded, skipping workflow: ${task.name}`);
332
+ return { success: false, error: 'budget_exceeded', output: '' };
333
+ }
334
+ const precheck = checkPrecondition(task);
335
+ if (!precheck.pass) {
336
+ state.tasks[task.name] = { last_run: new Date().toISOString(), status: 'skipped', output_preview: 'Precondition not met' };
337
+ saveState(state);
338
+ return { success: true, output: '(skipped)', skipped: true };
339
+ }
340
+ const steps = task.steps || [];
341
+ if (steps.length === 0) return { success: false, error: 'No steps defined', output: '' };
342
+
343
+ const model = task.model || 'sonnet';
344
+ const cwd = task.cwd ? task.cwd.replace(/^~/, HOME) : HOME;
345
+ const sessionId = crypto.randomUUID();
346
+ const outputs = [];
347
+ let totalTokens = 0;
348
+ const allowed = task.allowedTools || [];
349
+ // Auto-detect MCP config in task cwd
350
+ const mcpConfig = task.mcp_config
351
+ ? path.resolve(task.mcp_config.replace(/^~/, HOME))
352
+ : fs.existsSync(path.join(cwd, '.mcp.json'))
353
+ ? path.join(cwd, '.mcp.json')
354
+ : null;
355
+
356
+ log('INFO', `Workflow ${task.name}: ${steps.length} steps, session ${sessionId.slice(0, 8)}${mcpConfig ? ', mcp: ' + path.basename(mcpConfig) : ''}`);
357
+
358
+ for (let i = 0; i < steps.length; i++) {
359
+ const step = steps[i];
360
+ let prompt = (step.skill ? `/${step.skill} ` : '') + (step.prompt || '');
361
+ if (i === 0 && precheck.context) prompt += `\n\n相关数据:\n\`\`\`\n${precheck.context}\n\`\`\``;
362
+ const args = ['-p', '--model', model, '--dangerously-skip-permissions'];
363
+ for (const tool of allowed) args.push('--allowedTools', tool);
364
+ if (mcpConfig) args.push('--mcp-config', mcpConfig);
365
+ args.push(i === 0 ? '--session-id' : '--resume', sessionId);
366
+
367
+ log('INFO', `Workflow ${task.name} step ${i + 1}/${steps.length}: ${step.skill || 'prompt'}`);
368
+ try {
369
+ const output = execFileSync(CLAUDE_BIN, args, {
370
+ input: prompt, encoding: 'utf8', timeout: resolveTimeoutMs(step.timeout, 300), maxBuffer: 5 * 1024 * 1024, cwd, env: { ...process.env, ...getDaemonProviderEnv(), CLAUDECODE: undefined },
371
+ }).trim();
372
+ const tk = Math.ceil((prompt.length + output.length) / 4);
373
+ totalTokens += tk;
374
+ outputs.push({ step: i + 1, skill: step.skill || null, output: output.slice(0, 500), tokens: tk });
375
+ log('INFO', `Workflow ${task.name} step ${i + 1} done (${tk} tokens)`);
376
+ if (!checkBudget(config, loadState())) { log('WARN', 'Budget exceeded mid-workflow'); break; }
377
+ } catch (e) {
378
+ log('ERROR', `Workflow ${task.name} step ${i + 1} failed: ${e.message.slice(0, 200)}`);
379
+ outputs.push({ step: i + 1, skill: step.skill || null, error: e.message.slice(0, 200) });
380
+ if (!step.optional) {
381
+ recordTokens(loadState(), totalTokens);
382
+ state.tasks[task.name] = { last_run: new Date().toISOString(), status: 'error', error: `Step ${i + 1} failed`, steps_completed: i, steps_total: steps.length };
383
+ saveState(state);
384
+ return { success: false, error: `Step ${i + 1} failed`, output: outputs.map(o => `Step ${o.step}: ${o.error ? 'FAILED' : 'OK'}`).join('\n'), tokens: totalTokens };
385
+ }
386
+ }
387
+ }
388
+ recordTokens(loadState(), totalTokens);
389
+ const lastOk = [...outputs].reverse().find(o => !o.error);
390
+ state.tasks[task.name] = { last_run: new Date().toISOString(), status: 'success', output_preview: (lastOk ? lastOk.output : '').slice(0, 200), steps_completed: outputs.filter(o => !o.error).length, steps_total: steps.length };
391
+ saveState(state);
392
+ maybeSaveTaskMemory(task, (lastOk ? lastOk.output : ''), totalTokens, sessionId);
393
+ log('INFO', `Workflow ${task.name} done: ${outputs.filter(o => !o.error).length}/${steps.length} steps (${totalTokens} tokens)`);
394
+ return { success: true, output: outputs.map(o => `Step ${o.step} (${o.skill || 'prompt'}): ${o.error ? 'FAILED' : 'OK'}`).join('\n') + '\n\n' + (lastOk ? lastOk.output : ''), tokens: totalTokens };
395
+ }
396
+
397
+ function getAllTasks(cfg) {
398
+ const general = (cfg.heartbeat && cfg.heartbeat.tasks) || [];
399
+ const project = [];
400
+ const generalNames = new Set(general.map(t => t.name));
401
+ for (const [key, proj] of Object.entries(cfg.projects || {})) {
402
+ for (const t of (proj.heartbeat_tasks || [])) {
403
+ if (generalNames.has(t.name)) log('WARN', `Duplicate task name "${t.name}" in project "${key}" and general heartbeat`);
404
+ project.push({ ...t, _project: { key, name: proj.name || key, color: proj.color || 'blue', icon: proj.icon || '🤖' } });
405
+ }
406
+ }
407
+ return { general, project, all: [...general, ...project] };
408
+ }
409
+
410
+ function findTask(cfg, name) {
411
+ const { general, project } = getAllTasks(cfg);
412
+ const found = general.find(t => t.name === name) || project.find(t => t.name === name);
413
+ return found || null;
414
+ }
415
+
416
+ function startHeartbeat(config, notifyFn) {
417
+ const { all: tasks } = getAllTasks(config);
418
+
419
+ const enabledTasks = tasks.filter(t => t.enabled !== false);
420
+ const checkIntervalSec = (config.daemon && config.daemon.heartbeat_check_interval) || 60;
421
+ log('INFO', `Heartbeat scheduler started (check every ${checkIntervalSec}s, ${enabledTasks.length}/${tasks.length} tasks enabled)`);
422
+
423
+ // Even with zero tasks, the physiological heartbeat still runs
424
+
425
+ // Track next run times
426
+ const nextRun = {};
427
+ const now = Date.now();
428
+ const state = loadState();
429
+
430
+ let newTaskIndex = 0;
431
+ for (const task of enabledTasks) {
432
+ const intervalSec = parseInterval(task.interval);
433
+ const lastRun = state.tasks[task.name] && state.tasks[task.name].last_run;
434
+ if (lastRun) {
435
+ const elapsed = (now - new Date(lastRun).getTime()) / 1000;
436
+ nextRun[task.name] = now + Math.max(0, (intervalSec - elapsed)) * 1000;
437
+ } else {
438
+ // First run: stagger new tasks to avoid thundering herd
439
+ // Each new task waits an additional check interval beyond the first
440
+ newTaskIndex++;
441
+ nextRun[task.name] = now + checkIntervalSec * 1000 * newTaskIndex;
442
+ }
443
+ }
444
+
445
+ // Tracks tasks currently running (prevents concurrent runs of the same task)
446
+ const runningTasks = new Set();
447
+
448
+ const timer = setInterval(() => {
449
+ // ① Physiological heartbeat (zero token, pure awareness)
450
+ physiologicalHeartbeat(config);
451
+
452
+ // Sleep mode detection — log transitions once
453
+ const idle = isUserIdle();
454
+ if (idle && !isInSleepMode()) {
455
+ setSleepMode(true);
456
+ log('INFO', '[DAEMON] Entering Sleep Mode');
457
+ // Generate summaries for sessions idle 2-24h
458
+ spawnSessionSummaries();
459
+ } else if (!idle && isInSleepMode()) {
460
+ setSleepMode(false);
461
+ log('INFO', '[DAEMON] Exiting Sleep Mode — local activity detected');
462
+ }
463
+
464
+ // ② Task heartbeat (burns tokens on schedule)
465
+ const currentTime = Date.now();
466
+ for (const task of enabledTasks) {
467
+ if (currentTime >= (nextRun[task.name] || 0)) {
468
+ const intervalSec = parseInterval(task.interval);
469
+ // Dream tasks: only run when user is idle
470
+ if (task.require_idle && !isUserIdle()) {
471
+ // Retry on next scheduler tick instead of waiting full interval.
472
+ nextRun[task.name] = currentTime + checkIntervalSec * 1000;
473
+ log('INFO', `[DAEMON] Deferring dream task "${task.name}" — user active`);
474
+ continue;
475
+ }
476
+
477
+ if (runningTasks.has(task.name)) {
478
+ // Task is still running; skip this cycle and keep full interval cadence.
479
+ nextRun[task.name] = currentTime + intervalSec * 1000;
480
+ log('WARN', `Task ${task.name} still running — skipping this interval`);
481
+ continue;
482
+ }
483
+
484
+ nextRun[task.name] = currentTime + intervalSec * 1000;
485
+ runningTasks.add(task.name);
486
+ // executeTask now returns a Promise (async, non-blocking, process-group kill)
487
+ Promise.resolve(executeTask(task, config))
488
+ .then((result) => {
489
+ runningTasks.delete(task.name);
490
+ if (task.notify && notifyFn && !result.skipped) {
491
+ const proj = task._project || null;
492
+ if (result.success) {
493
+ notifyFn(`✅ *${task.name}* completed\n\n${result.output}`, proj);
494
+ } else {
495
+ notifyFn(`❌ *${task.name}* failed: ${result.error}`, proj);
496
+ }
497
+ }
498
+ })
499
+ .catch((err) => {
500
+ runningTasks.delete(task.name);
501
+ log('ERROR', `Task ${task.name} threw: ${err.message}`);
502
+ });
503
+ }
504
+ }
505
+
506
+ // Skill evolution: check queue and notify user of actionable items
507
+ if (skillEvolution) {
508
+ try {
509
+ const notifications = skillEvolution.checkEvolutionQueue();
510
+ for (const item of notifications) {
511
+ let msg = '';
512
+ if (item.type === 'skill_gap') {
513
+ msg = `🧬 *技能缺口检测*\n${item.reason}`;
514
+ if (item.search_hint) msg += `\n搜索建议: \`${item.search_hint}\``;
515
+ } else if (item.type === 'skill_fix') {
516
+ msg = `🔧 *技能需要修复*\n技能 \`${item.skill_name}\` ${item.reason}`;
517
+ } else if (item.type === 'user_complaint') {
518
+ msg = `⚠️ *技能反馈*\n技能 \`${item.skill_name}\` 收到用户反馈\n${item.reason}`;
519
+ }
520
+ if (msg && notifyFn) notifyFn(msg);
521
+ }
522
+ } catch (e) { log('WARN', `Skill evolution queue check failed: ${e.message}`); }
523
+ }
524
+ }, checkIntervalSec * 1000);
525
+
526
+ return timer;
527
+ }
528
+
529
+ return {
530
+ checkPrecondition,
531
+ executeTask,
532
+ executeWorkflow,
533
+ getAllTasks,
534
+ findTask,
535
+ startHeartbeat,
536
+ };
537
+ }
538
+
539
+ module.exports = { createTaskScheduler };