@geekbeer/minion 3.14.0 → 3.16.1

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,81 @@
1
+ /**
2
+ * DAG Node Executor
3
+ *
4
+ * Handles the execution lifecycle of a single DAG node.
5
+ * Responsible for:
6
+ * - Extracting structured output_data from skill execution results
7
+ * - Reporting completion to HQ via /api/dag/minion/node-complete
8
+ *
9
+ * Called by the post-execution hook in skills.js when a DAG node skill completes.
10
+ */
11
+
12
+ const { reportNodeComplete } = require('./dag-step-poller')
13
+
14
+ /**
15
+ * Parse structured output_data from a skill's execution output.
16
+ *
17
+ * Convention: Skills produce structured output via a "## Output Data" section
18
+ * in their execution report, containing a JSON code block.
19
+ *
20
+ * Example:
21
+ * ```
22
+ * ## Output Data
23
+ * ```json
24
+ * { "results": [...], "count": 5 }
25
+ * ```
26
+ * ```
27
+ *
28
+ * If no structured output is found, wraps the raw summary as { _raw: "..." }
29
+ */
30
+ function extractOutputData(summary, details) {
31
+ const fullText = [summary, details].filter(Boolean).join('\n\n')
32
+
33
+ // Try to find ## Output Data section with JSON
34
+ const outputDataMatch = fullText.match(
35
+ /##\s*Output\s*Data\s*\n+```(?:json)?\s*\n([\s\S]*?)\n```/i
36
+ )
37
+
38
+ if (outputDataMatch) {
39
+ try {
40
+ return JSON.parse(outputDataMatch[1].trim())
41
+ } catch {
42
+ console.warn('[DagNodeExecutor] Failed to parse Output Data JSON, using raw')
43
+ }
44
+ }
45
+
46
+ // Fallback: try to parse the entire summary as JSON
47
+ if (summary) {
48
+ try {
49
+ const parsed = JSON.parse(summary)
50
+ if (typeof parsed === 'object' && parsed !== null) {
51
+ return parsed
52
+ }
53
+ } catch {
54
+ // Not JSON, use raw
55
+ }
56
+ }
57
+
58
+ return { _raw: fullText || '' }
59
+ }
60
+
61
+ /**
62
+ * Report DAG node completion with extracted output_data.
63
+ *
64
+ * @param {string} nodeExecutionId - The dag_node_execution ID
65
+ * @param {string} status - 'completed' or 'failed'
66
+ * @param {string} summary - Execution summary text
67
+ * @param {string} details - Execution details text
68
+ */
69
+ async function reportDagNodeComplete(nodeExecutionId, status, summary, details) {
70
+ const outputSummary = [summary, details].filter(Boolean).join('\n\n')
71
+ const outputData = status === 'completed'
72
+ ? extractOutputData(summary, details)
73
+ : {}
74
+
75
+ return reportNodeComplete(nodeExecutionId, status, outputData, outputSummary)
76
+ }
77
+
78
+ module.exports = {
79
+ extractOutputData,
80
+ reportDagNodeComplete,
81
+ }
@@ -0,0 +1,282 @@
1
+ /**
2
+ * DAG Step Poller
3
+ *
4
+ * Polling daemon for DAG workflow execution.
5
+ * Similar to step-poller.js but with key differences:
6
+ * - Polls /api/dag/minion/pending-nodes instead of /api/minion/pending-steps
7
+ * - Supports concurrent node execution (up to MAX_CONCURRENT)
8
+ * - Reports completion to /api/dag/minion/node-complete
9
+ * - Injects input_data into skill context
10
+ *
11
+ * Feature flag: only starts when DAG_ENGINE_ENABLED=true
12
+ */
13
+
14
+ const { config, isHqConfigured } = require('../config')
15
+ const api = require('../api')
16
+ const variableStore = require('../stores/variable-store')
17
+
18
+ // Polling interval: 30 seconds (matches step-poller)
19
+ const POLL_INTERVAL_MS = 30_000
20
+
21
+ // Maximum concurrent DAG nodes this minion can execute
22
+ const MAX_CONCURRENT = 2
23
+
24
+ // Prevent concurrent poll cycles
25
+ let polling = false
26
+ let pollTimer = null
27
+ let lastPollAt = null
28
+
29
+ // Track active node executions: nodeExecId -> Promise
30
+ const activeNodes = new Map()
31
+
32
+ /**
33
+ * Send request to HQ's DAG API endpoints.
34
+ */
35
+ async function dagRequest(endpoint, options = {}) {
36
+ if (!isHqConfigured()) {
37
+ return { skipped: true, reason: 'HQ not configured' }
38
+ }
39
+
40
+ const url = `${config.HQ_URL}/api/dag/minion${endpoint}`
41
+ const resp = await fetch(url, {
42
+ ...options,
43
+ headers: {
44
+ 'Content-Type': 'application/json',
45
+ 'Authorization': `Bearer ${config.API_TOKEN}`,
46
+ ...(options.headers || {}),
47
+ },
48
+ })
49
+
50
+ if (!resp.ok) {
51
+ const err = new Error(`DAG API ${endpoint} failed: ${resp.status}`)
52
+ err.statusCode = resp.status
53
+ throw err
54
+ }
55
+
56
+ return resp.json()
57
+ }
58
+
59
+ /**
60
+ * Poll HQ for pending DAG nodes and execute them.
61
+ */
62
+ async function pollOnce() {
63
+ if (!isHqConfigured()) return
64
+ if (polling) return
65
+
66
+ polling = true
67
+ try {
68
+ const data = await dagRequest('/pending-nodes')
69
+
70
+ if (!data.nodes || data.nodes.length === 0) return
71
+
72
+ console.log(`[DagPoller] Found ${data.nodes.length} pending node(s), active: ${activeNodes.size}/${MAX_CONCURRENT}`)
73
+
74
+ for (const node of data.nodes) {
75
+ if (activeNodes.size >= MAX_CONCURRENT) break
76
+ if (activeNodes.has(node.node_execution_id)) continue
77
+
78
+ const promise = executeNode(node)
79
+ .catch(err => {
80
+ console.error(`[DagPoller] Node ${node.node_id} execution error: ${err.message}`)
81
+ })
82
+ .finally(() => {
83
+ activeNodes.delete(node.node_execution_id)
84
+ })
85
+
86
+ activeNodes.set(node.node_execution_id, promise)
87
+ }
88
+ } catch (err) {
89
+ if (err.message?.includes('fetch failed') || err.message?.includes('ECONNREFUSED')) {
90
+ console.log('[DagPoller] HQ unreachable, will retry next cycle')
91
+ } else {
92
+ console.error(`[DagPoller] Poll error: ${err.message}`)
93
+ }
94
+ } finally {
95
+ polling = false
96
+ lastPollAt = new Date().toISOString()
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Execute a single pending DAG node:
102
+ * 1. Claim the node
103
+ * 2. Fetch the skill from HQ
104
+ * 3. Run the skill locally (with input_data injected)
105
+ * 4. Report completion with output_data
106
+ */
107
+ async function executeNode(node) {
108
+ const {
109
+ node_execution_id,
110
+ execution_id,
111
+ dag_workflow_name,
112
+ node_id,
113
+ scope_path,
114
+ skill_version_id,
115
+ skill_name: resolvedSkillName,
116
+ assigned_role,
117
+ input_data,
118
+ revision_feedback,
119
+ } = node
120
+
121
+ console.log(
122
+ `[DagPoller] Executing node "${node_id}" of DAG "${dag_workflow_name}" ` +
123
+ `(skill: ${resolvedSkillName || skill_version_id}, scope: "${scope_path || 'root'}", role: ${assigned_role})`
124
+ )
125
+
126
+ try {
127
+ // 1. Claim the node
128
+ try {
129
+ await dagRequest('/claim-node', {
130
+ method: 'POST',
131
+ body: JSON.stringify({ node_execution_id }),
132
+ })
133
+ } catch (claimErr) {
134
+ if (claimErr.statusCode === 409) {
135
+ console.log(`[DagPoller] Node ${node_id} already claimed, skipping`)
136
+ return
137
+ }
138
+ throw claimErr
139
+ }
140
+
141
+ // 2. Resolve skill name (prefer pre-resolved from pending-nodes, fall back to API)
142
+ const skillName = resolvedSkillName || await resolveSkillName(skill_version_id)
143
+ if (!skillName) {
144
+ console.error(`[DagPoller] Could not resolve skill name for version ${skill_version_id}`)
145
+ await reportNodeComplete(node_execution_id, 'failed', null, 'Could not resolve skill name')
146
+ return
147
+ }
148
+
149
+ // 3. Fetch the skill
150
+ if (skillName) {
151
+ try {
152
+ const minionVars = variableStore.getAll('variables')
153
+ const mergedVars = { ...minionVars }
154
+ const varsParam = Object.keys(mergedVars).length > 0
155
+ ? `?vars=${Buffer.from(JSON.stringify(mergedVars)).toString('base64')}`
156
+ : ''
157
+ const fetchUrl = `http://localhost:${config.AGENT_PORT || 8080}/api/skills/fetch/${encodeURIComponent(skillName)}${varsParam}`
158
+ await fetch(fetchUrl, {
159
+ method: 'POST',
160
+ headers: { 'Authorization': `Bearer ${config.API_TOKEN}` },
161
+ })
162
+ } catch (fetchErr) {
163
+ console.error(`[DagPoller] Skill fetch error: ${fetchErr.message}`)
164
+ }
165
+ }
166
+
167
+ // 4. Run the skill with input_data context
168
+ const runPayload = {
169
+ skill_name: skillName,
170
+ execution_id,
171
+ workflow_name: dag_workflow_name,
172
+ role: assigned_role,
173
+ // DAG-specific: inject input_data as context
174
+ dag_node_id: node_id,
175
+ dag_input_data: input_data,
176
+ dag_node_execution_id: node_execution_id,
177
+ }
178
+ if (revision_feedback) {
179
+ runPayload.revision_feedback = revision_feedback
180
+ }
181
+
182
+ const runUrl = `http://localhost:${config.AGENT_PORT || 8080}/api/skills/run`
183
+ const runResp = await fetch(runUrl, {
184
+ method: 'POST',
185
+ headers: {
186
+ 'Content-Type': 'application/json',
187
+ 'Authorization': `Bearer ${config.API_TOKEN}`,
188
+ },
189
+ body: JSON.stringify(runPayload),
190
+ })
191
+
192
+ if (!runResp.ok) {
193
+ const errData = await runResp.json().catch(() => ({}))
194
+ console.error(`[DagPoller] Skill run failed: ${errData.error || runResp.status}`)
195
+ await reportNodeComplete(
196
+ node_execution_id,
197
+ 'failed',
198
+ null,
199
+ `Failed to start skill: ${errData.error || 'unknown error'}`
200
+ )
201
+ return
202
+ }
203
+
204
+ const runData = await runResp.json()
205
+ console.log(
206
+ `[DagPoller] Skill "${skillName}" started for node "${node_id}" ` +
207
+ `(session: ${runData.session_name}). Completion reported by post-execution hook.`
208
+ )
209
+
210
+ // Note: The post-execution hook in skills.js needs to be extended to handle DAG nodes.
211
+ // For now, we wait for the skill to complete and then report via the session monitor.
212
+ // This will be handled by dag-node-executor.js which watches the session.
213
+
214
+ } catch (err) {
215
+ console.error(`[DagPoller] Failed to execute node ${node_id}: ${err.message}`)
216
+ try {
217
+ await reportNodeComplete(node_execution_id, 'failed', null, err.message)
218
+ } catch {
219
+ // best-effort
220
+ }
221
+ }
222
+ }
223
+
224
+ /**
225
+ * Resolve skill_version_id to skill name via HQ API.
226
+ */
227
+ async function resolveSkillName(skillVersionId) {
228
+ if (!skillVersionId) return null
229
+ try {
230
+ // Use existing skill fetch pattern — the skill name is returned by pending-nodes
231
+ // But if not available, we need to resolve it
232
+ const data = await api.request(`/skill-version/${skillVersionId}`)
233
+ return data?.skill_name || null
234
+ } catch {
235
+ return null
236
+ }
237
+ }
238
+
239
+ /**
240
+ * Report node completion to HQ.
241
+ */
242
+ async function reportNodeComplete(nodeExecutionId, status, outputData, outputSummary) {
243
+ return dagRequest('/node-complete', {
244
+ method: 'POST',
245
+ body: JSON.stringify({
246
+ node_execution_id: nodeExecutionId,
247
+ status,
248
+ output_data: outputData || {},
249
+ output_summary: outputSummary || null,
250
+ }),
251
+ })
252
+ }
253
+
254
+ function start() {
255
+ if (!isHqConfigured()) {
256
+ console.log('[DagPoller] HQ not configured, DAG poller disabled')
257
+ return
258
+ }
259
+
260
+ setTimeout(() => pollOnce(), 7000) // Slightly delayed after step-poller
261
+ pollTimer = setInterval(() => pollOnce(), POLL_INTERVAL_MS)
262
+ console.log(`[DagPoller] Started (polling every ${POLL_INTERVAL_MS / 1000}s, max concurrent: ${MAX_CONCURRENT})`)
263
+ }
264
+
265
+ function stop() {
266
+ if (pollTimer) {
267
+ clearInterval(pollTimer)
268
+ pollTimer = null
269
+ console.log('[DagPoller] Stopped')
270
+ }
271
+ }
272
+
273
+ function getStatus() {
274
+ return {
275
+ running: pollTimer !== null,
276
+ last_poll_at: lastPollAt,
277
+ active_nodes: activeNodes.size,
278
+ max_concurrent: MAX_CONCURRENT,
279
+ }
280
+ }
281
+
282
+ module.exports = { start, stop, pollOnce, getStatus, reportNodeComplete }
@@ -568,7 +568,7 @@ Note: 既読メールは受信後90日で自動削除される。未読メール
568
568
  | GET | `/api/commands` | List available whitelisted commands |
569
569
  | POST | `/api/command` | Execute command. Body: `{command}` |
570
570
 
571
- Available commands: `restart-agent`, `update-agent`, `restart-display`, `status-services`
571
+ Available commands: `restart-agent`, `update-agent`, `restart-display`, `restart-all`, `status-services`
572
572
 
573
573
  ---
574
574
 
@@ -63,6 +63,11 @@ function buildAllowedCommands(procMgr) {
63
63
  description: 'Restart Xvfb, Fluxbox, x11vnc and noVNC services',
64
64
  command: `${SUDO}systemctl restart xvfb fluxbox x11vnc novnc`,
65
65
  }
66
+ commands['restart-all'] = {
67
+ description: 'Restart all services (display stack + agent)',
68
+ command: `${SUDO}systemctl restart xvfb fluxbox x11vnc novnc && ${SUDO}systemctl restart minion-agent`,
69
+ deferred: true,
70
+ }
66
71
  commands['status-services'] = {
67
72
  description: 'Check status of all services',
68
73
  command: 'systemctl status minion-agent xvfb fluxbox x11vnc novnc --no-pager',
@@ -87,6 +92,11 @@ function buildAllowedCommands(procMgr) {
87
92
  description: 'Restart Xvfb, x11vnc and noVNC services',
88
93
  command: `${SUDO}supervisorctl restart xvfb fluxbox x11vnc novnc`,
89
94
  }
95
+ commands['restart-all'] = {
96
+ description: 'Restart all services (display stack + agent)',
97
+ command: `${SUDO}supervisorctl restart xvfb fluxbox x11vnc novnc && ${SUDO}supervisorctl restart minion-agent`,
98
+ deferred: true,
99
+ }
90
100
  commands['status-services'] = {
91
101
  description: 'Check status of all services',
92
102
  command: `${SUDO}supervisorctl status`,
package/linux/server.js CHANGED
@@ -58,6 +58,7 @@ const { getConfigWarnings } = require('../core/lib/config-warnings')
58
58
 
59
59
  // Pull-model daemons (from core/)
60
60
  const stepPoller = require('../core/lib/step-poller')
61
+ const dagStepPoller = require('../core/lib/dag-step-poller')
61
62
  const revisionWatcher = require('../core/lib/revision-watcher')
62
63
  const reflectionScheduler = require('../core/lib/reflection-scheduler')
63
64
  const threadWatcher = require('../core/lib/thread-watcher')
@@ -122,6 +123,7 @@ async function shutdown(signal) {
122
123
 
123
124
  // Stop pollers, runners, and scheduler
124
125
  stepPoller.stop()
126
+ dagStepPoller.stop()
125
127
  revisionWatcher.stop()
126
128
  reflectionScheduler.stop()
127
129
  threadWatcher.stop()
@@ -391,6 +393,7 @@ async function start() {
391
393
 
392
394
  // Start Pull-model daemons
393
395
  stepPoller.start()
396
+ dagStepPoller.start()
394
397
  revisionWatcher.start()
395
398
  threadWatcher.start(runQuickLlmCall)
396
399
  } else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geekbeer/minion",
3
- "version": "3.14.0",
3
+ "version": "3.16.1",
4
4
  "description": "AI Agent runtime for Minion - manages status and skill deployment on VPS",
5
5
  "main": "linux/server.js",
6
6
  "bin": {
@@ -220,6 +220,62 @@ function buildRestartScript(_nssmPath, agentPort, apiToken) {
220
220
  return scriptPath
221
221
  }
222
222
 
223
+ /**
224
+ * Generate a temporary PowerShell script for restarting ALL services:
225
+ * 1. Restart display services (VNC + websockify)
226
+ * 2. Graceful shutdown of agent via HTTP API
227
+ * 3. Restart agent service via NSSM
228
+ * 4. Remove the temporary updater service (self-cleanup)
229
+ *
230
+ * @param {string} _nssmPath - Absolute path to nssm.exe
231
+ * @param {number} agentPort - The agent's HTTP port
232
+ * @param {string} apiToken - The agent's API token
233
+ * @returns {string} - Path to the generated restart script (.ps1)
234
+ */
235
+ function buildRestartAllScript(_nssmPath, agentPort, apiToken) {
236
+ const dataDir = path.join(os.homedir(), '.minion')
237
+ const homeDir = os.homedir()
238
+ const scriptPath = path.join(dataDir, 'restart-all.ps1')
239
+ const logDir = path.join(dataDir, 'logs')
240
+ const logPath = path.join(logDir, 'restart-all.log')
241
+
242
+ const gracefulStop = buildGracefulStopBlock(agentPort, apiToken)
243
+
244
+ const ps1 = [
245
+ `$env:USERPROFILE = '${homeDir}'`,
246
+ `$env:HOME = '${homeDir}'`,
247
+ `$ErrorActionPreference = 'Stop'`,
248
+ `$nssm = '${path.join(dataDir, 'nssm.exe')}'`,
249
+ `$logFile = '${logPath}'`,
250
+ `function Log($msg) { "$(Get-Date -Format o) $msg" | Out-File -Append $logFile }`,
251
+ `Log 'Restart-all started'`,
252
+ `try {`,
253
+ ` Log 'Restarting display services...'`,
254
+ ` try { & $nssm restart minion-vnc 2>&1 | ForEach-Object { Log "minion-vnc: $_" } } catch { Log "minion-vnc restart failed: $_" }`,
255
+ ` try { & $nssm restart minion-websockify 2>&1 | ForEach-Object { Log "minion-websockify: $_" } } catch { Log "minion-websockify restart failed: $_" }`,
256
+ ` Log 'Display services restarted'`,
257
+ ` Log 'Requesting graceful shutdown of agent...'`,
258
+ gracefulStop,
259
+ ` Log 'Restarting agent service via NSSM...'`,
260
+ ` & $nssm restart minion-agent`,
261
+ ` Log 'Restart-all completed successfully'`,
262
+ `} catch {`,
263
+ ` Log "Restart-all failed: $_"`,
264
+ ` Log 'Attempting to start agent service anyway...'`,
265
+ ` & $nssm start minion-agent`,
266
+ `} finally {`,
267
+ ` Log 'Cleaning up updater service...'`,
268
+ ` & $nssm stop minion-update confirm 2>$null`,
269
+ ` & $nssm remove minion-update confirm 2>$null`,
270
+ `}`,
271
+ ].join('\n')
272
+
273
+ try { fs.mkdirSync(dataDir, { recursive: true }) } catch { /* exists */ }
274
+ fs.writeFileSync(scriptPath, ps1, 'utf-8')
275
+
276
+ return scriptPath
277
+ }
278
+
223
279
  /**
224
280
  * Build allowed commands for NSSM-based service management.
225
281
  *
@@ -271,6 +327,12 @@ function buildAllowedCommands(_procMgr, agentConfig = {}) {
271
327
  command: `"${nssmPath}" restart minion-vnc & "${nssmPath}" restart minion-websockify`,
272
328
  }
273
329
 
330
+ commands['restart-all'] = {
331
+ description: 'Restart all services (display + agent)',
332
+ nssmService: { scriptPath: buildRestartAllScript(nssmPath, agentPort, apiToken) },
333
+ deferred: true,
334
+ }
335
+
274
336
  commands['restart-tunnel'] = {
275
337
  description: 'Restart Cloudflare tunnel service',
276
338
  command: `"${nssmPath}" restart minion-cloudflared`,
package/win/server.js CHANGED
@@ -34,6 +34,7 @@ const { getConfigWarnings } = require('../core/lib/config-warnings')
34
34
 
35
35
  // Pull-model daemons (from core/)
36
36
  const stepPoller = require('../core/lib/step-poller')
37
+ const dagStepPoller = require('../core/lib/dag-step-poller')
37
38
  const revisionWatcher = require('../core/lib/revision-watcher')
38
39
  const reflectionScheduler = require('../core/lib/reflection-scheduler')
39
40
  const threadWatcher = require('../core/lib/thread-watcher')
@@ -94,6 +95,7 @@ async function shutdown(signal) {
94
95
  }
95
96
 
96
97
  stepPoller.stop()
98
+ dagStepPoller.stop()
97
99
  revisionWatcher.stop()
98
100
  reflectionScheduler.stop()
99
101
  threadWatcher.stop()
@@ -358,6 +360,7 @@ async function start() {
358
360
 
359
361
  // Start Pull-model daemons
360
362
  stepPoller.start()
363
+ dagStepPoller.start()
361
364
  revisionWatcher.start()
362
365
  threadWatcher.start(runQuickLlmCall)
363
366
  } else {