metame-cli 1.4.15 → 1.4.18

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,143 @@
1
+ 'use strict';
2
+
3
+ const ALLOWED_STATUS = new Set(['queued', 'running', 'blocked', 'done', 'failed']);
4
+ const ALLOWED_PRIORITY = new Set(['low', 'normal', 'high', 'urgent']);
5
+ const ALLOWED_TASK_KIND = new Set(['team', 'heartbeat']);
6
+
7
+ function sanitizeText(input, maxLen = 800) {
8
+ return String(input || '').replace(/[\x00-\x1F\x7F]/g, ' ').trim().slice(0, maxLen);
9
+ }
10
+
11
+ function normalizeScopeId(value, fallbackTaskId) {
12
+ const raw = sanitizeText(value, 120) || sanitizeText(fallbackTaskId, 120);
13
+ if (!raw) return '';
14
+ const cleaned = raw
15
+ .replace(/[^a-zA-Z0-9_-]/g, '_')
16
+ .replace(/_+/g, '_')
17
+ .replace(/^_+|_+$/g, '');
18
+ return cleaned.slice(0, 120);
19
+ }
20
+
21
+ function sanitizeStringArray(values, maxItems = 20, maxItemLen = 300) {
22
+ if (!Array.isArray(values)) return [];
23
+ const out = [];
24
+ const seen = new Set();
25
+ for (const item of values) {
26
+ const v = sanitizeText(item, maxItemLen);
27
+ if (!v || seen.has(v)) continue;
28
+ seen.add(v);
29
+ out.push(v);
30
+ if (out.length >= maxItems) break;
31
+ }
32
+ return out;
33
+ }
34
+
35
+ function normalizeInputs(input) {
36
+ if (!input || typeof input !== 'object' || Array.isArray(input)) return {};
37
+ const out = {};
38
+ for (const [k, v] of Object.entries(input)) {
39
+ const key = sanitizeText(k, 80);
40
+ if (!key) continue;
41
+ if (typeof v === 'string') out[key] = sanitizeText(v, 500);
42
+ else if (typeof v === 'number' || typeof v === 'boolean') out[key] = v;
43
+ else if (Array.isArray(v)) out[key] = sanitizeStringArray(v, 12, 240);
44
+ else if (v && typeof v === 'object') out[key] = JSON.parse(JSON.stringify(v));
45
+ }
46
+ return out;
47
+ }
48
+
49
+ function normalizeDefinitionOfDone(raw) {
50
+ if (Array.isArray(raw)) return sanitizeStringArray(raw, 12, 240);
51
+ const text = sanitizeText(raw, 1200);
52
+ if (!text) return [];
53
+ return sanitizeStringArray(
54
+ text.split(/\r?\n|;|;/g).map(s => s.trim()).filter(Boolean),
55
+ 12,
56
+ 240
57
+ );
58
+ }
59
+
60
+ function newTaskId(now = new Date()) {
61
+ const y = now.getUTCFullYear();
62
+ const m = String(now.getUTCMonth() + 1).padStart(2, '0');
63
+ const d = String(now.getUTCDate()).padStart(2, '0');
64
+ const rand = Math.random().toString(36).slice(2, 8);
65
+ return `t_${y}${m}${d}_${rand}`;
66
+ }
67
+
68
+ function newHandoffId() {
69
+ const rand = Math.random().toString(36).slice(2, 8);
70
+ return `h_${Date.now()}_${rand}`;
71
+ }
72
+
73
+ function normalizeTaskEnvelope(raw, overrides = {}) {
74
+ const nowIso = new Date().toISOString();
75
+ const src = (raw && typeof raw === 'object') ? raw : {};
76
+ const merged = { ...src, ...overrides };
77
+
78
+ const taskId = sanitizeText(merged.task_id, 80) || newTaskId();
79
+ const parentTaskId = sanitizeText(merged.parent_task_id, 80) || null;
80
+ const fromAgent = sanitizeText(merged.from_agent, 80) || 'unknown';
81
+ const toAgent = sanitizeText(merged.to_agent, 80);
82
+ const scopeId = normalizeScopeId(merged.scope_id, taskId) || taskId;
83
+ const goal = sanitizeText(merged.goal, 500);
84
+ const definitionOfDone = normalizeDefinitionOfDone(merged.definition_of_done);
85
+ const artifacts = sanitizeStringArray(merged.artifacts, 30, 500);
86
+ const ownedPaths = sanitizeStringArray(merged.owned_paths, 30, 500);
87
+ const participantBase = sanitizeStringArray(merged.participants, 30, 80);
88
+ if (fromAgent) participantBase.push(fromAgent);
89
+ if (toAgent) participantBase.push(toAgent);
90
+ const participants = sanitizeStringArray(participantBase, 30, 80);
91
+ const statusRaw = sanitizeText(merged.status, 20).toLowerCase();
92
+ const priorityRaw = sanitizeText(merged.priority, 20).toLowerCase();
93
+ const kindRaw = sanitizeText(merged.task_kind, 20).toLowerCase();
94
+ const status = ALLOWED_STATUS.has(statusRaw) ? statusRaw : 'queued';
95
+ const priority = ALLOWED_PRIORITY.has(priorityRaw) ? priorityRaw : 'normal';
96
+ const taskKind = ALLOWED_TASK_KIND.has(kindRaw) ? kindRaw : 'team';
97
+ const createdAt = sanitizeText(merged.created_at, 64) || nowIso;
98
+ const updatedAt = sanitizeText(merged.updated_at, 64) || nowIso;
99
+ const inputs = normalizeInputs(merged.inputs);
100
+
101
+ return {
102
+ task_id: taskId,
103
+ scope_id: scopeId,
104
+ parent_task_id: parentTaskId,
105
+ from_agent: fromAgent,
106
+ to_agent: toAgent,
107
+ participants,
108
+ goal,
109
+ definition_of_done: definitionOfDone,
110
+ inputs,
111
+ artifacts,
112
+ owned_paths: ownedPaths,
113
+ task_kind: taskKind,
114
+ priority,
115
+ status,
116
+ created_at: createdAt,
117
+ updated_at: updatedAt,
118
+ };
119
+ }
120
+
121
+ function validateTaskEnvelope(env) {
122
+ if (!env || typeof env !== 'object') return { ok: false, error: 'envelope_missing' };
123
+ if (!sanitizeText(env.task_id, 80)) return { ok: false, error: 'task_id_required' };
124
+ if (!normalizeScopeId(env.scope_id, env.task_id)) return { ok: false, error: 'scope_id_required' };
125
+ if (!sanitizeText(env.from_agent, 80)) return { ok: false, error: 'from_agent_required' };
126
+ if (!sanitizeText(env.to_agent, 80)) return { ok: false, error: 'to_agent_required' };
127
+ if (!sanitizeText(env.goal, 500)) return { ok: false, error: 'goal_required' };
128
+ if (!ALLOWED_TASK_KIND.has(String(env.task_kind || '').toLowerCase())) return { ok: false, error: 'invalid_task_kind' };
129
+ if (!ALLOWED_STATUS.has(String(env.status || '').toLowerCase())) return { ok: false, error: 'invalid_status' };
130
+ if (!ALLOWED_PRIORITY.has(String(env.priority || '').toLowerCase())) return { ok: false, error: 'invalid_priority' };
131
+ return { ok: true };
132
+ }
133
+
134
+ module.exports = {
135
+ ALLOWED_STATUS,
136
+ ALLOWED_PRIORITY,
137
+ ALLOWED_TASK_KIND,
138
+ newTaskId,
139
+ newHandoffId,
140
+ normalizeScopeId,
141
+ normalizeTaskEnvelope,
142
+ validateTaskEnvelope,
143
+ };
@@ -0,0 +1,59 @@
1
+ 'use strict';
2
+
3
+ const test = require('node:test');
4
+ const assert = require('node:assert/strict');
5
+
6
+ const {
7
+ normalizeTaskEnvelope,
8
+ validateTaskEnvelope,
9
+ newTaskId,
10
+ newHandoffId,
11
+ } = require('./daemon-task-envelope');
12
+
13
+ test('normalizeTaskEnvelope sets defaults for team task', () => {
14
+ const env = normalizeTaskEnvelope({
15
+ from_agent: 'assistant',
16
+ to_agent: 'coder',
17
+ goal: 'run tests',
18
+ });
19
+ assert.ok(env.task_id.startsWith('t_'));
20
+ assert.equal(env.scope_id, env.task_id);
21
+ assert.equal(env.task_kind, 'team');
22
+ assert.equal(env.status, 'queued');
23
+ assert.equal(env.priority, 'normal');
24
+ assert.equal(env.from_agent, 'assistant');
25
+ assert.equal(env.to_agent, 'coder');
26
+ assert.equal(env.goal, 'run tests');
27
+ assert.deepEqual(env.participants.sort(), ['assistant', 'coder'].sort());
28
+ });
29
+
30
+ test('validateTaskEnvelope rejects missing goal', () => {
31
+ const env = normalizeTaskEnvelope({
32
+ from_agent: 'assistant',
33
+ to_agent: 'coder',
34
+ goal: '',
35
+ });
36
+ const v = validateTaskEnvelope(env);
37
+ assert.equal(v.ok, false);
38
+ assert.equal(v.error, 'goal_required');
39
+ });
40
+
41
+ test('id generators return expected prefixes', () => {
42
+ const taskId = newTaskId(new Date('2026-02-25T00:00:00.000Z'));
43
+ const handoffId = newHandoffId();
44
+ assert.ok(taskId.startsWith('t_20260225_'));
45
+ assert.ok(handoffId.startsWith('h_'));
46
+ });
47
+
48
+ test('normalizeTaskEnvelope keeps explicit scope and merges participants', () => {
49
+ const env = normalizeTaskEnvelope({
50
+ task_id: 't_1',
51
+ scope_id: 'scope#A/1',
52
+ from_agent: 'assistant',
53
+ to_agent: 'reviewer',
54
+ participants: ['assistant', 'coder'],
55
+ goal: 'review changes',
56
+ });
57
+ assert.equal(env.scope_id, 'scope_A_1');
58
+ assert.deepEqual(env.participants.sort(), ['assistant', 'coder', 'reviewer'].sort());
59
+ });
@@ -1,5 +1,159 @@
1
1
  'use strict';
2
2
 
3
+ const { classifyTaskUsage } = require('./usage-classifier');
4
+
5
+ const WEEKDAY_INDEX = Object.freeze({
6
+ sun: 0,
7
+ sunday: 0,
8
+ mon: 1,
9
+ monday: 1,
10
+ tue: 2,
11
+ tues: 2,
12
+ tuesday: 2,
13
+ wed: 3,
14
+ wednesday: 3,
15
+ thu: 4,
16
+ thur: 4,
17
+ thurs: 4,
18
+ thursday: 4,
19
+ fri: 5,
20
+ friday: 5,
21
+ sat: 6,
22
+ saturday: 6,
23
+ });
24
+
25
+ function parseAtTime(raw) {
26
+ const text = String(raw || '').trim();
27
+ const m = text.match(/^([01]?\d|2[0-3]):([0-5]\d)$/);
28
+ if (!m) return null;
29
+ return { hour: Number(m[1]), minute: Number(m[2]) };
30
+ }
31
+
32
+ function parseDays(raw) {
33
+ if (raw === undefined || raw === null || raw === '') return { ok: true, days: null };
34
+
35
+ let tokens = [];
36
+ if (Array.isArray(raw)) {
37
+ tokens = raw;
38
+ } else if (typeof raw === 'string') {
39
+ const lower = raw.trim().toLowerCase();
40
+ if (!lower || lower === 'daily' || lower === 'everyday' || lower === 'all') {
41
+ return { ok: true, days: null };
42
+ }
43
+ if (lower === 'weekdays' || lower === 'workdays') {
44
+ return { ok: true, days: new Set([1, 2, 3, 4, 5]) };
45
+ }
46
+ if (lower === 'weekends') {
47
+ return { ok: true, days: new Set([0, 6]) };
48
+ }
49
+ tokens = lower.split(/[\s,|/]+/).filter(Boolean);
50
+ } else {
51
+ return { ok: false, error: 'days must be string or array' };
52
+ }
53
+
54
+ const out = new Set();
55
+ for (const token of tokens) {
56
+ let day = null;
57
+ if (typeof token === 'number' && Number.isInteger(token)) {
58
+ day = token;
59
+ } else if (typeof token === 'string' && token.trim()) {
60
+ const t = token.trim().toLowerCase();
61
+ if (/^\d+$/.test(t)) day = Number(t);
62
+ else if (Object.prototype.hasOwnProperty.call(WEEKDAY_INDEX, t)) day = WEEKDAY_INDEX[t];
63
+ }
64
+ if (!Number.isInteger(day) || day < 0 || day > 6) {
65
+ return { ok: false, error: `invalid day token: ${String(token)}` };
66
+ }
67
+ out.add(day);
68
+ }
69
+
70
+ return { ok: true, days: out.size > 0 ? out : null };
71
+ }
72
+
73
+ function dayAllowed(days, day) {
74
+ if (!days || days.size === 0) return true;
75
+ return days.has(day);
76
+ }
77
+
78
+ function nextClockRunAfter(schedule, fromMs) {
79
+ const baseMs = Number.isFinite(fromMs) ? fromMs : Date.now();
80
+ const start = new Date(baseMs + 1000);
81
+ start.setSeconds(0, 0);
82
+
83
+ for (let offset = 0; offset <= 8; offset++) {
84
+ const candidate = new Date(start);
85
+ candidate.setDate(start.getDate() + offset);
86
+ candidate.setHours(schedule.hour, schedule.minute, 0, 0);
87
+ const ts = candidate.getTime();
88
+ if (ts <= baseMs) continue;
89
+ if (!dayAllowed(schedule.days, candidate.getDay())) continue;
90
+ return ts;
91
+ }
92
+
93
+ return baseMs + 24 * 60 * 60 * 1000;
94
+ }
95
+
96
+ function buildTaskSchedule(task, parseInterval) {
97
+ const atRaw = typeof task.at === 'string' ? task.at.trim() : '';
98
+ if (atRaw) {
99
+ const at = parseAtTime(atRaw);
100
+ if (!at) return { ok: false, error: `invalid at time "${task.at}"` };
101
+ const parsedDays = parseDays(task.days !== undefined ? task.days : task.weekdays);
102
+ if (!parsedDays.ok) return { ok: false, error: parsedDays.error };
103
+ return {
104
+ ok: true,
105
+ schedule: {
106
+ mode: 'clock',
107
+ hour: at.hour,
108
+ minute: at.minute,
109
+ days: parsedDays.days,
110
+ },
111
+ };
112
+ }
113
+
114
+ return {
115
+ ok: true,
116
+ schedule: {
117
+ mode: 'interval',
118
+ intervalSec: parseInterval(task.interval),
119
+ },
120
+ };
121
+ }
122
+
123
+ function nextRunAfter(schedule, fromMs) {
124
+ if (!schedule || schedule.mode !== 'clock') {
125
+ const intervalSec = schedule && Number.isFinite(schedule.intervalSec)
126
+ ? schedule.intervalSec
127
+ : 3600;
128
+ return fromMs + intervalSec * 1000;
129
+ }
130
+ return nextClockRunAfter(schedule, fromMs);
131
+ }
132
+
133
+ function computeInitialNextRun(task, schedule, state, nowMs, checkIntervalSec, newTaskIndex) {
134
+ if (!schedule || schedule.mode !== 'clock') {
135
+ const intervalSec = schedule && Number.isFinite(schedule.intervalSec)
136
+ ? schedule.intervalSec
137
+ : 3600;
138
+ const lastRun = state.tasks[task.name] && state.tasks[task.name].last_run;
139
+ if (lastRun) {
140
+ const elapsed = (nowMs - new Date(lastRun).getTime()) / 1000;
141
+ return nowMs + Math.max(0, (intervalSec - elapsed)) * 1000;
142
+ }
143
+ return nowMs + checkIntervalSec * 1000 * newTaskIndex;
144
+ }
145
+
146
+ const lastRun = state.tasks[task.name] && state.tasks[task.name].last_run;
147
+ if (lastRun) {
148
+ const lastMs = new Date(lastRun).getTime();
149
+ if (Number.isFinite(lastMs) && lastMs > 0) {
150
+ const dueAfterLast = nextClockRunAfter(schedule, lastMs);
151
+ if (dueAfterLast <= nowMs) return nowMs;
152
+ }
153
+ }
154
+ return nextClockRunAfter(schedule, nowMs);
155
+ }
156
+
3
157
  function createTaskScheduler(deps) {
4
158
  const {
5
159
  fs,
@@ -134,7 +288,11 @@ function createTaskScheduler(deps) {
134
288
  if (task.type === 'script') {
135
289
  log('INFO', `Executing script task: ${task.name} → ${task.command}`);
136
290
  try {
137
- const scriptEnv = { ...process.env, METAME_ROOT: process.env.METAME_ROOT || '' };
291
+ const scriptEnv = {
292
+ ...process.env,
293
+ METAME_ROOT: process.env.METAME_ROOT || '',
294
+ METAME_INTERNAL_PROMPT: '1',
295
+ };
138
296
  delete scriptEnv.CLAUDECODE;
139
297
  const output = execSync(task.command, {
140
298
  encoding: 'utf8',
@@ -232,7 +390,12 @@ function createTaskScheduler(deps) {
232
390
  // executeTask now returns a Promise — callers must handle it with .then() or await.
233
391
  const timeoutMs = resolveTimeoutMs(task.timeout, 120);
234
392
  const asyncArgs = [...claudeArgs];
235
- const asyncEnv = { ...process.env, ...getDaemonProviderEnv(), CLAUDECODE: undefined };
393
+ const asyncEnv = {
394
+ ...process.env,
395
+ ...getDaemonProviderEnv(),
396
+ CLAUDECODE: undefined,
397
+ METAME_INTERNAL_PROMPT: '1',
398
+ };
236
399
 
237
400
  return new Promise((resolve) => {
238
401
  const child = spawn(CLAUDE_BIN, asyncArgs, {
@@ -299,7 +462,7 @@ function createTaskScheduler(deps) {
299
462
  return resolve({ success: false, error: errMsg, output: '' });
300
463
  }
301
464
  const estimatedTokens = Math.ceil((fullPrompt.length + output.length) / 4);
302
- recordTokens(state, estimatedTokens);
465
+ recordTokens(state, estimatedTokens, { category: classifyTaskUsage(task) });
303
466
  const prevSessionId = state.tasks[task.name]?.session_id;
304
467
  const prevCreatedAt = state.tasks[task.name]?.session_created_at;
305
468
  state.tasks[task.name] = {
@@ -367,7 +530,17 @@ function createTaskScheduler(deps) {
367
530
  log('INFO', `Workflow ${task.name} step ${i + 1}/${steps.length}: ${step.skill || 'prompt'}`);
368
531
  try {
369
532
  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 },
533
+ input: prompt,
534
+ encoding: 'utf8',
535
+ timeout: resolveTimeoutMs(step.timeout, 300),
536
+ maxBuffer: 5 * 1024 * 1024,
537
+ cwd,
538
+ env: {
539
+ ...process.env,
540
+ ...getDaemonProviderEnv(),
541
+ CLAUDECODE: undefined,
542
+ METAME_INTERNAL_PROMPT: '1',
543
+ },
371
544
  }).trim();
372
545
  const tk = Math.ceil((prompt.length + output.length) / 4);
373
546
  totalTokens += tk;
@@ -378,14 +551,14 @@ function createTaskScheduler(deps) {
378
551
  log('ERROR', `Workflow ${task.name} step ${i + 1} failed: ${e.message.slice(0, 200)}`);
379
552
  outputs.push({ step: i + 1, skill: step.skill || null, error: e.message.slice(0, 200) });
380
553
  if (!step.optional) {
381
- recordTokens(loadState(), totalTokens);
554
+ recordTokens(loadState(), totalTokens, { category: classifyTaskUsage(task) });
382
555
  state.tasks[task.name] = { last_run: new Date().toISOString(), status: 'error', error: `Step ${i + 1} failed`, steps_completed: i, steps_total: steps.length };
383
556
  saveState(state);
384
557
  return { success: false, error: `Step ${i + 1} failed`, output: outputs.map(o => `Step ${o.step}: ${o.error ? 'FAILED' : 'OK'}`).join('\n'), tokens: totalTokens };
385
558
  }
386
559
  }
387
560
  }
388
- recordTokens(loadState(), totalTokens);
561
+ recordTokens(loadState(), totalTokens, { category: classifyTaskUsage(task) });
389
562
  const lastOk = [...outputs].reverse().find(o => !o.error);
390
563
  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
564
  saveState(state);
@@ -418,7 +591,19 @@ function createTaskScheduler(deps) {
418
591
 
419
592
  const enabledTasks = tasks.filter(t => t.enabled !== false);
420
593
  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)`);
594
+ const taskSchedules = new Map();
595
+ const runnableTasks = [];
596
+ for (const task of enabledTasks) {
597
+ const parsed = buildTaskSchedule(task, parseInterval);
598
+ if (!parsed.ok) {
599
+ log('WARN', `Skipping task "${task.name}": ${parsed.error}`);
600
+ continue;
601
+ }
602
+ taskSchedules.set(task.name, parsed.schedule);
603
+ runnableTasks.push(task);
604
+ }
605
+
606
+ log('INFO', `Heartbeat scheduler started (check every ${checkIntervalSec}s, ${runnableTasks.length}/${tasks.length} tasks enabled)`);
422
607
 
423
608
  // Even with zero tasks, the physiological heartbeat still runs
424
609
 
@@ -428,18 +613,11 @@ function createTaskScheduler(deps) {
428
613
  const state = loadState();
429
614
 
430
615
  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
- }
616
+ for (const task of runnableTasks) {
617
+ const schedule = taskSchedules.get(task.name);
618
+ if (!schedule) continue;
619
+ if (schedule.mode !== 'clock') newTaskIndex++;
620
+ nextRun[task.name] = computeInitialNextRun(task, schedule, state, now, checkIntervalSec, newTaskIndex);
443
621
  }
444
622
 
445
623
  // Tracks tasks currently running (prevents concurrent runs of the same task)
@@ -463,9 +641,10 @@ function createTaskScheduler(deps) {
463
641
 
464
642
  // ② Task heartbeat (burns tokens on schedule)
465
643
  const currentTime = Date.now();
466
- for (const task of enabledTasks) {
644
+ for (const task of runnableTasks) {
645
+ const schedule = taskSchedules.get(task.name);
646
+ if (!schedule) continue;
467
647
  if (currentTime >= (nextRun[task.name] || 0)) {
468
- const intervalSec = parseInterval(task.interval);
469
648
  // Dream tasks: only run when user is idle
470
649
  if (task.require_idle && !isUserIdle()) {
471
650
  // Retry on next scheduler tick instead of waiting full interval.
@@ -476,12 +655,12 @@ function createTaskScheduler(deps) {
476
655
 
477
656
  if (runningTasks.has(task.name)) {
478
657
  // Task is still running; skip this cycle and keep full interval cadence.
479
- nextRun[task.name] = currentTime + intervalSec * 1000;
658
+ nextRun[task.name] = nextRunAfter(schedule, currentTime);
480
659
  log('WARN', `Task ${task.name} still running — skipping this interval`);
481
660
  continue;
482
661
  }
483
662
 
484
- nextRun[task.name] = currentTime + intervalSec * 1000;
663
+ nextRun[task.name] = nextRunAfter(schedule, currentTime);
485
664
  runningTasks.add(task.name);
486
665
  // executeTask now returns a Promise (async, non-blocking, process-group kill)
487
666
  Promise.resolve(executeTask(task, config))
@@ -509,6 +688,7 @@ function createTaskScheduler(deps) {
509
688
  const notifications = skillEvolution.checkEvolutionQueue();
510
689
  for (const item of notifications) {
511
690
  let msg = '';
691
+ const idHint = item.id ? `\nID: \`${item.id}\`` : '';
512
692
  if (item.type === 'skill_gap') {
513
693
  msg = `🧬 *技能缺口检测*\n${item.reason}`;
514
694
  if (item.search_hint) msg += `\n搜索建议: \`${item.search_hint}\``;
@@ -517,6 +697,8 @@ function createTaskScheduler(deps) {
517
697
  } else if (item.type === 'user_complaint') {
518
698
  msg = `⚠️ *技能反馈*\n技能 \`${item.skill_name}\` 收到用户反馈\n${item.reason}`;
519
699
  }
700
+ if (msg && item.id) msg += `${idHint}\n处理: \`/skill-evo done ${item.id}\` 或 \`/skill-evo dismiss ${item.id}\``;
701
+ else if (msg) msg += idHint;
520
702
  if (msg && notifyFn) notifyFn(msg);
521
703
  }
522
704
  } catch (e) { log('WARN', `Skill evolution queue check failed: ${e.message}`); }
@@ -536,4 +718,14 @@ function createTaskScheduler(deps) {
536
718
  };
537
719
  }
538
720
 
539
- module.exports = { createTaskScheduler };
721
+ module.exports = {
722
+ createTaskScheduler,
723
+ _private: {
724
+ parseAtTime,
725
+ parseDays,
726
+ nextClockRunAfter,
727
+ buildTaskSchedule,
728
+ computeInitialNextRun,
729
+ nextRunAfter,
730
+ },
731
+ };
@@ -0,0 +1,106 @@
1
+ 'use strict';
2
+
3
+ const { describe, it } = require('node:test');
4
+ const assert = require('node:assert/strict');
5
+ const { _private } = require('./daemon-task-scheduler');
6
+
7
+ const {
8
+ parseAtTime,
9
+ parseDays,
10
+ nextClockRunAfter,
11
+ buildTaskSchedule,
12
+ computeInitialNextRun,
13
+ nextRunAfter,
14
+ } = _private;
15
+
16
+ function nextDayOfWeek(base, day) {
17
+ const d = new Date(base);
18
+ while (d.getDay() !== day) d.setDate(d.getDate() + 1);
19
+ return d;
20
+ }
21
+
22
+ describe('daemon-task-scheduler private helpers', () => {
23
+ it('parses HH:MM time for clock tasks', () => {
24
+ assert.deepEqual(parseAtTime('09:30'), { hour: 9, minute: 30 });
25
+ assert.deepEqual(parseAtTime('23:59'), { hour: 23, minute: 59 });
26
+ assert.equal(parseAtTime('24:00'), null);
27
+ assert.equal(parseAtTime('9:7'), null);
28
+ });
29
+
30
+ it('parses days keywords and weekday names', () => {
31
+ assert.deepEqual([...parseDays('weekdays').days], [1, 2, 3, 4, 5]);
32
+ assert.deepEqual([...parseDays('weekends').days], [0, 6]);
33
+ assert.deepEqual([...parseDays(['mon', 'wed', 'fri']).days], [1, 3, 5]);
34
+ assert.equal(parseDays('daily').days, null);
35
+ assert.equal(parseDays().days, null);
36
+ assert.equal(parseDays('funday').ok, false);
37
+ });
38
+
39
+ it('computes next run for daily fixed-time schedule', () => {
40
+ const schedule = { mode: 'clock', hour: 9, minute: 30, days: null };
41
+ const fromBefore = new Date(2026, 1, 25, 8, 0, 0, 0).getTime();
42
+ const fromAfter = new Date(2026, 1, 25, 10, 0, 0, 0).getTime();
43
+
44
+ const next1 = new Date(nextClockRunAfter(schedule, fromBefore));
45
+ const next2 = new Date(nextClockRunAfter(schedule, fromAfter));
46
+
47
+ assert.equal(next1.getHours(), 9);
48
+ assert.equal(next1.getMinutes(), 30);
49
+ assert.equal(next1.getDate(), 25);
50
+
51
+ assert.equal(next2.getHours(), 9);
52
+ assert.equal(next2.getMinutes(), 30);
53
+ assert.equal(next2.getDate(), 26);
54
+ });
55
+
56
+ it('computes next run for weekday-only fixed-time schedule', () => {
57
+ const saturdayBase = nextDayOfWeek(new Date(2026, 1, 1, 8, 0, 0, 0), 6);
58
+ const schedule = { mode: 'clock', hour: 9, minute: 0, days: parseDays('weekdays').days };
59
+ const next = new Date(nextClockRunAfter(schedule, saturdayBase.getTime()));
60
+ const monday = nextDayOfWeek(new Date(saturdayBase), 1);
61
+
62
+ assert.equal(next.getDay(), 1);
63
+ assert.equal(next.getHours(), 9);
64
+ assert.equal(next.getMinutes(), 0);
65
+ assert.equal(next.getDate(), monday.getDate());
66
+ assert.equal(next.getMonth(), monday.getMonth());
67
+ assert.equal(next.getFullYear(), monday.getFullYear());
68
+ });
69
+
70
+ it('builds interval or clock schedule from task config', () => {
71
+ const intervalTask = { name: 'a', interval: '2h' };
72
+ const clockTask = { name: 'b', at: '07:15', days: 'weekdays' };
73
+
74
+ const interval = buildTaskSchedule(intervalTask, () => 7200);
75
+ const clock = buildTaskSchedule(clockTask, () => 3600);
76
+ const invalid = buildTaskSchedule({ name: 'bad', at: '25:99' }, () => 3600);
77
+
78
+ assert.equal(interval.ok, true);
79
+ assert.equal(interval.schedule.mode, 'interval');
80
+ assert.equal(interval.schedule.intervalSec, 7200);
81
+
82
+ assert.equal(clock.ok, true);
83
+ assert.equal(clock.schedule.mode, 'clock');
84
+ assert.equal(clock.schedule.hour, 7);
85
+ assert.equal(clock.schedule.minute, 15);
86
+ assert.deepEqual([...clock.schedule.days], [1, 2, 3, 4, 5]);
87
+
88
+ assert.equal(invalid.ok, false);
89
+ });
90
+
91
+ it('does catch-up for missed fixed-time runs and computes next run after execution', () => {
92
+ const task = { name: 'daily-report', at: '09:00' };
93
+ const schedule = { mode: 'clock', hour: 9, minute: 0, days: null };
94
+ const now = new Date(2026, 1, 25, 10, 0, 0, 0).getTime();
95
+ const yesterday = new Date(2026, 1, 24, 9, 0, 0, 0).toISOString();
96
+ const state = { tasks: { 'daily-report': { last_run: yesterday } } };
97
+
98
+ const initial = computeInitialNextRun(task, schedule, state, now, 60, 1);
99
+ const next = new Date(nextRunAfter(schedule, now));
100
+
101
+ assert.equal(initial, now);
102
+ assert.equal(next.getDate(), 26);
103
+ assert.equal(next.getHours(), 9);
104
+ assert.equal(next.getMinutes(), 0);
105
+ });
106
+ });