@geekbeer/minion 2.5.0 → 2.6.0

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,404 @@
1
+ /**
2
+ * Routine Runner
3
+ * Manages cron-based routine execution using croner.
4
+ * Routines are minion-scoped autonomous tasks (e.g., morning-work, skill-reflection).
5
+ *
6
+ * Key design:
7
+ * - All skills in a routine run in ONE CLI session (context preserved)
8
+ * - /execution-report is automatically appended to report outcome
9
+ * - Marker file written before session start for skill-to-execution mapping
10
+ * - Same tmux execution model as workflow-runner
11
+ */
12
+
13
+ const { Cron } = require('croner')
14
+ const { exec } = require('child_process')
15
+ const { promisify } = require('util')
16
+ const crypto = require('crypto')
17
+ const os = require('os')
18
+ const path = require('path')
19
+ const fs = require('fs').promises
20
+ const execAsync = promisify(exec)
21
+
22
+ const executionStore = require('./execution-store')
23
+ const routineStore = require('./routine-store')
24
+ const logManager = require('./lib/log-manager')
25
+
26
+ // Active cron jobs keyed by routine ID
27
+ const activeJobs = new Map()
28
+
29
+ // Currently running executions
30
+ const runningExecutions = new Map()
31
+
32
+ // Marker file directory (shared with workflow-runner)
33
+ const MARKER_DIR = '/tmp/minion-executions'
34
+
35
+ /**
36
+ * Sleep for specified milliseconds
37
+ * @param {number} ms - Milliseconds to sleep
38
+ */
39
+ function sleep(ms) {
40
+ return new Promise((resolve) => setTimeout(resolve, ms))
41
+ }
42
+
43
+ /**
44
+ * Generate tmux session name from routine ID and execution ID
45
+ * Format: rt-{routineId first 8}-{executionId first 4}
46
+ * @param {string} routineId - Routine UUID
47
+ * @param {string} executionId - Execution UUID (optional)
48
+ * @returns {string} Safe session name
49
+ */
50
+ function generateSessionName(routineId, executionId) {
51
+ const routineShort = routineId ? routineId.substring(0, 8) : 'manual'
52
+ const execShort = executionId ? executionId.substring(0, 4) : ''
53
+ return execShort ? `rt-${routineShort}-${execShort}` : `rt-${routineShort}`
54
+ }
55
+
56
+ /**
57
+ * Write execution marker file for skill to read
58
+ * @param {string} sessionName - tmux session name
59
+ * @param {object} data - Execution metadata
60
+ */
61
+ async function writeMarkerFile(sessionName, data) {
62
+ try {
63
+ await fs.mkdir(MARKER_DIR, { recursive: true })
64
+ const filePath = path.join(MARKER_DIR, `${sessionName}.json`)
65
+ await fs.writeFile(filePath, JSON.stringify(data, null, 2), 'utf-8')
66
+ console.log(`[RoutineRunner] Wrote marker file: ${filePath}`)
67
+ } catch (err) {
68
+ console.error(`[RoutineRunner] Failed to write marker file: ${err.message}`)
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Clean up marker file after execution
74
+ * @param {string} sessionName - tmux session name
75
+ */
76
+ async function cleanupMarkerFile(sessionName) {
77
+ try {
78
+ const filePath = path.join(MARKER_DIR, `${sessionName}.json`)
79
+ await fs.unlink(filePath)
80
+ console.log(`[RoutineRunner] Cleaned up marker file: ${filePath}`)
81
+ } catch {
82
+ // Ignore if file doesn't exist
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Execute a routine in a single CLI session
88
+ * All skills run sequentially with context preserved.
89
+ * @param {object} routine - Routine configuration
90
+ * @param {string} executionId - Execution UUID
91
+ * @param {string[]} skillNames - Skills to execute
92
+ * @returns {Promise<{success: boolean, error?: string, sessionName?: string}>}
93
+ */
94
+ async function executeRoutineSession(routine, executionId, skillNames) {
95
+ const homeDir = os.homedir()
96
+ const sessionName = generateSessionName(routine.id, executionId)
97
+
98
+ // Build prompt: run each skill in sequence, then execution-report
99
+ const skillCommands = skillNames.map(name => `/${name}`).join(', then ')
100
+ const prompt = `Run the following skills in order: ${skillCommands}. After completing all skills, run /execution-report to report the results.`
101
+
102
+ // Extend PATH to include common CLI installation locations
103
+ const additionalPaths = [
104
+ path.join(homeDir, 'bin'),
105
+ path.join(homeDir, '.npm-global', 'bin'),
106
+ path.join(homeDir, '.local', 'bin'),
107
+ path.join(homeDir, '.claude', 'bin'),
108
+ '/usr/local/bin',
109
+ ]
110
+ const currentPath = process.env.PATH || ''
111
+ const extendedPath = [...additionalPaths, currentPath].join(':')
112
+
113
+ // Exit code file to capture CLI result
114
+ const exitCodeFile = `/tmp/tmux-exit-${sessionName}`
115
+
116
+ // Set up log file for this execution
117
+ const logFile = logManager.getLogPath(executionId)
118
+
119
+ console.log(`[RoutineRunner] Executing routine: ${routine.name}`)
120
+ console.log(`[RoutineRunner] Skills: ${skillNames.join(' → ')} → execution-report`)
121
+ console.log(`[RoutineRunner] tmux session: ${sessionName}`)
122
+ console.log(`[RoutineRunner] Log file: ${logFile}`)
123
+
124
+ try {
125
+ // Ensure log directory exists and prune old logs
126
+ await logManager.ensureLogDir()
127
+ await logManager.pruneOldLogs()
128
+
129
+ // Kill existing session if any
130
+ await execAsync(`tmux kill-session -t "${sessionName}" 2>/dev/null || true`)
131
+
132
+ // Remove old exit code file
133
+ await execAsync(`rm -f "${exitCodeFile}"`)
134
+
135
+ // Write marker file BEFORE starting session
136
+ await writeMarkerFile(sessionName, {
137
+ execution_id: executionId,
138
+ routine_id: routine.id,
139
+ routine_name: routine.name,
140
+ skill_names: skillNames,
141
+ started_at: new Date().toISOString(),
142
+ })
143
+
144
+ // Build the command to run in tmux
145
+ const escapedPrompt = prompt.replace(/'/g, "'\\''")
146
+ const claudeCommand = `claude -p '${escapedPrompt}'; echo $? > ${exitCodeFile}`
147
+
148
+ // Create tmux session with extended environment
149
+ // Pass execution context as environment variables for /execution-report skill
150
+ const tmuxCommand = [
151
+ 'tmux new-session -d',
152
+ `-s "${sessionName}"`,
153
+ '-x 200 -y 50',
154
+ `-e "DISPLAY=:99"`,
155
+ `-e "PATH=${extendedPath}"`,
156
+ `-e "HOME=${homeDir}"`,
157
+ `-e "MINION_EXECUTION_ID=${executionId}"`,
158
+ `-e "MINION_ROUTINE_ID=${routine.id}"`,
159
+ `-e "MINION_ROUTINE_NAME=${routine.name.replace(/"/g, '\\"')}"`,
160
+ `"${claudeCommand}"`,
161
+ ].join(' ')
162
+
163
+ await execAsync(tmuxCommand, {
164
+ cwd: homeDir,
165
+ env: { ...process.env, HOME: homeDir },
166
+ })
167
+
168
+ // Keep session alive after command completes (for debugging via terminal mirror)
169
+ await execAsync(`tmux set-option -t "${sessionName}" remain-on-exit on`)
170
+
171
+ // Set up pipe-pane to capture terminal output to log file
172
+ try {
173
+ await execAsync(`tmux pipe-pane -o -t "${sessionName}" "cat >> ${logFile}"`)
174
+ console.log(`[RoutineRunner] Enabled pipe-pane logging to: ${logFile}`)
175
+ } catch (err) {
176
+ console.error(`[RoutineRunner] Failed to set up pipe-pane: ${err.message}`)
177
+ }
178
+
179
+ console.log(`[RoutineRunner] Started tmux session: ${sessionName}`)
180
+
181
+ // Wait for session to complete (poll for exit code file)
182
+ const timeout = 60 * 60 * 1000 // 60 minutes
183
+ const pollInterval = 2000 // 2 seconds
184
+ const startTime = Date.now()
185
+
186
+ while (Date.now() - startTime < timeout) {
187
+ try {
188
+ await execAsync(`tmux has-session -t "${sessionName}" 2>/dev/null`)
189
+ await sleep(pollInterval)
190
+ } catch {
191
+ // Session ended
192
+ break
193
+ }
194
+ }
195
+
196
+ // Check if we timed out
197
+ if (Date.now() - startTime >= timeout) {
198
+ console.error(`[RoutineRunner] Routine ${routine.name} timed out after 60 minutes`)
199
+ await execAsync(`tmux kill-session -t "${sessionName}" 2>/dev/null || true`)
200
+ return { success: false, error: 'Execution timeout (60 minutes)', sessionName }
201
+ }
202
+
203
+ // Read exit code
204
+ try {
205
+ const { stdout } = await execAsync(`cat "${exitCodeFile}"`)
206
+ const exitCode = parseInt(stdout.trim(), 10)
207
+ await execAsync(`rm -f "${exitCodeFile}"`)
208
+
209
+ if (exitCode === 0) {
210
+ console.log(`[RoutineRunner] Routine ${routine.name} completed successfully`)
211
+ return { success: true, sessionName }
212
+ } else {
213
+ console.error(`[RoutineRunner] Routine ${routine.name} failed with exit code: ${exitCode}`)
214
+ return { success: false, error: `Exit code: ${exitCode}`, sessionName }
215
+ }
216
+ } catch {
217
+ console.log(`[RoutineRunner] Routine ${routine.name} completed (exit code unknown)`)
218
+ return { success: true, sessionName }
219
+ }
220
+ } catch (error) {
221
+ console.error(`[RoutineRunner] Routine ${routine.name} failed: ${error.message}`)
222
+ return { success: false, error: error.message, sessionName }
223
+ } finally {
224
+ await cleanupMarkerFile(sessionName)
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Run a routine: execute all pipeline skills in a single CLI session
230
+ * @param {object} routine - Routine configuration
231
+ * @returns {Promise<{execution_id: string, session_name: string}>}
232
+ */
233
+ async function runRoutine(routine) {
234
+ const pipelineSkillNames = routine.pipeline_skill_names || []
235
+
236
+ if (pipelineSkillNames.length === 0) {
237
+ console.log(`[RoutineRunner] Routine "${routine.name}" has empty pipeline, skipping`)
238
+ return { execution_id: null, session_name: null }
239
+ }
240
+
241
+ const executionId = crypto.randomUUID()
242
+ const startedAt = new Date().toISOString()
243
+ const sessionName = generateSessionName(routine.id, executionId)
244
+
245
+ console.log(`[RoutineRunner] Starting routine: ${routine.name} (execution: ${executionId})`)
246
+
247
+ runningExecutions.set(executionId, {
248
+ routine_name: routine.name,
249
+ skill_names: pipelineSkillNames,
250
+ started_at: startedAt,
251
+ session_name: sessionName,
252
+ })
253
+
254
+ const logFile = logManager.getLogPath(executionId)
255
+
256
+ // Save: routine running
257
+ await executionStore.save({
258
+ id: executionId,
259
+ skill_name: pipelineSkillNames.join(' → '),
260
+ routine_id: routine.id,
261
+ routine_name: routine.name,
262
+ status: 'running',
263
+ outcome: null,
264
+ started_at: startedAt,
265
+ completed_at: null,
266
+ parent_execution_id: null,
267
+ error_message: null,
268
+ log_file: logFile,
269
+ })
270
+
271
+ // Execute all skills in one session
272
+ const result = await executeRoutineSession(routine, executionId, pipelineSkillNames)
273
+
274
+ const completedAt = new Date().toISOString()
275
+
276
+ // Save: routine completed
277
+ await executionStore.save({
278
+ id: executionId,
279
+ skill_name: pipelineSkillNames.join(' → '),
280
+ routine_id: routine.id,
281
+ routine_name: routine.name,
282
+ status: result.success ? 'completed' : 'failed',
283
+ outcome: result.success ? null : 'failure',
284
+ started_at: startedAt,
285
+ completed_at: completedAt,
286
+ parent_execution_id: null,
287
+ error_message: result.error || null,
288
+ log_file: logFile,
289
+ })
290
+
291
+ // Update last_run in local store
292
+ await routineStore.updateLastRun(routine.id)
293
+
294
+ runningExecutions.delete(executionId)
295
+ console.log(`[RoutineRunner] Completed routine: ${routine.name}`)
296
+
297
+ return { execution_id: executionId, session_name: sessionName }
298
+ }
299
+
300
+ /**
301
+ * Load routines and register cron jobs
302
+ * @param {Array} routines - Array of routine configurations
303
+ */
304
+ function loadRoutines(routines) {
305
+ // Stop all existing jobs first
306
+ stopAll()
307
+
308
+ let activeCount = 0
309
+
310
+ for (const routine of routines) {
311
+ if (!routine.is_active) {
312
+ console.log(`[RoutineRunner] Skipping inactive routine: ${routine.name}`)
313
+ continue
314
+ }
315
+
316
+ if (!routine.cron_expression) {
317
+ console.log(`[RoutineRunner] Routine "${routine.name}" has no schedule (manual trigger only)`)
318
+ activeJobs.set(routine.id, { job: null, routine })
319
+ continue
320
+ }
321
+
322
+ try {
323
+ const job = new Cron(routine.cron_expression, () => {
324
+ runRoutine(routine).catch(err => {
325
+ console.error(`[RoutineRunner] Unhandled error in ${routine.name}: ${err.message}`)
326
+ })
327
+ })
328
+
329
+ activeJobs.set(routine.id, { job, routine })
330
+ activeCount++
331
+
332
+ console.log(`[RoutineRunner] Registered: ${routine.name} (${routine.cron_expression})`)
333
+ } catch (err) {
334
+ console.error(`[RoutineRunner] Failed to register ${routine.name}: ${err.message}`)
335
+ }
336
+ }
337
+
338
+ console.log(`[RoutineRunner] Loaded ${routines.length} routines, ${activeCount} with cron schedule`)
339
+ }
340
+
341
+ /**
342
+ * Stop all active cron jobs
343
+ */
344
+ function stopAll() {
345
+ for (const [, { job, routine }] of activeJobs) {
346
+ if (job) job.stop()
347
+ console.log(`[RoutineRunner] Stopped: ${routine.name}`)
348
+ }
349
+ activeJobs.clear()
350
+ }
351
+
352
+ /**
353
+ * Get current routine runner status
354
+ * @returns {object} Status summary
355
+ */
356
+ function getStatus() {
357
+ const routines = []
358
+ for (const [id, { job, routine }] of activeJobs) {
359
+ routines.push({
360
+ id,
361
+ name: routine.name,
362
+ cron_expression: routine.cron_expression,
363
+ next_run: job?.nextRun()?.toISOString() || null,
364
+ })
365
+ }
366
+
367
+ const running = []
368
+ for (const [id, info] of runningExecutions) {
369
+ running.push({
370
+ execution_id: id,
371
+ routine_name: info.routine_name,
372
+ skill_names: info.skill_names,
373
+ session_name: info.session_name,
374
+ started_at: info.started_at,
375
+ })
376
+ }
377
+
378
+ return {
379
+ active_routines: activeJobs.size,
380
+ running_executions: runningExecutions.size,
381
+ routines,
382
+ running,
383
+ }
384
+ }
385
+
386
+ /**
387
+ * Get a routine by ID from active jobs
388
+ * @param {string} routineId - Routine UUID
389
+ * @returns {object|null} Routine object or null
390
+ */
391
+ function getRoutineById(routineId) {
392
+ const entry = activeJobs.get(routineId)
393
+ return entry?.routine || null
394
+ }
395
+
396
+ module.exports = {
397
+ loadRoutines,
398
+ stopAll,
399
+ getStatus,
400
+ runRoutine,
401
+ getRoutineById,
402
+ generateSessionName,
403
+ MARKER_DIR,
404
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Routine Store
3
+ * Persists routine configurations to local JSON file.
4
+ * Allows minion to continue operating when HQ is offline.
5
+ */
6
+
7
+ const fs = require('fs').promises
8
+ const path = require('path')
9
+ const os = require('os')
10
+
11
+ // Routine file location: /opt/minion-agent/routines.json (systemd/supervisord)
12
+ // or ~/routines.json (standalone)
13
+ function getRoutineFilePath() {
14
+ const optPath = '/opt/minion-agent/routines.json'
15
+ try {
16
+ require('fs').accessSync(path.dirname(optPath))
17
+ return optPath
18
+ } catch {
19
+ return path.join(os.homedir(), 'routines.json')
20
+ }
21
+ }
22
+
23
+ const ROUTINE_FILE = getRoutineFilePath()
24
+
25
+ /**
26
+ * Load routines from local file
27
+ * @returns {Promise<Array>} Array of routine objects
28
+ */
29
+ async function load() {
30
+ try {
31
+ const data = await fs.readFile(ROUTINE_FILE, 'utf-8')
32
+ return JSON.parse(data)
33
+ } catch (err) {
34
+ if (err.code === 'ENOENT') {
35
+ return []
36
+ }
37
+ console.error(`[RoutineStore] Failed to load routines: ${err.message}`)
38
+ return []
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Save routines to local file
44
+ * @param {Array} routines - Array of routine objects
45
+ */
46
+ async function save(routines) {
47
+ try {
48
+ await fs.writeFile(ROUTINE_FILE, JSON.stringify(routines, null, 2), 'utf-8')
49
+ console.log(`[RoutineStore] Saved ${routines.length} routines to ${ROUTINE_FILE}`)
50
+ } catch (err) {
51
+ console.error(`[RoutineStore] Failed to save routines: ${err.message}`)
52
+ throw err
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Update last_run for a specific routine
58
+ * @param {string} routineId - Routine UUID
59
+ */
60
+ async function updateLastRun(routineId) {
61
+ const routines = await load()
62
+ const routine = routines.find(r => r.id === routineId)
63
+ if (routine) {
64
+ routine.last_run = new Date().toISOString()
65
+ await save(routines)
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Find a routine by name
71
+ * @param {string} name - Routine name
72
+ * @returns {Promise<object|null>} Routine object or null
73
+ */
74
+ async function findByName(name) {
75
+ const routines = await load()
76
+ return routines.find(r => r.name === name) || null
77
+ }
78
+
79
+ /**
80
+ * Upsert a routine from HQ data.
81
+ * Upserts by ID (routines come from HQ with UUIDs).
82
+ * @param {object} routineData - { id, name, pipeline_skill_names, content, is_active, cron_expression, last_run, next_run }
83
+ * @returns {Promise<Array>} Updated routines array
84
+ */
85
+ async function upsertFromHQ(routineData) {
86
+ const routines = await load()
87
+ const index = routines.findIndex(r => r.id === routineData.id)
88
+
89
+ if (index >= 0) {
90
+ // Update from HQ (preserve local-only fields if any)
91
+ routines[index] = {
92
+ ...routines[index],
93
+ name: routineData.name,
94
+ pipeline_skill_names: routineData.pipeline_skill_names,
95
+ content: routineData.content || '',
96
+ is_active: routineData.is_active,
97
+ cron_expression: routineData.cron_expression || '',
98
+ }
99
+ } else {
100
+ routines.push({
101
+ id: routineData.id,
102
+ name: routineData.name,
103
+ pipeline_skill_names: routineData.pipeline_skill_names || [],
104
+ content: routineData.content || '',
105
+ is_active: routineData.is_active ?? false,
106
+ cron_expression: routineData.cron_expression || '',
107
+ last_run: routineData.last_run || null,
108
+ next_run: routineData.next_run || null,
109
+ })
110
+ }
111
+
112
+ await save(routines)
113
+ return routines
114
+ }
115
+
116
+ module.exports = { load, save, updateLastRun, findByName, upsertFromHQ }