@geekbeer/minion 2.33.4 → 2.42.5
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/.env.example +0 -3
- package/README.md +0 -1
- package/core/api.js +13 -0
- package/core/config.js +46 -1
- package/core/lib/log-manager.js +4 -1
- package/core/lib/platform.js +8 -13
- package/core/lib/revision-watcher.js +252 -0
- package/core/lib/step-poller.js +222 -0
- package/core/lib/strip-ansi.js +18 -0
- package/core/lib/workflow-orchestrator.js +382 -0
- package/core/routes/diagnose.js +296 -0
- package/core/routes/health.js +27 -0
- package/core/routes/routines.js +15 -10
- package/core/routes/skills.js +4 -1
- package/core/routes/workflows.js +49 -2
- package/core/stores/chat-store.js +8 -1
- package/core/stores/routine-store.js +2 -2
- package/linux/lib/process-manager.js +14 -0
- package/linux/minion-cli.sh +57 -16
- package/linux/routes/chat.js +182 -20
- package/linux/routes/config.js +8 -12
- package/linux/routine-runner.js +5 -4
- package/linux/server.js +53 -1
- package/linux/workflow-runner.js +25 -61
- package/package.json +1 -1
- package/roles/pm.md +11 -12
- package/win/lib/process-manager.js +15 -0
- package/win/minion-cli.ps1 +122 -27
- package/win/routes/chat.js +178 -14
- package/win/routes/config.js +6 -2
- package/win/routine-runner.js +4 -2
- package/win/server.js +53 -0
- package/win/workflow-runner.js +31 -43
- package/skills/execution-report/SKILL.md +0 -106
package/linux/routes/chat.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* GET /api/chat/session - Get active session (messages + session_id)
|
|
13
13
|
* POST /api/chat/clear - Clear session and start fresh
|
|
14
14
|
* POST /api/chat/abort - Kill the active LLM CLI process
|
|
15
|
+
* POST /api/chat/reset - Summarize conversation and start fresh session
|
|
15
16
|
*/
|
|
16
17
|
|
|
17
18
|
const { spawn } = require('child_process')
|
|
@@ -136,6 +137,44 @@ async function chatRoutes(fastify) {
|
|
|
136
137
|
|
|
137
138
|
return { success: true }
|
|
138
139
|
})
|
|
140
|
+
|
|
141
|
+
// POST /api/chat/reset - Summarize conversation and start fresh session
|
|
142
|
+
fastify.post('/api/chat/reset', async (request, reply) => {
|
|
143
|
+
if (!verifyToken(request)) {
|
|
144
|
+
reply.code(401)
|
|
145
|
+
return { success: false, error: 'Unauthorized' }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const session = await chatStore.load()
|
|
149
|
+
if (!session || session.messages.length === 0) {
|
|
150
|
+
await chatStore.clear()
|
|
151
|
+
return { success: true, summary: null }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Build summarization prompt from recent messages
|
|
155
|
+
const recentMessages = session.messages.slice(-20)
|
|
156
|
+
const conversationText = recentMessages
|
|
157
|
+
.map(m => `${m.role === 'user' ? 'ユーザー' : 'アシスタント'}: ${m.content.substring(0, 500)}`)
|
|
158
|
+
.join('\n\n')
|
|
159
|
+
|
|
160
|
+
const summarizePrompt = `以下の会話を要約してください。重要なコンテキスト、決定事項、進行中のタスクを含めてください。200文字以内で。\n\n${conversationText}`
|
|
161
|
+
|
|
162
|
+
let summary = null
|
|
163
|
+
try {
|
|
164
|
+
summary = await runQuickLlmCall(summarizePrompt)
|
|
165
|
+
} catch (err) {
|
|
166
|
+
console.error('[Chat] summarization failed:', err.message)
|
|
167
|
+
// Fallback: use last few messages as summary
|
|
168
|
+
const fallback = recentMessages.slice(-4)
|
|
169
|
+
.map(m => `${m.role === 'user' ? 'ユーザー' : 'アシスタント'}: ${m.content.substring(0, 200)}`)
|
|
170
|
+
.join('\n')
|
|
171
|
+
summary = fallback
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
await chatStore.clear()
|
|
175
|
+
console.log(`[Chat] session reset with summary (${summary?.length || 0} chars)`)
|
|
176
|
+
return { success: true, summary }
|
|
177
|
+
})
|
|
139
178
|
}
|
|
140
179
|
|
|
141
180
|
/**
|
|
@@ -210,8 +249,8 @@ function getLlmBinary() {
|
|
|
210
249
|
/**
|
|
211
250
|
* Stream LLM CLI output as SSE events.
|
|
212
251
|
* Uses --resume to continue existing sessions.
|
|
213
|
-
*
|
|
214
|
-
*
|
|
252
|
+
* Tracks block types to correctly forward tool_use vs text events
|
|
253
|
+
* and counts turns for session management.
|
|
215
254
|
*/
|
|
216
255
|
function streamLlmResponse(res, prompt, sessionId) {
|
|
217
256
|
return new Promise((resolve, reject) => {
|
|
@@ -275,6 +314,12 @@ function streamLlmResponse(res, prompt, sessionId) {
|
|
|
275
314
|
let lineBuffer = ''
|
|
276
315
|
let resolvedSessionId = sessionId || null
|
|
277
316
|
|
|
317
|
+
// Block-type state tracking for correct event forwarding
|
|
318
|
+
let currentBlockType = null // 'text' | 'tool_use' | null
|
|
319
|
+
let currentToolName = null
|
|
320
|
+
let toolInputBuffer = ''
|
|
321
|
+
let turnCount = 0
|
|
322
|
+
|
|
278
323
|
child.stdout.on('data', (data) => {
|
|
279
324
|
lineBuffer += data.toString()
|
|
280
325
|
const parts = lineBuffer.split('\n')
|
|
@@ -292,18 +337,68 @@ function streamLlmResponse(res, prompt, sessionId) {
|
|
|
292
337
|
console.log(`[Chat] session_id: ${resolvedSessionId}`)
|
|
293
338
|
}
|
|
294
339
|
|
|
295
|
-
//
|
|
296
|
-
if (parsed.type === 'content_block_start'
|
|
297
|
-
const
|
|
298
|
-
|
|
340
|
+
// content_block_start — track block type
|
|
341
|
+
if (parsed.type === 'content_block_start') {
|
|
342
|
+
const blockType = parsed.content_block?.type
|
|
343
|
+
if (blockType === 'tool_use') {
|
|
344
|
+
currentBlockType = 'tool_use'
|
|
345
|
+
currentToolName = parsed.content_block.name || 'unknown'
|
|
346
|
+
toolInputBuffer = ''
|
|
347
|
+
const event = JSON.stringify({
|
|
348
|
+
type: 'tool_start',
|
|
349
|
+
tool: currentToolName,
|
|
350
|
+
})
|
|
351
|
+
res.write(`data: ${event}\n\n`)
|
|
352
|
+
} else if (blockType === 'text') {
|
|
353
|
+
currentBlockType = 'text'
|
|
354
|
+
}
|
|
299
355
|
}
|
|
356
|
+
|
|
357
|
+
// content_block_delta — handle both text and tool input
|
|
358
|
+
if (parsed.type === 'content_block_delta') {
|
|
359
|
+
const deltaType = parsed.delta?.type
|
|
360
|
+
if (deltaType === 'input_json_delta' && currentBlockType === 'tool_use') {
|
|
361
|
+
// Accumulate tool input JSON
|
|
362
|
+
const partial = parsed.delta.partial_json || ''
|
|
363
|
+
if (partial) {
|
|
364
|
+
toolInputBuffer += partial
|
|
365
|
+
const event = JSON.stringify({ type: 'tool_input_delta', partial_json: partial })
|
|
366
|
+
res.write(`data: ${event}\n\n`)
|
|
367
|
+
}
|
|
368
|
+
} else {
|
|
369
|
+
// Text delta
|
|
370
|
+
const delta = parsed.delta?.text || ''
|
|
371
|
+
if (delta) {
|
|
372
|
+
fullResponse += delta
|
|
373
|
+
const event = JSON.stringify({ type: 'delta', content: delta })
|
|
374
|
+
res.write(`data: ${event}\n\n`)
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// content_block_stop — only emit tool_end for tool_use blocks (bug fix)
|
|
300
380
|
if (parsed.type === 'content_block_stop') {
|
|
301
|
-
|
|
302
|
-
|
|
381
|
+
if (currentBlockType === 'tool_use') {
|
|
382
|
+
// Try to parse the accumulated tool input
|
|
383
|
+
let parsedInput = null
|
|
384
|
+
try {
|
|
385
|
+
if (toolInputBuffer) parsedInput = JSON.parse(toolInputBuffer)
|
|
386
|
+
} catch { /* partial or invalid JSON */ }
|
|
387
|
+
const event = JSON.stringify({
|
|
388
|
+
type: 'tool_end',
|
|
389
|
+
tool: currentToolName,
|
|
390
|
+
input: parsedInput,
|
|
391
|
+
})
|
|
392
|
+
res.write(`data: ${event}\n\n`)
|
|
393
|
+
}
|
|
394
|
+
currentBlockType = null
|
|
395
|
+
currentToolName = null
|
|
396
|
+
toolInputBuffer = ''
|
|
303
397
|
}
|
|
304
398
|
|
|
305
|
-
// assistant message
|
|
399
|
+
// assistant message — count turns and forward text blocks
|
|
306
400
|
if (parsed.type === 'assistant' && parsed.message) {
|
|
401
|
+
turnCount++
|
|
307
402
|
for (const block of (parsed.message.content || [])) {
|
|
308
403
|
if (block.type === 'text') {
|
|
309
404
|
fullResponse += block.text
|
|
@@ -311,20 +406,17 @@ function streamLlmResponse(res, prompt, sessionId) {
|
|
|
311
406
|
res.write(`data: ${event}\n\n`)
|
|
312
407
|
}
|
|
313
408
|
}
|
|
314
|
-
} else if (parsed.type === 'content_block_delta') {
|
|
315
|
-
const delta = parsed.delta?.text || ''
|
|
316
|
-
if (delta) {
|
|
317
|
-
fullResponse += delta
|
|
318
|
-
const event = JSON.stringify({ type: 'delta', content: delta })
|
|
319
|
-
res.write(`data: ${event}\n\n`)
|
|
320
|
-
}
|
|
321
409
|
} else if (parsed.type === 'result') {
|
|
322
|
-
// result event —
|
|
410
|
+
// result event — forward but do NOT overwrite accumulated fullResponse
|
|
323
411
|
const resultText = parsed.result || ''
|
|
324
412
|
if (resultText) {
|
|
325
413
|
const event = JSON.stringify({ type: 'result', content: resultText })
|
|
326
414
|
res.write(`data: ${event}\n\n`)
|
|
327
|
-
fullResponse
|
|
415
|
+
// Use result as fullResponse only if nothing was accumulated
|
|
416
|
+
// (single-turn responses without deltas)
|
|
417
|
+
if (!fullResponse) {
|
|
418
|
+
fullResponse = resultText
|
|
419
|
+
}
|
|
328
420
|
}
|
|
329
421
|
}
|
|
330
422
|
} catch {
|
|
@@ -349,9 +441,9 @@ function streamLlmResponse(res, prompt, sessionId) {
|
|
|
349
441
|
if (!sessionId) {
|
|
350
442
|
await chatStore.addMessage(resolvedSessionId, { role: 'user', content: prompt })
|
|
351
443
|
}
|
|
352
|
-
// Store assistant response
|
|
444
|
+
// Store assistant response with turn count
|
|
353
445
|
if (fullResponse) {
|
|
354
|
-
await chatStore.addMessage(resolvedSessionId, { role: 'assistant', content: fullResponse })
|
|
446
|
+
await chatStore.addMessage(resolvedSessionId, { role: 'assistant', content: fullResponse }, turnCount)
|
|
355
447
|
}
|
|
356
448
|
}
|
|
357
449
|
|
|
@@ -363,9 +455,14 @@ function streamLlmResponse(res, prompt, sessionId) {
|
|
|
363
455
|
res.write(`data: ${errorEvent}\n\n`)
|
|
364
456
|
}
|
|
365
457
|
|
|
458
|
+
// Load current turn count from session for the done event
|
|
459
|
+
const session = await chatStore.load()
|
|
460
|
+
const totalTurnCount = session?.turn_count || turnCount
|
|
461
|
+
|
|
366
462
|
const doneEvent = JSON.stringify({
|
|
367
463
|
type: 'done',
|
|
368
464
|
session_id: resolvedSessionId,
|
|
465
|
+
turn_count: totalTurnCount,
|
|
369
466
|
})
|
|
370
467
|
res.write(`data: ${doneEvent}\n\n`)
|
|
371
468
|
resolve()
|
|
@@ -386,4 +483,69 @@ function streamLlmResponse(res, prompt, sessionId) {
|
|
|
386
483
|
})
|
|
387
484
|
}
|
|
388
485
|
|
|
486
|
+
/**
|
|
487
|
+
* Run a quick non-streaming LLM call (for summarization etc.)
|
|
488
|
+
* Uses -p with json output format, no --resume.
|
|
489
|
+
* @param {string} prompt
|
|
490
|
+
* @returns {Promise<string>} The result text
|
|
491
|
+
*/
|
|
492
|
+
function runQuickLlmCall(prompt) {
|
|
493
|
+
return new Promise((resolve, reject) => {
|
|
494
|
+
const binaryName = getLlmBinary()
|
|
495
|
+
if (!binaryName) {
|
|
496
|
+
reject(new Error('LLM_COMMAND is not configured'))
|
|
497
|
+
return
|
|
498
|
+
}
|
|
499
|
+
const binaryPath = path.join(config.HOME_DIR, '.local', 'bin', binaryName)
|
|
500
|
+
const binary = fs.existsSync(binaryPath) ? binaryPath : binaryName
|
|
501
|
+
|
|
502
|
+
const extendedPath = [
|
|
503
|
+
`${config.HOME_DIR}/bin`,
|
|
504
|
+
`${config.HOME_DIR}/.npm-global/bin`,
|
|
505
|
+
`${config.HOME_DIR}/.local/bin`,
|
|
506
|
+
`${config.HOME_DIR}/.claude/bin`,
|
|
507
|
+
'/usr/local/bin',
|
|
508
|
+
'/usr/bin',
|
|
509
|
+
'/bin',
|
|
510
|
+
].join(':')
|
|
511
|
+
|
|
512
|
+
const args = ['-p', '--model', 'haiku', '--max-turns', '1', '--output-format', 'json', prompt]
|
|
513
|
+
|
|
514
|
+
const child = spawn(binary, args, {
|
|
515
|
+
cwd: config.HOME_DIR,
|
|
516
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
517
|
+
timeout: 30000,
|
|
518
|
+
env: {
|
|
519
|
+
...process.env,
|
|
520
|
+
HOME: config.HOME_DIR,
|
|
521
|
+
PATH: extendedPath,
|
|
522
|
+
DISPLAY: ':99',
|
|
523
|
+
},
|
|
524
|
+
})
|
|
525
|
+
|
|
526
|
+
child.stdin.end()
|
|
527
|
+
|
|
528
|
+
let stdout = ''
|
|
529
|
+
let stderr = ''
|
|
530
|
+
|
|
531
|
+
child.stdout.on('data', (data) => { stdout += data.toString() })
|
|
532
|
+
child.stderr.on('data', (data) => { stderr += data.toString() })
|
|
533
|
+
|
|
534
|
+
child.on('close', (code) => {
|
|
535
|
+
if (code !== 0) {
|
|
536
|
+
reject(new Error(`LLM call failed (exit ${code}): ${stderr.substring(0, 200)}`))
|
|
537
|
+
return
|
|
538
|
+
}
|
|
539
|
+
try {
|
|
540
|
+
const parsed = JSON.parse(stdout)
|
|
541
|
+
resolve(parsed.result || stdout.trim())
|
|
542
|
+
} catch {
|
|
543
|
+
resolve(stdout.trim())
|
|
544
|
+
}
|
|
545
|
+
})
|
|
546
|
+
|
|
547
|
+
child.on('error', (err) => reject(err))
|
|
548
|
+
})
|
|
549
|
+
}
|
|
550
|
+
|
|
389
551
|
module.exports = { chatRoutes }
|
package/linux/routes/config.js
CHANGED
|
@@ -12,7 +12,8 @@ const fs = require('fs')
|
|
|
12
12
|
const path = require('path')
|
|
13
13
|
const { verifyToken } = require('../../core/lib/auth')
|
|
14
14
|
const { clearLlmCache } = require('../../core/lib/llm-checker')
|
|
15
|
-
const { config } = require('../../core/config')
|
|
15
|
+
const { config, updateConfig } = require('../../core/config')
|
|
16
|
+
const { resolveEnvFilePath: resolveEnvFilePathFromPlatform } = require('../../core/lib/platform')
|
|
16
17
|
|
|
17
18
|
/** Keys that can be read/written via the config API */
|
|
18
19
|
const ALLOWED_ENV_KEYS = ['LLM_COMMAND']
|
|
@@ -26,18 +27,10 @@ const BACKUP_PATHS = [
|
|
|
26
27
|
|
|
27
28
|
/**
|
|
28
29
|
* Resolve the .env file path.
|
|
29
|
-
*
|
|
30
|
-
* falls back to ~/minion.env (standalone/dev).
|
|
31
|
-
* Checks that the directory is writable by the current process.
|
|
30
|
+
* Delegates to core/lib/platform.js for cross-platform consistency.
|
|
32
31
|
*/
|
|
33
32
|
function resolveEnvFilePath() {
|
|
34
|
-
|
|
35
|
-
try {
|
|
36
|
-
fs.accessSync(path.dirname(optPath), fs.constants.W_OK)
|
|
37
|
-
return optPath
|
|
38
|
-
} catch {
|
|
39
|
-
return path.join(config.HOME_DIR, 'minion.env')
|
|
40
|
-
}
|
|
33
|
+
return resolveEnvFilePathFromPlatform(config.HOME_DIR)
|
|
41
34
|
}
|
|
42
35
|
|
|
43
36
|
/**
|
|
@@ -235,10 +228,13 @@ function configRoutes(fastify, _opts, done) {
|
|
|
235
228
|
writeEnvKey(envPath, key, value)
|
|
236
229
|
console.log(`[Config] Updated ${key} in ${envPath}`)
|
|
237
230
|
|
|
231
|
+
// Sync in-memory config so the change takes effect without restart
|
|
232
|
+
updateConfig(key, value)
|
|
233
|
+
|
|
238
234
|
// Clear LLM cache so health check reflects immediately
|
|
239
235
|
clearLlmCache()
|
|
240
236
|
|
|
241
|
-
return { success: true, restart_required:
|
|
237
|
+
return { success: true, restart_required: false }
|
|
242
238
|
} catch (err) {
|
|
243
239
|
console.error(`[Config] Failed to update ${key} in ${envPath}:`, err.message)
|
|
244
240
|
const detail = err.code === 'EACCES' ? ' (permission denied)' : ''
|
package/linux/routine-runner.js
CHANGED
|
@@ -189,17 +189,18 @@ async function executeRoutineSession(routine, executionId, skillNames) {
|
|
|
189
189
|
console.log(`[RoutineRunner] Started tmux session: ${sessionName}`)
|
|
190
190
|
|
|
191
191
|
// Wait for session to complete (poll for exit code file)
|
|
192
|
+
// Note: use fs.access on exitCodeFile (not tmux has-session) because
|
|
193
|
+
// remain-on-exit keeps the session alive after command completes.
|
|
192
194
|
const timeout = 60 * 60 * 1000 // 60 minutes
|
|
193
195
|
const pollInterval = 2000 // 2 seconds
|
|
194
196
|
const startTime = Date.now()
|
|
195
197
|
|
|
196
198
|
while (Date.now() - startTime < timeout) {
|
|
197
199
|
try {
|
|
198
|
-
await
|
|
199
|
-
|
|
200
|
+
await fs.access(exitCodeFile)
|
|
201
|
+
break // Exit code file exists — CLI has finished
|
|
200
202
|
} catch {
|
|
201
|
-
|
|
202
|
-
break
|
|
203
|
+
await sleep(pollInterval)
|
|
203
204
|
}
|
|
204
205
|
}
|
|
205
206
|
|
package/linux/server.js
CHANGED
|
@@ -31,17 +31,28 @@ const PACKAGE_ROOT = path.join(__dirname, '..')
|
|
|
31
31
|
|
|
32
32
|
// Core shared modules
|
|
33
33
|
const { config, validate, isHqConfigured } = require('../core/config')
|
|
34
|
+
const { sendHeartbeat } = require('../core/api')
|
|
35
|
+
const { version } = require('../package.json')
|
|
34
36
|
const workflowStore = require('../core/stores/workflow-store')
|
|
35
37
|
const routineStore = require('../core/stores/routine-store')
|
|
36
38
|
|
|
39
|
+
// Heartbeat interval: fixed at 30s (not user-configurable)
|
|
40
|
+
const HEARTBEAT_INTERVAL_MS = 30_000
|
|
41
|
+
let heartbeatTimer = null
|
|
42
|
+
|
|
37
43
|
// Linux-specific modules
|
|
38
44
|
const workflowRunner = require('./workflow-runner')
|
|
39
45
|
const routineRunner = require('./routine-runner')
|
|
40
46
|
const { cleanupTtyd, killStaleTtydProcesses } = require('./routes/terminal')
|
|
41
47
|
const { startTerminalProxy, stopTerminalProxy } = require('./terminal-proxy')
|
|
42
48
|
|
|
49
|
+
// Pull-model daemons (from core/)
|
|
50
|
+
const stepPoller = require('../core/lib/step-poller')
|
|
51
|
+
const revisionWatcher = require('../core/lib/revision-watcher')
|
|
52
|
+
|
|
43
53
|
// Shared routes (from core/)
|
|
44
54
|
const { healthRoutes, setOffline } = require('../core/routes/health')
|
|
55
|
+
const { diagnoseRoutes } = require('../core/routes/diagnose')
|
|
45
56
|
const { skillRoutes } = require('../core/routes/skills')
|
|
46
57
|
const { workflowRoutes } = require('../core/routes/workflows')
|
|
47
58
|
const { routineRoutes } = require('../core/routes/routines')
|
|
@@ -68,7 +79,27 @@ async function shutdown(signal) {
|
|
|
68
79
|
|
|
69
80
|
setOffline()
|
|
70
81
|
|
|
71
|
-
// Stop
|
|
82
|
+
// Stop heartbeat timer
|
|
83
|
+
if (heartbeatTimer) {
|
|
84
|
+
clearInterval(heartbeatTimer)
|
|
85
|
+
heartbeatTimer = null
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Send offline heartbeat to HQ (best-effort, don't block shutdown)
|
|
89
|
+
if (isHqConfigured()) {
|
|
90
|
+
try {
|
|
91
|
+
await Promise.race([
|
|
92
|
+
sendHeartbeat({ status: 'offline', version }),
|
|
93
|
+
new Promise(resolve => setTimeout(resolve, 3000)),
|
|
94
|
+
])
|
|
95
|
+
} catch {
|
|
96
|
+
// Best-effort — don't block shutdown
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Stop pollers and runners
|
|
101
|
+
stepPoller.stop()
|
|
102
|
+
revisionWatcher.stop()
|
|
72
103
|
workflowRunner.stopAll()
|
|
73
104
|
routineRunner.stopAll()
|
|
74
105
|
|
|
@@ -216,6 +247,7 @@ function syncBundledDocs() {
|
|
|
216
247
|
async function registerAllRoutes(app) {
|
|
217
248
|
// Shared routes (from core/) - inject runners via opts
|
|
218
249
|
await app.register(healthRoutes)
|
|
250
|
+
await app.register(diagnoseRoutes)
|
|
219
251
|
await app.register(skillRoutes, { workflowRunner })
|
|
220
252
|
await app.register(workflowRoutes, { workflowRunner })
|
|
221
253
|
await app.register(routineRoutes, { routineRunner })
|
|
@@ -281,6 +313,26 @@ async function start() {
|
|
|
281
313
|
|
|
282
314
|
if (isHqConfigured()) {
|
|
283
315
|
console.log(`[Server] HQ URL: ${config.HQ_URL}`)
|
|
316
|
+
|
|
317
|
+
// Send initial online heartbeat
|
|
318
|
+
const { getStatus } = require('../core/routes/health')
|
|
319
|
+
const { currentTask } = getStatus()
|
|
320
|
+
sendHeartbeat({ status: 'online', current_task: currentTask, version }).catch(err => {
|
|
321
|
+
console.error('[Heartbeat] Initial heartbeat failed:', err.message)
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
// Start periodic heartbeat
|
|
325
|
+
heartbeatTimer = setInterval(() => {
|
|
326
|
+
const { currentStatus, currentTask } = getStatus()
|
|
327
|
+
sendHeartbeat({ status: currentStatus, current_task: currentTask, version }).catch(err => {
|
|
328
|
+
console.error('[Heartbeat] Periodic heartbeat failed:', err.message)
|
|
329
|
+
})
|
|
330
|
+
}, HEARTBEAT_INTERVAL_MS)
|
|
331
|
+
console.log(`[Heartbeat] Sending every ${HEARTBEAT_INTERVAL_MS / 1000}s`)
|
|
332
|
+
|
|
333
|
+
// Start Pull-model daemons
|
|
334
|
+
stepPoller.start()
|
|
335
|
+
revisionWatcher.start()
|
|
284
336
|
} else {
|
|
285
337
|
console.log('[Server] Running in standalone mode (no HQ connection)')
|
|
286
338
|
}
|
package/linux/workflow-runner.js
CHANGED
|
@@ -6,8 +6,7 @@
|
|
|
6
6
|
*
|
|
7
7
|
* Key design:
|
|
8
8
|
* - All skills in a workflow run in ONE CLI session (context preserved)
|
|
9
|
-
* - /
|
|
10
|
-
* - Marker file written before session start for skill-to-execution mapping
|
|
9
|
+
* - Outcome (success/failure) is determined by CLI exit code and recorded automatically
|
|
11
10
|
*/
|
|
12
11
|
|
|
13
12
|
const { Cron } = require('croner')
|
|
@@ -29,9 +28,6 @@ const activeJobs = new Map()
|
|
|
29
28
|
// Currently running executions
|
|
30
29
|
const runningExecutions = new Map()
|
|
31
30
|
|
|
32
|
-
// Marker file directory
|
|
33
|
-
const MARKER_DIR = '/tmp/minion-executions'
|
|
34
|
-
|
|
35
31
|
/**
|
|
36
32
|
* Sleep for specified milliseconds
|
|
37
33
|
* @param {number} ms - Milliseconds to sleep
|
|
@@ -54,36 +50,6 @@ function generateSessionName(workflowId, executionId) {
|
|
|
54
50
|
return execShort ? `wf-${workflowShort}-${execShort}` : `wf-${workflowShort}`
|
|
55
51
|
}
|
|
56
52
|
|
|
57
|
-
/**
|
|
58
|
-
* Write execution marker file for skill to read
|
|
59
|
-
* @param {string} sessionName - tmux session name
|
|
60
|
-
* @param {object} data - Execution metadata
|
|
61
|
-
*/
|
|
62
|
-
async function writeMarkerFile(sessionName, data) {
|
|
63
|
-
try {
|
|
64
|
-
await fs.mkdir(MARKER_DIR, { recursive: true })
|
|
65
|
-
const filePath = path.join(MARKER_DIR, `${sessionName}.json`)
|
|
66
|
-
await fs.writeFile(filePath, JSON.stringify(data, null, 2), 'utf-8')
|
|
67
|
-
console.log(`[WorkflowRunner] Wrote marker file: ${filePath}`)
|
|
68
|
-
} catch (err) {
|
|
69
|
-
console.error(`[WorkflowRunner] Failed to write marker file: ${err.message}`)
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* Clean up marker file after execution
|
|
75
|
-
* @param {string} sessionName - tmux session name
|
|
76
|
-
*/
|
|
77
|
-
async function cleanupMarkerFile(sessionName) {
|
|
78
|
-
try {
|
|
79
|
-
const filePath = path.join(MARKER_DIR, `${sessionName}.json`)
|
|
80
|
-
await fs.unlink(filePath)
|
|
81
|
-
console.log(`[WorkflowRunner] Cleaned up marker file: ${filePath}`)
|
|
82
|
-
} catch {
|
|
83
|
-
// Ignore if file doesn't exist
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
|
|
87
53
|
/**
|
|
88
54
|
* Execute a workflow in a single CLI session
|
|
89
55
|
* All skills run sequentially with context preserved.
|
|
@@ -95,8 +61,6 @@ async function executeWorkflowSession(workflow, executionId, skillNames, options
|
|
|
95
61
|
const sessionName = generateSessionName(workflow.id, executionId)
|
|
96
62
|
|
|
97
63
|
// Build prompt: run each skill in sequence
|
|
98
|
-
// When skipExecutionReport is true (dispatched step), the minion server's
|
|
99
|
-
// post-execution hook handles completion reporting instead of /execution-report.
|
|
100
64
|
const skillCommands = skillNames.map(name => `/${name}`).join(', then ')
|
|
101
65
|
|
|
102
66
|
// Inject role context if provided (e.g. "pm" or "engineer")
|
|
@@ -109,9 +73,7 @@ async function executeWorkflowSession(workflow, executionId, skillNames, options
|
|
|
109
73
|
? `## Revision Feedback\nThe reviewer requested changes to your previous output. Address the following feedback:\n${options.revisionFeedback}\n\n`
|
|
110
74
|
: ''
|
|
111
75
|
|
|
112
|
-
const prompt =
|
|
113
|
-
? `${rolePrefix}${revisionContext}Run the following skills in order: ${skillCommands}.`
|
|
114
|
-
: `${rolePrefix}${revisionContext}Run the following skills in order: ${skillCommands}. After completing all skills, run /execution-report to report the results.`
|
|
76
|
+
const prompt = `${rolePrefix}${revisionContext}Run the following skills in order: ${skillCommands}.`
|
|
115
77
|
|
|
116
78
|
// Extend PATH to include common CLI installation locations
|
|
117
79
|
const additionalPaths = [
|
|
@@ -131,7 +93,7 @@ async function executeWorkflowSession(workflow, executionId, skillNames, options
|
|
|
131
93
|
const logFile = logManager.getLogPath(executionId)
|
|
132
94
|
|
|
133
95
|
console.log(`[WorkflowRunner] Executing workflow: ${workflow.name}`)
|
|
134
|
-
console.log(`[WorkflowRunner] Skills: ${skillNames.join(' → ')}
|
|
96
|
+
console.log(`[WorkflowRunner] Skills: ${skillNames.join(' → ')}`)
|
|
135
97
|
console.log(`[WorkflowRunner] tmux session: ${sessionName}`)
|
|
136
98
|
console.log(`[WorkflowRunner] Log file: ${logFile}`)
|
|
137
99
|
console.log(`[WorkflowRunner] HOME: ${homeDir}`)
|
|
@@ -147,15 +109,6 @@ async function executeWorkflowSession(workflow, executionId, skillNames, options
|
|
|
147
109
|
// Remove old exit code file
|
|
148
110
|
await execAsync(`rm -f "${exitCodeFile}"`)
|
|
149
111
|
|
|
150
|
-
// Write marker file BEFORE starting session
|
|
151
|
-
await writeMarkerFile(sessionName, {
|
|
152
|
-
execution_id: executionId,
|
|
153
|
-
workflow_id: workflow.id,
|
|
154
|
-
workflow_name: workflow.name,
|
|
155
|
-
skill_names: skillNames,
|
|
156
|
-
started_at: new Date().toISOString(),
|
|
157
|
-
})
|
|
158
|
-
|
|
159
112
|
// Build the command to run in tmux
|
|
160
113
|
if (!config.LLM_COMMAND) {
|
|
161
114
|
throw new Error('LLM_COMMAND is not configured. Set LLM_COMMAND in minion.env (e.g., LLM_COMMAND="claude -p \'{prompt}\'")')
|
|
@@ -165,7 +118,6 @@ async function executeWorkflowSession(workflow, executionId, skillNames, options
|
|
|
165
118
|
const execCommand = `${llmCommand}; echo $? > ${exitCodeFile}`
|
|
166
119
|
|
|
167
120
|
// Create tmux session with extended environment
|
|
168
|
-
// Pass execution context as environment variables for /execution-report skill
|
|
169
121
|
const tmuxCommand = [
|
|
170
122
|
'tmux new-session -d',
|
|
171
123
|
`-s "${sessionName}"`,
|
|
@@ -173,9 +125,6 @@ async function executeWorkflowSession(workflow, executionId, skillNames, options
|
|
|
173
125
|
`-e "DISPLAY=:99"`,
|
|
174
126
|
`-e "PATH=${extendedPath}"`,
|
|
175
127
|
`-e "HOME=${homeDir}"`,
|
|
176
|
-
`-e "MINION_EXECUTION_ID=${executionId}"`,
|
|
177
|
-
`-e "MINION_WORKFLOW_ID=${workflow.id}"`,
|
|
178
|
-
`-e "MINION_WORKFLOW_NAME=${workflow.name.replace(/"/g, '\\"')}"`,
|
|
179
128
|
`"${execCommand}"`,
|
|
180
129
|
].join(' ')
|
|
181
130
|
|
|
@@ -240,9 +189,6 @@ async function executeWorkflowSession(workflow, executionId, skillNames, options
|
|
|
240
189
|
} catch (error) {
|
|
241
190
|
console.error(`[WorkflowRunner] Workflow ${workflow.name} failed: ${error.message}`)
|
|
242
191
|
return { success: false, error: error.message, sessionName }
|
|
243
|
-
} finally {
|
|
244
|
-
// Clean up marker file
|
|
245
|
-
await cleanupMarkerFile(sessionName)
|
|
246
192
|
}
|
|
247
193
|
}
|
|
248
194
|
|
|
@@ -306,17 +252,17 @@ async function runWorkflow(workflow, options = {}) {
|
|
|
306
252
|
const result = await executeWorkflowSession(workflow, executionId, pipelineSkillNames, options)
|
|
307
253
|
|
|
308
254
|
const completedAt = new Date().toISOString()
|
|
255
|
+
const outcome = result.success ? 'success' : 'failure'
|
|
309
256
|
console.log(`[WorkflowRunner] executeWorkflowSession returned: success=${result.success}, error=${result.error || 'none'}`)
|
|
310
257
|
|
|
311
|
-
// Save
|
|
312
|
-
// If CLI exited with error, mark as failed; otherwise mark as completed (pending outcome)
|
|
258
|
+
// Save execution with outcome determined by CLI exit code
|
|
313
259
|
await saveExecution({
|
|
314
260
|
id: executionId,
|
|
315
261
|
skill_name: pipelineSkillNames.join(' → '),
|
|
316
262
|
workflow_id: workflow.id,
|
|
317
263
|
workflow_name: workflow.name,
|
|
318
264
|
status: result.success ? 'completed' : 'failed',
|
|
319
|
-
outcome
|
|
265
|
+
outcome,
|
|
320
266
|
started_at: startedAt,
|
|
321
267
|
completed_at: completedAt,
|
|
322
268
|
parent_execution_id: null,
|
|
@@ -324,6 +270,25 @@ async function runWorkflow(workflow, options = {}) {
|
|
|
324
270
|
log_file: logFile,
|
|
325
271
|
})
|
|
326
272
|
|
|
273
|
+
// Report outcome via local API (same data the execution-report skill used to send)
|
|
274
|
+
try {
|
|
275
|
+
const resp = await fetch(`http://localhost:${config.AGENT_PORT || 8080}/api/executions/${executionId}/outcome`, {
|
|
276
|
+
method: 'POST',
|
|
277
|
+
headers: { 'Content-Type': 'application/json' },
|
|
278
|
+
body: JSON.stringify({
|
|
279
|
+
outcome,
|
|
280
|
+
summary: result.success
|
|
281
|
+
? `All skills completed successfully: ${pipelineSkillNames.join(', ')}`
|
|
282
|
+
: `Workflow failed: ${result.error || 'unknown error'}`,
|
|
283
|
+
}),
|
|
284
|
+
})
|
|
285
|
+
if (!resp.ok) {
|
|
286
|
+
console.error(`[WorkflowRunner] Failed to report outcome: ${resp.status}`)
|
|
287
|
+
}
|
|
288
|
+
} catch (err) {
|
|
289
|
+
console.error(`[WorkflowRunner] Failed to report outcome: ${err.message}`)
|
|
290
|
+
}
|
|
291
|
+
|
|
327
292
|
// Update last_run in local store
|
|
328
293
|
await workflowStore.updateLastRun(workflow.id)
|
|
329
294
|
|
|
@@ -438,5 +403,4 @@ module.exports = {
|
|
|
438
403
|
runWorkflow,
|
|
439
404
|
getWorkflowById,
|
|
440
405
|
generateSessionName,
|
|
441
|
-
MARKER_DIR,
|
|
442
406
|
}
|
package/package.json
CHANGED
package/roles/pm.md
CHANGED
|
@@ -6,26 +6,25 @@
|
|
|
6
6
|
|
|
7
7
|
- **ワークフロー管理**: ワークフローの作成・修正・バージョン管理
|
|
8
8
|
- **プロジェクトコンテキスト管理**: プロジェクトの共有コンテキスト(markdown)の更新
|
|
9
|
-
- **オーケストレーション**: ワークフローステップのディスパッチ、エンジニアミニオンへの指示
|
|
10
9
|
- **レビュー**: `requires_review=true` のステップの承認判断
|
|
11
10
|
|
|
12
11
|
## Workflow Execution Model
|
|
13
12
|
|
|
14
|
-
ワークフロー実行は
|
|
13
|
+
ワークフロー実行はPMミニオンのランタイム(Node.js)がオーケストレーションする。
|
|
14
|
+
HQのDBがステートマシンとして状態を管理し、ミニオン間の直接通信は不要。
|
|
15
15
|
|
|
16
16
|
```
|
|
17
|
-
1. Execution 作成 → workflow_execution + 全 step_executions (status='pending')
|
|
18
|
-
2.
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
4. 次ステップが eligible に → 別のミニオンが検知して実行
|
|
17
|
+
1. HQ が Execution 作成 → workflow_execution + 全 step_executions (status='pending')
|
|
18
|
+
2. HQ が PM ミニオンの POST /api/workflows/orchestrate を呼び出し
|
|
19
|
+
3. PM ランタイムが各ステップを順次 dispatch → ステータスポーリング → レビューゲート処理
|
|
20
|
+
4. レビューで差し戻しが発生した場合、LLM で差し戻し先を判断しリセット
|
|
22
21
|
5. 全ステップ完了 → execution 全体を completed に
|
|
23
22
|
```
|
|
24
23
|
|
|
25
24
|
ポイント:
|
|
26
|
-
-
|
|
27
|
-
-
|
|
28
|
-
-
|
|
25
|
+
- オーケストレーションはコード(workflow-orchestrator.js)で決定論的に実行
|
|
26
|
+
- 差し戻し判断のみ LLM を使用(Anthropic API 直接呼び出し、フォールバック付き)
|
|
27
|
+
- `~/.minion/revision-policy.md` でPM固有の差し戻しポリシーをカスタマイズ可能
|
|
29
28
|
|
|
30
29
|
## Workflow 管理
|
|
31
30
|
|
|
@@ -45,11 +44,11 @@
|
|
|
45
44
|
|
|
46
45
|
## Routine (従来方式)
|
|
47
46
|
|
|
48
|
-
ワークフローは `project-workflow-check`
|
|
47
|
+
ワークフローは `project-workflow-check` スキルを通じてルーティンからも実行可能:
|
|
49
48
|
|
|
50
49
|
```
|
|
51
50
|
ルーティン: "morning-work" (cron: 0 9 * * 1-5)
|
|
52
|
-
pipeline: [project-workflow-check
|
|
51
|
+
pipeline: [project-workflow-check]
|
|
53
52
|
↓
|
|
54
53
|
project-workflow-check:
|
|
55
54
|
1. GET /api/minion/me/projects → 参加プロジェクト一覧
|