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.
- package/README.md +9 -6
- package/index.js +12 -5
- package/package.json +2 -2
- package/scripts/check-macos-control-capabilities.sh +77 -0
- package/scripts/daemon-admin-commands.js +441 -12
- package/scripts/daemon-admin-commands.test.js +333 -0
- package/scripts/daemon-claude-engine.js +71 -22
- package/scripts/daemon-command-router.js +242 -3
- package/scripts/daemon-default.yaml +10 -3
- package/scripts/daemon-exec-commands.js +248 -13
- package/scripts/daemon-task-envelope.js +143 -0
- package/scripts/daemon-task-envelope.test.js +59 -0
- package/scripts/daemon-task-scheduler.js +216 -24
- package/scripts/daemon-task-scheduler.test.js +106 -0
- package/scripts/daemon.js +374 -26
- package/scripts/distill.js +184 -34
- package/scripts/memory-extract.js +13 -5
- package/scripts/memory.js +239 -60
- package/scripts/providers.js +1 -1
- package/scripts/reliability-core.test.js +268 -0
- package/scripts/session-analytics.js +123 -35
- package/scripts/signal-capture.js +171 -11
- package/scripts/skill-evolution.js +288 -38
- package/scripts/skill-evolution.test.js +107 -0
- package/scripts/task-board.js +398 -0
- package/scripts/task-board.test.js +83 -0
- package/scripts/usage-classifier.js +139 -0
- package/scripts/utils.js +107 -0
- package/scripts/utils.test.js +61 -1
|
@@ -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 = {
|
|
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 = {
|
|
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,
|
|
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
|
-
|
|
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
|
|
432
|
-
const
|
|
433
|
-
|
|
434
|
-
if (
|
|
435
|
-
|
|
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
|
|
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
|
|
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
|
|
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 = {
|
|
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
|
+
});
|