@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/.env.example
CHANGED
package/README.md
CHANGED
package/core/api.js
CHANGED
|
@@ -74,9 +74,22 @@ async function reportIssue(data) {
|
|
|
74
74
|
})
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
/**
|
|
78
|
+
* Send heartbeat to HQ to report current status.
|
|
79
|
+
* Called periodically, on startup, on shutdown, and on status change.
|
|
80
|
+
* @param {object} data - { status: 'online' | 'offline' | 'busy', current_task?: string | null, version?: string }
|
|
81
|
+
*/
|
|
82
|
+
async function sendHeartbeat(data) {
|
|
83
|
+
return request('/heartbeat', {
|
|
84
|
+
method: 'POST',
|
|
85
|
+
body: JSON.stringify(data),
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
|
|
77
89
|
module.exports = {
|
|
78
90
|
request,
|
|
79
91
|
reportExecution,
|
|
80
92
|
reportStepComplete,
|
|
81
93
|
reportIssue,
|
|
94
|
+
sendHeartbeat,
|
|
82
95
|
}
|
package/core/config.js
CHANGED
|
@@ -15,8 +15,40 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
const os = require('os')
|
|
18
|
+
const fs = require('fs')
|
|
18
19
|
const { execSync } = require('child_process')
|
|
19
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Load .env file into process.env (without overwriting existing values).
|
|
23
|
+
* This ensures values written to the .env file at runtime (e.g. LLM_COMMAND
|
|
24
|
+
* set via the config API) are picked up on process restart, even when the
|
|
25
|
+
* process manager (supervisord) does not include them in its environment line.
|
|
26
|
+
*/
|
|
27
|
+
function loadEnvFile() {
|
|
28
|
+
const { resolveEnvFilePath } = require('./lib/platform')
|
|
29
|
+
// resolveHomeDir is not available yet, use a lightweight fallback
|
|
30
|
+
const home = process.env.HOME || os.homedir()
|
|
31
|
+
const envPath = resolveEnvFilePath(home)
|
|
32
|
+
try {
|
|
33
|
+
const content = fs.readFileSync(envPath, 'utf-8')
|
|
34
|
+
for (const line of content.split('\n')) {
|
|
35
|
+
const trimmed = line.trim()
|
|
36
|
+
if (!trimmed || trimmed.startsWith('#') || !trimmed.includes('=')) continue
|
|
37
|
+
const eqIdx = trimmed.indexOf('=')
|
|
38
|
+
const key = trimmed.slice(0, eqIdx).trim()
|
|
39
|
+
const value = trimmed.slice(eqIdx + 1).trim()
|
|
40
|
+
// Do not overwrite values already set by the process manager
|
|
41
|
+
if (!(key in process.env)) {
|
|
42
|
+
process.env[key] = value
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
} catch {
|
|
46
|
+
// .env file doesn't exist or can't be read — not an error
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
loadEnvFile()
|
|
51
|
+
|
|
20
52
|
/**
|
|
21
53
|
* Resolve the correct home directory for the minion user.
|
|
22
54
|
* On Linux, supervisord environments may set HOME=/root incorrectly.
|
|
@@ -82,4 +114,17 @@ function isLlmConfigured() {
|
|
|
82
114
|
return !!config.LLM_COMMAND
|
|
83
115
|
}
|
|
84
116
|
|
|
85
|
-
|
|
117
|
+
/**
|
|
118
|
+
* Update a config value at runtime (e.g. after .env file write).
|
|
119
|
+
* Also syncs to process.env so child processes inherit the change.
|
|
120
|
+
* @param {string} key
|
|
121
|
+
* @param {string} value
|
|
122
|
+
*/
|
|
123
|
+
function updateConfig(key, value) {
|
|
124
|
+
if (key in config) {
|
|
125
|
+
config[key] = value
|
|
126
|
+
process.env[key] = value
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
module.exports = { config, validate, isHqConfigured, isLlmConfigured, updateConfig }
|
package/core/lib/log-manager.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
const fs = require('fs').promises
|
|
8
8
|
const path = require('path')
|
|
9
9
|
const platform = require('./platform')
|
|
10
|
+
const { stripAnsi } = require('./strip-ansi')
|
|
10
11
|
|
|
11
12
|
// Log storage configuration (platform-aware via platform.js)
|
|
12
13
|
const LOG_DIR = platform.LOG_DIR
|
|
@@ -59,7 +60,9 @@ async function readLog(executionId, options = {}) {
|
|
|
59
60
|
|
|
60
61
|
try {
|
|
61
62
|
const stats = await fs.stat(logPath)
|
|
62
|
-
const
|
|
63
|
+
const rawContent = await fs.readFile(logPath, 'utf-8')
|
|
64
|
+
// Strip ANSI/TTY escape sequences from tmux pipe-pane output
|
|
65
|
+
const content = stripAnsi(rawContent)
|
|
63
66
|
const allLines = content.split('\n')
|
|
64
67
|
|
|
65
68
|
let resultContent = content
|
package/core/lib/platform.js
CHANGED
|
@@ -16,22 +16,15 @@ const TEMP_DIR = os.tmpdir()
|
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
18
|
* Resolve the data directory for minion agent persistent files.
|
|
19
|
-
* Windows: %
|
|
19
|
+
* Windows: %USERPROFILE%\.minion (matches minion-cli.ps1 / start-agent.ps1)
|
|
20
20
|
* Linux: /opt/minion-agent (existing behavior)
|
|
21
21
|
*/
|
|
22
22
|
function resolveDataDir() {
|
|
23
23
|
if (IS_WINDOWS) {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
fs.mkdirSync(dir, { recursive: true })
|
|
29
|
-
return dir
|
|
30
|
-
} catch {
|
|
31
|
-
// Fall through to home-based path
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
return path.join(os.homedir(), '.minion-agent')
|
|
24
|
+
// Use ~/.minion to match minion-cli.ps1 and start-agent.ps1.
|
|
25
|
+
// All Windows-specific code (CLI, process-manager, server.js) uses ~/.minion
|
|
26
|
+
// as the canonical data directory, so core modules must align with it.
|
|
27
|
+
return path.join(os.homedir(), '.minion')
|
|
35
28
|
}
|
|
36
29
|
return '/opt/minion-agent'
|
|
37
30
|
}
|
|
@@ -89,7 +82,9 @@ function getDefaultShell() {
|
|
|
89
82
|
|
|
90
83
|
/**
|
|
91
84
|
* Resolve .env file path.
|
|
92
|
-
*
|
|
85
|
+
* Returns DATA_DIR/.env (which is ~/.minion/.env on Windows,
|
|
86
|
+
* /opt/minion-agent/.env on Linux).
|
|
87
|
+
* Falls back to ~/minion.env if DATA_DIR is not writable.
|
|
93
88
|
* @param {string} homeDir - User home directory
|
|
94
89
|
* @returns {string} Path to .env file
|
|
95
90
|
*/
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Revision Watcher
|
|
3
|
+
*
|
|
4
|
+
* PM-only polling daemon that detects steps with revision_requested
|
|
5
|
+
* review status and handles revision routing.
|
|
6
|
+
*
|
|
7
|
+
* When a reviewer requests changes on a completed step:
|
|
8
|
+
* 1. This watcher detects the revision_requested status
|
|
9
|
+
* 2. Uses LLM to decide which step to roll back to
|
|
10
|
+
* 3. Calls HQ's /api/minion/revision-reset to reset affected steps
|
|
11
|
+
* 4. The step-poller on the target minion picks up the re-pending step
|
|
12
|
+
*
|
|
13
|
+
* Only runs on minions that have PM role in at least one project.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const { config, isHqConfigured } = require('../config')
|
|
17
|
+
const api = require('../api')
|
|
18
|
+
|
|
19
|
+
// Poll every 30 seconds (same frequency as step-poller)
|
|
20
|
+
const POLL_INTERVAL_MS = 30_000
|
|
21
|
+
|
|
22
|
+
let polling = false
|
|
23
|
+
let pollTimer = null
|
|
24
|
+
|
|
25
|
+
// Track revisions being processed to avoid duplicate handling
|
|
26
|
+
const processingRevisions = new Set()
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Poll HQ for pending revisions and handle them.
|
|
30
|
+
*/
|
|
31
|
+
async function pollOnce() {
|
|
32
|
+
if (!isHqConfigured()) return
|
|
33
|
+
if (polling) return
|
|
34
|
+
|
|
35
|
+
polling = true
|
|
36
|
+
try {
|
|
37
|
+
const data = await api.request('/pending-revisions')
|
|
38
|
+
|
|
39
|
+
if (!data.revisions || data.revisions.length === 0) {
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
console.log(`[RevisionWatcher] Found ${data.revisions.length} pending revision(s)`)
|
|
44
|
+
|
|
45
|
+
for (const revision of data.revisions) {
|
|
46
|
+
const key = `${revision.execution_id}-${revision.revision_step_index}`
|
|
47
|
+
if (processingRevisions.has(key)) {
|
|
48
|
+
continue
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
processingRevisions.add(key)
|
|
52
|
+
try {
|
|
53
|
+
await handleRevision(revision)
|
|
54
|
+
} finally {
|
|
55
|
+
processingRevisions.delete(key)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
} catch (err) {
|
|
59
|
+
if (err.message?.includes('fetch failed') || err.message?.includes('ECONNREFUSED')) {
|
|
60
|
+
console.log(`[RevisionWatcher] HQ unreachable, will retry next cycle`)
|
|
61
|
+
} else {
|
|
62
|
+
console.error(`[RevisionWatcher] Poll error: ${err.message}`)
|
|
63
|
+
}
|
|
64
|
+
} finally {
|
|
65
|
+
polling = false
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Handle a single revision request:
|
|
71
|
+
* 1. Decide which step to roll back to (LLM or simple heuristic)
|
|
72
|
+
* 2. Call revision-reset API
|
|
73
|
+
*
|
|
74
|
+
* @param {object} revision
|
|
75
|
+
* @param {string} revision.execution_id
|
|
76
|
+
* @param {string} revision.workflow_name
|
|
77
|
+
* @param {number} revision.revision_step_index
|
|
78
|
+
* @param {string} revision.review_comment
|
|
79
|
+
* @param {Array<{step_index: number, skill_name: string|null, assigned_role: string}>} revision.pipeline
|
|
80
|
+
*/
|
|
81
|
+
async function handleRevision(revision) {
|
|
82
|
+
const { execution_id, workflow_name, revision_step_index, review_comment, pipeline } = revision
|
|
83
|
+
|
|
84
|
+
console.log(
|
|
85
|
+
`[RevisionWatcher] Handling revision for "${workflow_name}" step ${revision_step_index}: "${review_comment}"`
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
// Decide target step for rollback
|
|
89
|
+
const targetStepIndex = await decideRevisionTarget(pipeline, review_comment, revision_step_index)
|
|
90
|
+
|
|
91
|
+
console.log(`[RevisionWatcher] Rolling back to step ${targetStepIndex}`)
|
|
92
|
+
|
|
93
|
+
// Call revision-reset API on HQ
|
|
94
|
+
try {
|
|
95
|
+
await api.request('/revision-reset', {
|
|
96
|
+
method: 'POST',
|
|
97
|
+
body: JSON.stringify({
|
|
98
|
+
execution_id,
|
|
99
|
+
target_step_index: targetStepIndex,
|
|
100
|
+
revision_step_index: revision_step_index,
|
|
101
|
+
revision_feedback: review_comment,
|
|
102
|
+
}),
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
console.log(
|
|
106
|
+
`[RevisionWatcher] Revision reset complete: steps ${targetStepIndex}-${revision_step_index} ` +
|
|
107
|
+
`of execution ${execution_id} reset to pending`
|
|
108
|
+
)
|
|
109
|
+
} catch (err) {
|
|
110
|
+
console.error(`[RevisionWatcher] Revision reset failed: ${err.message}`)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Decide which step to roll back to.
|
|
116
|
+
* Uses LLM when multiple steps are involved, otherwise defaults to the reviewed step.
|
|
117
|
+
*/
|
|
118
|
+
async function decideRevisionTarget(pipeline, reviewComment, currentStepIndex) {
|
|
119
|
+
// If only one step or reviewing the first step, no choice needed
|
|
120
|
+
if (currentStepIndex === 0 || pipeline.length <= 1) {
|
|
121
|
+
return currentStepIndex
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Build pipeline description for LLM
|
|
125
|
+
const pipelineDesc = pipeline
|
|
126
|
+
.map(s => `Step ${s.step_index}: ${s.skill_name || 'unknown'} (role: ${s.assigned_role})`)
|
|
127
|
+
.join('\n')
|
|
128
|
+
|
|
129
|
+
const systemPrompt = `You are analyzing a workflow pipeline to decide which step to roll back to after a reviewer requested changes.
|
|
130
|
+
|
|
131
|
+
Given the pipeline steps and the reviewer's feedback, determine which step is the root cause that needs to be re-executed.
|
|
132
|
+
- If the feedback targets the current step's output only, return the current step index.
|
|
133
|
+
- If the feedback suggests an earlier step produced incorrect input, return that earlier step's index.
|
|
134
|
+
- Always return the EARLIEST step that needs re-execution.
|
|
135
|
+
|
|
136
|
+
Respond with ONLY a JSON object: {"target_step_index": <number>}
|
|
137
|
+
Do not include any other text.`
|
|
138
|
+
|
|
139
|
+
const userPrompt = `## Pipeline (steps 0 through ${currentStepIndex})
|
|
140
|
+
${pipelineDesc}
|
|
141
|
+
|
|
142
|
+
## Reviewer Feedback
|
|
143
|
+
${reviewComment}
|
|
144
|
+
|
|
145
|
+
## Current Step (reviewed)
|
|
146
|
+
Step ${currentStepIndex}`
|
|
147
|
+
|
|
148
|
+
// Load optional PM revision policy
|
|
149
|
+
let revisionPolicy = ''
|
|
150
|
+
try {
|
|
151
|
+
const fs = require('fs').promises
|
|
152
|
+
const path = require('path')
|
|
153
|
+
const policyPath = path.join(config.HOME_DIR, '.minion', 'revision-policy.md')
|
|
154
|
+
revisionPolicy = await fs.readFile(policyPath, 'utf-8')
|
|
155
|
+
} catch {
|
|
156
|
+
// No custom policy
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (revisionPolicy) {
|
|
160
|
+
// Append policy to user prompt (same as orchestrator did)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
const result = await callLlmForJson(systemPrompt, userPrompt)
|
|
165
|
+
|
|
166
|
+
if (
|
|
167
|
+
result &&
|
|
168
|
+
typeof result.target_step_index === 'number' &&
|
|
169
|
+
Number.isInteger(result.target_step_index) &&
|
|
170
|
+
result.target_step_index >= 0 &&
|
|
171
|
+
result.target_step_index <= currentStepIndex
|
|
172
|
+
) {
|
|
173
|
+
console.log(`[RevisionWatcher] LLM decided revision target: step ${result.target_step_index}`)
|
|
174
|
+
return result.target_step_index
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
console.warn(`[RevisionWatcher] LLM returned invalid target, falling back to step ${currentStepIndex}`)
|
|
178
|
+
return currentStepIndex
|
|
179
|
+
} catch (err) {
|
|
180
|
+
console.error(`[RevisionWatcher] LLM call failed, falling back to step ${currentStepIndex}: ${err.message}`)
|
|
181
|
+
return currentStepIndex
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Call LLM API for JSON response.
|
|
187
|
+
* Reuses the same Anthropic Messages API pattern as workflow-orchestrator.
|
|
188
|
+
*/
|
|
189
|
+
async function callLlmForJson(systemPrompt, userPrompt) {
|
|
190
|
+
const apiKey = process.env.ANTHROPIC_API_KEY
|
|
191
|
+
if (!apiKey) {
|
|
192
|
+
throw new Error('ANTHROPIC_API_KEY not set — cannot make LLM call for revision routing')
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const resp = await fetch('https://api.anthropic.com/v1/messages', {
|
|
196
|
+
method: 'POST',
|
|
197
|
+
headers: {
|
|
198
|
+
'Content-Type': 'application/json',
|
|
199
|
+
'x-api-key': apiKey,
|
|
200
|
+
'anthropic-version': '2023-06-01',
|
|
201
|
+
},
|
|
202
|
+
body: JSON.stringify({
|
|
203
|
+
model: 'claude-haiku-4-5-20251001',
|
|
204
|
+
max_tokens: 256,
|
|
205
|
+
system: systemPrompt,
|
|
206
|
+
messages: [{ role: 'user', content: userPrompt }],
|
|
207
|
+
}),
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
if (!resp.ok) {
|
|
211
|
+
const text = await resp.text()
|
|
212
|
+
throw new Error(`Anthropic API error: ${resp.status} ${text}`)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const data = await resp.json()
|
|
216
|
+
const content = data.content?.[0]?.text
|
|
217
|
+
if (!content) {
|
|
218
|
+
throw new Error('Empty response from Anthropic API')
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return JSON.parse(content)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Start the revision watcher daemon.
|
|
226
|
+
*/
|
|
227
|
+
function start() {
|
|
228
|
+
if (!isHqConfigured()) {
|
|
229
|
+
console.log('[RevisionWatcher] HQ not configured, revision watcher disabled')
|
|
230
|
+
return
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Initial poll after a short delay
|
|
234
|
+
setTimeout(() => pollOnce(), 8000)
|
|
235
|
+
|
|
236
|
+
// Periodic polling
|
|
237
|
+
pollTimer = setInterval(() => pollOnce(), POLL_INTERVAL_MS)
|
|
238
|
+
console.log(`[RevisionWatcher] Started (polling every ${POLL_INTERVAL_MS / 1000}s)`)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Stop the revision watcher daemon.
|
|
243
|
+
*/
|
|
244
|
+
function stop() {
|
|
245
|
+
if (pollTimer) {
|
|
246
|
+
clearInterval(pollTimer)
|
|
247
|
+
pollTimer = null
|
|
248
|
+
console.log('[RevisionWatcher] Stopped')
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
module.exports = { start, stop, pollOnce }
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Step Poller
|
|
3
|
+
*
|
|
4
|
+
* Polling daemon that runs on every minion (including PM).
|
|
5
|
+
* Periodically checks HQ for pending workflow steps assigned to this
|
|
6
|
+
* minion's role, then fetches the skill and executes it.
|
|
7
|
+
*
|
|
8
|
+
* This enables the Pull model: minions autonomously pick up work
|
|
9
|
+
* when their turn comes, without needing a PM to push-dispatch.
|
|
10
|
+
* Handles minion absence gracefully — when a minion comes online,
|
|
11
|
+
* it simply picks up any pending steps waiting for its role.
|
|
12
|
+
*
|
|
13
|
+
* Flow per poll cycle:
|
|
14
|
+
* 1. GET /api/minion/pending-steps → list of actionable steps
|
|
15
|
+
* 2. For each step (one at a time):
|
|
16
|
+
* a. POST /api/skills/fetch/:name → deploy skill locally
|
|
17
|
+
* b. POST /api/skills/run → execute in tmux session
|
|
18
|
+
* c. (step-complete is reported by the /api/skills/run post-execution hook)
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const { config, isHqConfigured } = require('../config')
|
|
22
|
+
const api = require('../api')
|
|
23
|
+
|
|
24
|
+
// Polling interval: 30 seconds (matches heartbeat frequency)
|
|
25
|
+
const POLL_INTERVAL_MS = 30_000
|
|
26
|
+
|
|
27
|
+
// Prevent concurrent poll cycles from overlapping
|
|
28
|
+
let polling = false
|
|
29
|
+
|
|
30
|
+
// Timer reference for cleanup
|
|
31
|
+
let pollTimer = null
|
|
32
|
+
|
|
33
|
+
// Track currently executing step to avoid double-dispatch
|
|
34
|
+
let activeStepExecutionId = null
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Poll HQ for pending steps and execute them.
|
|
38
|
+
*/
|
|
39
|
+
async function pollOnce() {
|
|
40
|
+
if (!isHqConfigured()) return
|
|
41
|
+
if (polling) return
|
|
42
|
+
|
|
43
|
+
polling = true
|
|
44
|
+
try {
|
|
45
|
+
// 1. Fetch pending steps from HQ
|
|
46
|
+
const data = await api.request('/pending-steps')
|
|
47
|
+
|
|
48
|
+
if (!data.steps || data.steps.length === 0) {
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
console.log(`[StepPoller] Found ${data.steps.length} pending step(s)`)
|
|
53
|
+
|
|
54
|
+
// 2. Process steps one at a time (sequential execution)
|
|
55
|
+
for (const step of data.steps) {
|
|
56
|
+
// Skip if we're already executing this step
|
|
57
|
+
if (activeStepExecutionId === step.step_execution_id) {
|
|
58
|
+
console.log(`[StepPoller] Step ${step.step_execution_id} already in progress, skipping`)
|
|
59
|
+
continue
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
await executeStep(step)
|
|
63
|
+
|
|
64
|
+
// Only execute one step per poll cycle to avoid overloading
|
|
65
|
+
break
|
|
66
|
+
}
|
|
67
|
+
} catch (err) {
|
|
68
|
+
// Don't log network errors at error level — they're expected when HQ is temporarily unreachable
|
|
69
|
+
if (err.message?.includes('fetch failed') || err.message?.includes('ECONNREFUSED')) {
|
|
70
|
+
console.log(`[StepPoller] HQ unreachable, will retry next cycle`)
|
|
71
|
+
} else {
|
|
72
|
+
console.error(`[StepPoller] Poll error: ${err.message}`)
|
|
73
|
+
}
|
|
74
|
+
} finally {
|
|
75
|
+
polling = false
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Execute a single pending step:
|
|
81
|
+
* 1. Claim the step by calling dispatch-self endpoint
|
|
82
|
+
* 2. Fetch the skill from HQ
|
|
83
|
+
* 3. Run the skill locally
|
|
84
|
+
*
|
|
85
|
+
* @param {object} step - Step info from pending-steps response
|
|
86
|
+
*/
|
|
87
|
+
async function executeStep(step) {
|
|
88
|
+
const {
|
|
89
|
+
step_execution_id,
|
|
90
|
+
execution_id,
|
|
91
|
+
workflow_name,
|
|
92
|
+
step_index,
|
|
93
|
+
skill_version_id,
|
|
94
|
+
assigned_role,
|
|
95
|
+
skill_name,
|
|
96
|
+
revision_feedback,
|
|
97
|
+
} = step
|
|
98
|
+
|
|
99
|
+
console.log(
|
|
100
|
+
`[StepPoller] Executing step ${step_index} of "${workflow_name}" ` +
|
|
101
|
+
`(skill: ${skill_name || skill_version_id}, role: ${assigned_role})`
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
activeStepExecutionId = step_execution_id
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
// 1. Claim the step — tell HQ we're taking it
|
|
108
|
+
// This sets status to 'running' and prevents other minions from picking it up
|
|
109
|
+
try {
|
|
110
|
+
await api.request('/claim-step', {
|
|
111
|
+
method: 'POST',
|
|
112
|
+
body: JSON.stringify({
|
|
113
|
+
execution_id,
|
|
114
|
+
step_index,
|
|
115
|
+
}),
|
|
116
|
+
})
|
|
117
|
+
} catch (claimErr) {
|
|
118
|
+
// 409 means step is no longer pending (already claimed or completed)
|
|
119
|
+
if (claimErr.statusCode === 409) {
|
|
120
|
+
console.log(`[StepPoller] Step ${step_index} already claimed, skipping`)
|
|
121
|
+
return
|
|
122
|
+
}
|
|
123
|
+
throw claimErr
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// 2. Fetch the skill from HQ to ensure it's deployed locally
|
|
127
|
+
if (skill_name) {
|
|
128
|
+
try {
|
|
129
|
+
const fetchUrl = `http://localhost:${config.AGENT_PORT || 8080}/api/skills/fetch/${encodeURIComponent(skill_name)}`
|
|
130
|
+
const fetchResp = await fetch(fetchUrl, {
|
|
131
|
+
method: 'POST',
|
|
132
|
+
headers: { 'Authorization': `Bearer ${config.API_TOKEN}` },
|
|
133
|
+
})
|
|
134
|
+
if (!fetchResp.ok) {
|
|
135
|
+
console.error(`[StepPoller] Skill fetch failed: ${await fetchResp.text()}`)
|
|
136
|
+
}
|
|
137
|
+
} catch (fetchErr) {
|
|
138
|
+
console.error(`[StepPoller] Skill fetch error: ${fetchErr.message}`)
|
|
139
|
+
// Continue — skill may already be deployed locally
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// 3. Run the skill via local API
|
|
144
|
+
const runPayload = {
|
|
145
|
+
skill_name,
|
|
146
|
+
execution_id,
|
|
147
|
+
step_index,
|
|
148
|
+
workflow_name,
|
|
149
|
+
role: assigned_role,
|
|
150
|
+
}
|
|
151
|
+
if (revision_feedback) {
|
|
152
|
+
runPayload.revision_feedback = revision_feedback
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const runUrl = `http://localhost:${config.AGENT_PORT || 8080}/api/skills/run`
|
|
156
|
+
const runResp = await fetch(runUrl, {
|
|
157
|
+
method: 'POST',
|
|
158
|
+
headers: {
|
|
159
|
+
'Content-Type': 'application/json',
|
|
160
|
+
'Authorization': `Bearer ${config.API_TOKEN}`,
|
|
161
|
+
},
|
|
162
|
+
body: JSON.stringify(runPayload),
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
if (!runResp.ok) {
|
|
166
|
+
const errData = await runResp.json().catch(() => ({}))
|
|
167
|
+
console.error(`[StepPoller] Skill run failed: ${errData.error || runResp.status}`)
|
|
168
|
+
// Report failure to HQ
|
|
169
|
+
try {
|
|
170
|
+
await api.reportStepComplete({
|
|
171
|
+
workflow_execution_id: execution_id,
|
|
172
|
+
step_index,
|
|
173
|
+
status: 'failed',
|
|
174
|
+
output_summary: `Step poller failed to start skill: ${errData.error || 'unknown error'}`,
|
|
175
|
+
})
|
|
176
|
+
} catch (reportErr) {
|
|
177
|
+
console.error(`[StepPoller] Failed to report step failure: ${reportErr.message}`)
|
|
178
|
+
}
|
|
179
|
+
return
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const runData = await runResp.json()
|
|
183
|
+
console.log(
|
|
184
|
+
`[StepPoller] Skill "${skill_name}" started (session: ${runData.session_name}). ` +
|
|
185
|
+
`Step completion will be reported by the post-execution hook.`
|
|
186
|
+
)
|
|
187
|
+
} catch (err) {
|
|
188
|
+
console.error(`[StepPoller] Failed to execute step ${step_index}: ${err.message}`)
|
|
189
|
+
} finally {
|
|
190
|
+
activeStepExecutionId = null
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Start the polling daemon.
|
|
196
|
+
*/
|
|
197
|
+
function start() {
|
|
198
|
+
if (!isHqConfigured()) {
|
|
199
|
+
console.log('[StepPoller] HQ not configured, step poller disabled')
|
|
200
|
+
return
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Initial poll after a short delay (let server fully start)
|
|
204
|
+
setTimeout(() => pollOnce(), 5000)
|
|
205
|
+
|
|
206
|
+
// Periodic polling
|
|
207
|
+
pollTimer = setInterval(() => pollOnce(), POLL_INTERVAL_MS)
|
|
208
|
+
console.log(`[StepPoller] Started (polling every ${POLL_INTERVAL_MS / 1000}s)`)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Stop the polling daemon.
|
|
213
|
+
*/
|
|
214
|
+
function stop() {
|
|
215
|
+
if (pollTimer) {
|
|
216
|
+
clearInterval(pollTimer)
|
|
217
|
+
pollTimer = null
|
|
218
|
+
console.log('[StepPoller] Stopped')
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
module.exports = { start, stop, pollOnce }
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strip ANSI escape sequences from a string.
|
|
3
|
+
* Handles CSI sequences, OSC sequences, and other control codes
|
|
4
|
+
* that terminal emulators (node-pty) produce.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// Match ANSI escape sequences:
|
|
8
|
+
// - CSI: ESC[ ... (params including <>=:;?) ... (final byte)
|
|
9
|
+
// - OSC: ESC] ... ST (string terminator: ESC\ or BEL, or next ESC)
|
|
10
|
+
// - Other ESC sequences: ESC followed by a character
|
|
11
|
+
// - Standalone control characters (except newline, carriage return, tab)
|
|
12
|
+
const ANSI_REGEX = /(?:\x1B\[[0-9;?<>=:]*[A-Za-z~]|\x1B\][^\x07\x1B]*(?:\x07|\x1B\\|\x1B(?=\[|\]))|\x1B[^[\]()][A-Za-z]?|[\x00-\x08\x0B\x0C\x0E-\x1F\x7F])/g
|
|
13
|
+
|
|
14
|
+
function stripAnsi(str) {
|
|
15
|
+
return str.replace(ANSI_REGEX, '')
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
module.exports = { stripAnsi }
|