@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.
@@ -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
- * Note: Chat-specific flags (--output-format stream-json, --resume, etc.)
214
- * are Claude Code CLI features. Other LLM CLIs may not support them.
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
- // tool_use events forward to frontend for progress display
296
- if (parsed.type === 'content_block_start' && parsed.content_block?.type === 'tool_use') {
297
- const event = JSON.stringify({ type: 'tool_start', tool: parsed.content_block.name || 'unknown' })
298
- res.write(`data: ${event}\n\n`)
340
+ // content_block_starttrack 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
- const event = JSON.stringify({ type: 'tool_end' })
302
- res.write(`data: ${event}\n\n`)
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 content blocks
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 — send as 'result' type for frontend to use as final text
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 = resultText
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 }
@@ -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
- * Prefers /opt/minion-agent/.env (systemd/supervisord installs),
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
- const optPath = '/opt/minion-agent/.env'
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: true }
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)' : ''
@@ -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 execAsync(`tmux has-session -t "${sessionName}" 2>/dev/null`)
199
- await sleep(pollInterval)
200
+ await fs.access(exitCodeFile)
201
+ break // Exit code file exists — CLI has finished
200
202
  } catch {
201
- // Session ended
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 workflow and routine runners
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
  }
@@ -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
- * - /execution-report is automatically appended to report outcome
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 = options.skipExecutionReport
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(' → ')} → execution-report`)
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: workflow completed (outcome may be updated later by /execution-report)
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: result.success ? null : 'failure', // null = pending outcome from /execution-report
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geekbeer/minion",
3
- "version": "2.33.4",
3
+ "version": "2.42.5",
4
4
  "description": "AI Agent runtime for Minion - manages status and skill deployment on VPS",
5
5
  "main": "linux/server.js",
6
6
  "bin": {
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
- ワークフロー実行はHQのDBがステートマシンとして管理する。ミニオン間の直接通信は不要。
13
+ ワークフロー実行はPMミニオンのランタイム(Node.js)がオーケストレーションする。
14
+ HQのDBがステートマシンとして状態を管理し、ミニオン間の直接通信は不要。
15
15
 
16
16
  ```
17
- 1. Execution 作成 → workflow_execution + 全 step_executions (status='pending')
18
- 2. ミニオンエージェントが GET /api/minion/pending-steps をポーリング
19
- 自分のロールに該当 & 前ステップ完了済みのステップを取得
20
- 3. ミニオンがステップを実行し完了を報告
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
- - HQの責務は「executionレコードを作る(指示書発行)」だけ
27
- - 各ミニオンは自分の担当ステップだけをポーリングして実行
28
- - ミニオンエージェント(Port 8080)が軽量HTTPポーリングを行い、pending検知時のみClaude Codeを起動
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, execution-report]
51
+ pipeline: [project-workflow-check]
53
52
 
54
53
  project-workflow-check:
55
54
  1. GET /api/minion/me/projects → 参加プロジェクト一覧