ralphblaster-agent 0.1.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.
- package/LICENSE +21 -0
- package/README.md +294 -0
- package/bin/agent-dashboard.sh +168 -0
- package/bin/monitor-agent.sh +264 -0
- package/bin/ralphblaster.js +247 -0
- package/package.json +64 -0
- package/postinstall-colored.js +66 -0
- package/src/api-client.js +764 -0
- package/src/claude-plugin/.claude-plugin/plugin.json +9 -0
- package/src/claude-plugin/README.md +42 -0
- package/src/claude-plugin/skills/ralph/SKILL.md +259 -0
- package/src/commands/add-project.js +257 -0
- package/src/commands/init.js +79 -0
- package/src/config-file-manager.js +84 -0
- package/src/config.js +66 -0
- package/src/error-window.js +86 -0
- package/src/executor/claude-runner.js +716 -0
- package/src/executor/error-handler.js +65 -0
- package/src/executor/git-helper.js +196 -0
- package/src/executor/index.js +296 -0
- package/src/executor/job-handlers/clarifying-questions.js +213 -0
- package/src/executor/job-handlers/code-execution.js +145 -0
- package/src/executor/job-handlers/prd-generation.js +259 -0
- package/src/executor/path-helper.js +74 -0
- package/src/executor/prompt-validator.js +51 -0
- package/src/executor.js +4 -0
- package/src/index.js +342 -0
- package/src/logger.js +193 -0
- package/src/logging/README.md +93 -0
- package/src/logging/config.js +179 -0
- package/src/logging/destinations/README.md +290 -0
- package/src/logging/destinations/api-destination-unbatched.js +118 -0
- package/src/logging/destinations/api-destination.js +40 -0
- package/src/logging/destinations/base-destination.js +85 -0
- package/src/logging/destinations/batched-destination.js +198 -0
- package/src/logging/destinations/console-destination.js +172 -0
- package/src/logging/destinations/file-destination.js +208 -0
- package/src/logging/destinations/index.js +29 -0
- package/src/logging/destinations/progress-batch-destination-unbatched.js +92 -0
- package/src/logging/destinations/progress-batch-destination.js +41 -0
- package/src/logging/formatter.js +288 -0
- package/src/logging/log-manager.js +426 -0
- package/src/progress-throttle.js +101 -0
- package/src/system-monitor.js +64 -0
- package/src/utils/format.js +16 -0
- package/src/utils/log-file-helper.js +265 -0
- package/src/utils/progress-parser.js +250 -0
- package/src/worktree-manager.js +255 -0
|
@@ -0,0 +1,716 @@
|
|
|
1
|
+
const { spawn } = require('child_process');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const logger = require('../logger');
|
|
4
|
+
const { formatDuration } = require('../utils/format');
|
|
5
|
+
|
|
6
|
+
// Timeout constants
|
|
7
|
+
const TIMEOUTS = {
|
|
8
|
+
CLAUDE_EXECUTION_MS: 7200000, // 2 hours (fallback/default only)
|
|
9
|
+
DEFAULT_TIMEOUT_MINUTES: 60, // Default timeout if not specified in job
|
|
10
|
+
SAFETY_MARGIN_MINUTES: 1 // Agent terminates 1 min before Rails timeout
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Calculate Claude execution timeout from job configuration
|
|
15
|
+
* Agent terminates 1 minute before Rails timeout to ensure clean state
|
|
16
|
+
* @param {Object} job - Job object from Rails API
|
|
17
|
+
* @returns {number} Timeout in milliseconds
|
|
18
|
+
*/
|
|
19
|
+
function getClaudeTimeout(job) {
|
|
20
|
+
const timeoutMinutes = job.timeout_minutes || TIMEOUTS.DEFAULT_TIMEOUT_MINUTES;
|
|
21
|
+
const safetyMarginMinutes = TIMEOUTS.SAFETY_MARGIN_MINUTES;
|
|
22
|
+
const effectiveMinutes = Math.max(timeoutMinutes - safetyMarginMinutes, 5); // Min 5 minutes
|
|
23
|
+
return effectiveMinutes * 60 * 1000; // Convert to milliseconds
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* ClaudeRunner - Handles Claude CLI execution
|
|
28
|
+
*
|
|
29
|
+
* This class encapsulates all Claude CLI interaction including:
|
|
30
|
+
* - Spawning Claude processes with proper security
|
|
31
|
+
* - Streaming raw terminal output
|
|
32
|
+
* - Handling timeouts and errors
|
|
33
|
+
*/
|
|
34
|
+
class ClaudeRunner {
|
|
35
|
+
constructor(errorHandler, gitHelper) {
|
|
36
|
+
this.errorHandler = errorHandler;
|
|
37
|
+
this.gitHelper = gitHelper;
|
|
38
|
+
this.currentProcess = null;
|
|
39
|
+
this.currentJobId = null;
|
|
40
|
+
this.apiClient = null;
|
|
41
|
+
this.capturedStderr = '';
|
|
42
|
+
// Debug mode enabled by default, disable with CLAUDE_STREAM_DEBUG=false
|
|
43
|
+
this.debugStream = process.env.CLAUDE_STREAM_DEBUG !== 'false';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Set the current job ID for event emission
|
|
48
|
+
* @param {number} jobId - Job identifier
|
|
49
|
+
*/
|
|
50
|
+
setJobId(jobId) {
|
|
51
|
+
this.currentJobId = jobId;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Set the API client for progress updates
|
|
56
|
+
* @param {Object} apiClient - API client instance
|
|
57
|
+
*/
|
|
58
|
+
setApiClient(apiClient) {
|
|
59
|
+
this.apiClient = apiClient;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Reset captured stderr
|
|
64
|
+
*/
|
|
65
|
+
resetCapturedStderr() {
|
|
66
|
+
this.capturedStderr = '';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Format stream-json event for progress display
|
|
71
|
+
* @param {Object} event - Stream JSON event
|
|
72
|
+
* @returns {string|null} Formatted text or null if not displayable
|
|
73
|
+
*/
|
|
74
|
+
formatStreamEvent(event) {
|
|
75
|
+
// Debug mode: log only interesting stream events, not every delta
|
|
76
|
+
if (this.debugStream) {
|
|
77
|
+
const interestingTypes = ['tool_use', 'tool_result', 'error', 'thinking'];
|
|
78
|
+
if (interestingTypes.includes(event.type)) {
|
|
79
|
+
const eventPreview = JSON.stringify(event).slice(0, 500);
|
|
80
|
+
logger.debug(`[STREAM] ${event.type} | ${eventPreview}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
switch (event.type) {
|
|
85
|
+
case 'content_block_delta':
|
|
86
|
+
// Only show Claude's actual text output, not every tiny delta
|
|
87
|
+
return event.delta?.text || null;
|
|
88
|
+
|
|
89
|
+
case 'tool_use':
|
|
90
|
+
case 'tool_result':
|
|
91
|
+
// Tool usage is shown via assistant event content blocks
|
|
92
|
+
return null;
|
|
93
|
+
|
|
94
|
+
case 'thinking':
|
|
95
|
+
// Thinking content is verbose, skip for cleaner output
|
|
96
|
+
return null;
|
|
97
|
+
|
|
98
|
+
case 'error':
|
|
99
|
+
return `\n❌ Error: ${event.error?.message || 'Unknown error'}\n`;
|
|
100
|
+
|
|
101
|
+
case 'assistant': {
|
|
102
|
+
// Extract both text and tool usage for terminal display
|
|
103
|
+
const content = event.message?.content || [];
|
|
104
|
+
const parts = [];
|
|
105
|
+
|
|
106
|
+
for (const block of content) {
|
|
107
|
+
if (block.type === 'text' && block.text) {
|
|
108
|
+
// Include substantial text (not just whitespace)
|
|
109
|
+
const trimmed = block.text.trim();
|
|
110
|
+
if (trimmed.length > 0) {
|
|
111
|
+
parts.push(block.text);
|
|
112
|
+
}
|
|
113
|
+
} else if (block.type === 'tool_use') {
|
|
114
|
+
// Show tool usage in terminal for visibility
|
|
115
|
+
const toolName = block.name;
|
|
116
|
+
const input = block.input || {};
|
|
117
|
+
|
|
118
|
+
switch (toolName) {
|
|
119
|
+
case 'Read':
|
|
120
|
+
parts.push(`\n📖 Reading: ${input.file_path || 'file'}\n`);
|
|
121
|
+
break;
|
|
122
|
+
case 'Edit':
|
|
123
|
+
parts.push(`\n✏️ Editing: ${input.file_path || 'file'}\n`);
|
|
124
|
+
break;
|
|
125
|
+
case 'Write':
|
|
126
|
+
parts.push(`\n📝 Creating: ${input.file_path || 'file'}\n`);
|
|
127
|
+
break;
|
|
128
|
+
case 'Bash':
|
|
129
|
+
const cmd = input.command?.slice(0, 60) || 'command';
|
|
130
|
+
parts.push(`\n💻 Running: ${cmd}${input.command?.length > 60 ? '...' : ''}\n`);
|
|
131
|
+
break;
|
|
132
|
+
case 'Grep':
|
|
133
|
+
case 'Glob':
|
|
134
|
+
parts.push(`\n🔍 Searching: ${input.pattern || input.glob || 'files'}\n`);
|
|
135
|
+
break;
|
|
136
|
+
case 'Task':
|
|
137
|
+
parts.push(`\n🔄 Subtask: ${input.description?.slice(0, 60) || 'working...'}\n`);
|
|
138
|
+
break;
|
|
139
|
+
default:
|
|
140
|
+
parts.push(`\n🔧 ${toolName}\n`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return parts.length > 0 ? parts.join('') : null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
case 'user':
|
|
148
|
+
// Skip user messages (tool results) - not useful in progress stream
|
|
149
|
+
return null;
|
|
150
|
+
|
|
151
|
+
case 'text':
|
|
152
|
+
return event.text || null;
|
|
153
|
+
|
|
154
|
+
case 'result':
|
|
155
|
+
// Skip result events - not useful for live progress
|
|
156
|
+
return null;
|
|
157
|
+
|
|
158
|
+
case 'system':
|
|
159
|
+
// Skip system events (init, hooks) - not useful for display
|
|
160
|
+
return null;
|
|
161
|
+
|
|
162
|
+
default:
|
|
163
|
+
if (this.debugStream) {
|
|
164
|
+
logger.debug(`Unhandled stream event type: ${event.type}`);
|
|
165
|
+
}
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Send structured status events for Claude tool usage
|
|
172
|
+
* @param {Object} event - Stream JSON event
|
|
173
|
+
* @param {number} jobId - Job ID for API calls
|
|
174
|
+
*/
|
|
175
|
+
async sendToolStatusEvent(event, jobId) {
|
|
176
|
+
if (!this.apiClient) return;
|
|
177
|
+
|
|
178
|
+
try {
|
|
179
|
+
if (event.type === 'assistant') {
|
|
180
|
+
const content = event.message?.content || [];
|
|
181
|
+
for (const block of content) {
|
|
182
|
+
if (block.type === 'tool_use') {
|
|
183
|
+
const toolName = block.name;
|
|
184
|
+
const input = block.input || {};
|
|
185
|
+
|
|
186
|
+
// Map tool names to valid Rails event types
|
|
187
|
+
let eventType;
|
|
188
|
+
let message;
|
|
189
|
+
const metadata = {};
|
|
190
|
+
|
|
191
|
+
switch (toolName) {
|
|
192
|
+
case 'Read':
|
|
193
|
+
eventType = 'read_file';
|
|
194
|
+
message = `📖 Reading: ${input.file_path?.split('/').pop() || 'file'}`;
|
|
195
|
+
metadata.file = input.file_path;
|
|
196
|
+
break;
|
|
197
|
+
case 'Edit':
|
|
198
|
+
eventType = 'edit_file';
|
|
199
|
+
message = `✏️ Editing: ${input.file_path?.split('/').pop() || 'file'}`;
|
|
200
|
+
metadata.file = input.file_path;
|
|
201
|
+
break;
|
|
202
|
+
case 'Write':
|
|
203
|
+
eventType = 'write_file';
|
|
204
|
+
message = `📝 Creating: ${input.file_path?.split('/').pop() || 'file'}`;
|
|
205
|
+
metadata.file = input.file_path;
|
|
206
|
+
break;
|
|
207
|
+
case 'Bash':
|
|
208
|
+
eventType = 'bash_command';
|
|
209
|
+
const cmd = input.command?.slice(0, 50) || 'command';
|
|
210
|
+
message = `💻 Running: ${cmd}${input.command?.length > 50 ? '...' : ''}`;
|
|
211
|
+
metadata.command = input.command?.slice(0, 200);
|
|
212
|
+
break;
|
|
213
|
+
case 'Grep':
|
|
214
|
+
case 'Glob':
|
|
215
|
+
eventType = 'search';
|
|
216
|
+
message = `🔍 Searching: ${input.pattern || input.glob || 'files'}`;
|
|
217
|
+
metadata.pattern = input.pattern || input.glob;
|
|
218
|
+
break;
|
|
219
|
+
case 'Task':
|
|
220
|
+
eventType = 'progress_update';
|
|
221
|
+
message = `🔄 Subtask: ${input.description?.slice(0, 50) || 'working...'}`;
|
|
222
|
+
metadata.subagent = input.subagent_type;
|
|
223
|
+
break;
|
|
224
|
+
default:
|
|
225
|
+
eventType = 'progress_update';
|
|
226
|
+
message = `🔧 Using: ${toolName}`;
|
|
227
|
+
metadata.tool = toolName;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
await this.apiClient.sendStatusEvent(jobId, eventType, message, metadata);
|
|
231
|
+
}
|
|
232
|
+
// Phase 1.4: REMOVED duplicate text block status events
|
|
233
|
+
// Text blocks are already captured in progress chunks, so this was redundant
|
|
234
|
+
// Expected impact: Eliminate 5-10 duplicate events per job
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
} catch (err) {
|
|
238
|
+
logger.debug(`Failed to send tool status event: ${err.message}`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Get sanitized environment variables for Claude execution
|
|
244
|
+
* @returns {Object} Sanitized environment object
|
|
245
|
+
*/
|
|
246
|
+
getSanitizedEnv() {
|
|
247
|
+
const safeEnv = {};
|
|
248
|
+
|
|
249
|
+
// Explicitly allowed environment variables
|
|
250
|
+
const allowedVars = [
|
|
251
|
+
'PATH',
|
|
252
|
+
'HOME',
|
|
253
|
+
'USER',
|
|
254
|
+
'LANG',
|
|
255
|
+
'LC_ALL',
|
|
256
|
+
'TERM',
|
|
257
|
+
'TMPDIR',
|
|
258
|
+
'SHELL',
|
|
259
|
+
'NODE_ENV' // Add NODE_ENV for Claude
|
|
260
|
+
];
|
|
261
|
+
|
|
262
|
+
// Explicitly blocked patterns (even if they match allowed vars)
|
|
263
|
+
const blockedPatterns = [
|
|
264
|
+
/^RALPH_API_TOKEN$/i,
|
|
265
|
+
/^RALPHBLASTER_API_TOKEN$/i,
|
|
266
|
+
/^.*_TOKEN$/i,
|
|
267
|
+
/^.*_SECRET$/i,
|
|
268
|
+
/^.*_KEY$/i,
|
|
269
|
+
/^.*_PASSWORD$/i,
|
|
270
|
+
/^AWS_/i,
|
|
271
|
+
/^AZURE_/i,
|
|
272
|
+
/^GCP_/i,
|
|
273
|
+
/^GOOGLE_/i
|
|
274
|
+
];
|
|
275
|
+
|
|
276
|
+
for (const key of allowedVars) {
|
|
277
|
+
if (process.env[key]) {
|
|
278
|
+
// Double-check not in blocklist
|
|
279
|
+
const isBlocked = blockedPatterns.some(pattern => pattern.test(key));
|
|
280
|
+
if (!isBlocked) {
|
|
281
|
+
safeEnv[key] = process.env[key];
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Don't log HOME to avoid exposing username in logs
|
|
287
|
+
const safeToLog = Object.keys(safeEnv).filter(k => k !== 'HOME');
|
|
288
|
+
logger.debug(`Sanitized environment: ${safeToLog.join(', ')}`);
|
|
289
|
+
return safeEnv;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Run Claude CLI with the given prompt
|
|
294
|
+
* @param {string} prompt - Prompt text
|
|
295
|
+
* @param {string} cwd - Working directory
|
|
296
|
+
* @param {Function} onProgress - Progress callback
|
|
297
|
+
* @param {number} timeout - Timeout in milliseconds (calculated from job.timeout_minutes)
|
|
298
|
+
* @param {Object} job - Job object (optional, for logging timeout details)
|
|
299
|
+
* @returns {Promise<string>} Command output
|
|
300
|
+
*/
|
|
301
|
+
runClaude(prompt, cwd, onProgress, timeout = TIMEOUTS.CLAUDE_EXECUTION_MS, job = null) {
|
|
302
|
+
return new Promise((resolve, reject) => {
|
|
303
|
+
const timeoutFormatted = formatDuration(timeout);
|
|
304
|
+
const logData = {
|
|
305
|
+
timeout: timeoutFormatted,
|
|
306
|
+
workingDirectory: cwd,
|
|
307
|
+
promptLength: prompt.length
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
// Log timeout configuration if job provided
|
|
311
|
+
if (job && job.timeout_minutes) {
|
|
312
|
+
logData.jobTimeoutMinutes = job.timeout_minutes;
|
|
313
|
+
logData.calculatedTimeoutMinutes = Math.floor(timeout / 60000);
|
|
314
|
+
logger.info(`Using job-specific timeout: ${job.timeout_minutes} minutes (agent will terminate at ${Math.floor(timeout / 60000)} minutes)`);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
logger.info(`Starting Claude CLI execution`, logData);
|
|
318
|
+
|
|
319
|
+
// Use stdin to pass prompt - avoids shell injection
|
|
320
|
+
// Use stream-json format for structured progress events
|
|
321
|
+
logger.info('Spawning Claude CLI process with stream-json output');
|
|
322
|
+
const claude = spawn('claude', ['-p', '--output-format', 'stream-json', '--include-partial-messages', '--permission-mode', 'acceptEdits', '--verbose'], {
|
|
323
|
+
cwd: cwd,
|
|
324
|
+
shell: false,
|
|
325
|
+
env: this.getSanitizedEnv()
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
logger.info('Claude CLI process spawned, writing prompt to stdin...');
|
|
329
|
+
|
|
330
|
+
// Set timeout
|
|
331
|
+
const timer = setTimeout(() => {
|
|
332
|
+
logger.error(`Claude CLI timed out after ${timeout}ms (${timeoutFormatted})`);
|
|
333
|
+
claude.kill('SIGTERM');
|
|
334
|
+
reject(new Error(`Claude CLI execution timed out after ${timeout}ms`));
|
|
335
|
+
}, timeout);
|
|
336
|
+
|
|
337
|
+
// Track process for shutdown cleanup
|
|
338
|
+
this.currentProcess = claude;
|
|
339
|
+
|
|
340
|
+
// Send prompt via stdin (safe from injection)
|
|
341
|
+
try {
|
|
342
|
+
claude.stdin.write(prompt);
|
|
343
|
+
claude.stdin.end();
|
|
344
|
+
logger.info('Prompt successfully written to Claude CLI stdin');
|
|
345
|
+
} catch (err) {
|
|
346
|
+
logger.error('Failed to write prompt to Claude CLI stdin', { error: err.message });
|
|
347
|
+
clearTimeout(timer);
|
|
348
|
+
reject(new Error(`Failed to write prompt to Claude: ${err.message}`));
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
let stdout = '';
|
|
353
|
+
let stderr = '';
|
|
354
|
+
let buffer = ''; // Buffer for incomplete JSON lines
|
|
355
|
+
|
|
356
|
+
claude.stdout.on('data', (data) => {
|
|
357
|
+
const chunk = data.toString();
|
|
358
|
+
buffer += chunk;
|
|
359
|
+
|
|
360
|
+
// Process complete JSON lines
|
|
361
|
+
const lines = buffer.split('\n');
|
|
362
|
+
buffer = lines.pop() || ''; // Keep incomplete line in buffer
|
|
363
|
+
|
|
364
|
+
for (const line of lines) {
|
|
365
|
+
if (!line.trim()) continue;
|
|
366
|
+
|
|
367
|
+
try {
|
|
368
|
+
const event = JSON.parse(line);
|
|
369
|
+
|
|
370
|
+
// Accumulate final output from content blocks or result events
|
|
371
|
+
if (event.type === 'content_block_delta' && event.delta?.text) {
|
|
372
|
+
stdout += event.delta.text;
|
|
373
|
+
} else if (event.type === 'result' && event.result) {
|
|
374
|
+
// Claude stream-json puts final output in result event
|
|
375
|
+
stdout = event.result;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Send progress updates for all event types
|
|
379
|
+
const progressText = this.formatStreamEvent(event);
|
|
380
|
+
if (progressText) {
|
|
381
|
+
// Write to stdout for terminal visibility
|
|
382
|
+
process.stdout.write(progressText);
|
|
383
|
+
|
|
384
|
+
// Send to progress callback if provided
|
|
385
|
+
if (onProgress) {
|
|
386
|
+
onProgress(progressText);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
} catch (err) {
|
|
390
|
+
// If JSON parse fails, treat as raw output
|
|
391
|
+
logger.debug(`Non-JSON stdout line: ${line.slice(0, 100)}`);
|
|
392
|
+
stdout += line + '\n';
|
|
393
|
+
|
|
394
|
+
// Write to stdout for terminal visibility
|
|
395
|
+
process.stdout.write(line + '\n');
|
|
396
|
+
|
|
397
|
+
if (onProgress) {
|
|
398
|
+
onProgress(line + '\n');
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
claude.stderr.on('data', (data) => {
|
|
405
|
+
const stderrChunk = data.toString();
|
|
406
|
+
stderr += stderrChunk;
|
|
407
|
+
// Save to instance variable for log file
|
|
408
|
+
this.capturedStderr = (this.capturedStderr || '') + stderrChunk;
|
|
409
|
+
|
|
410
|
+
// Log stderr but don't treat as JSON
|
|
411
|
+
process.stderr.write(stderrChunk);
|
|
412
|
+
|
|
413
|
+
// Send stderr to progress callback (Claude's interactive output comes on stderr)
|
|
414
|
+
if (onProgress) {
|
|
415
|
+
onProgress(stderrChunk);
|
|
416
|
+
}
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
claude.on('close', (code) => {
|
|
420
|
+
clearTimeout(timer); // Clear timeout
|
|
421
|
+
this.currentProcess = null; // Clear process reference
|
|
422
|
+
|
|
423
|
+
logger.info(`Claude CLI process exited`, {
|
|
424
|
+
exitCode: code,
|
|
425
|
+
stdoutLength: stdout.length,
|
|
426
|
+
stderrLength: stderr.length
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
if (code === 0) {
|
|
430
|
+
logger.info('Claude CLI execution completed successfully');
|
|
431
|
+
resolve(stdout);
|
|
432
|
+
} else {
|
|
433
|
+
logger.error(`Claude CLI exited with non-zero code ${code}`);
|
|
434
|
+
logger.error('Last 1000 chars of stderr:', stderr.slice(-1000));
|
|
435
|
+
|
|
436
|
+
const baseError = new Error(`Claude CLI failed with exit code ${code}: ${stderr}`);
|
|
437
|
+
const errorInfo = this.errorHandler.categorizeError(baseError, stderr, code);
|
|
438
|
+
|
|
439
|
+
logger.error('Error categorization:', {
|
|
440
|
+
category: errorInfo.category,
|
|
441
|
+
userMessage: errorInfo.userMessage
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
// Attach categorization to error object
|
|
445
|
+
const enrichedError = new Error(errorInfo.userMessage);
|
|
446
|
+
enrichedError.category = errorInfo.category;
|
|
447
|
+
enrichedError.technicalDetails = errorInfo.technicalDetails;
|
|
448
|
+
enrichedError.partialOutput = stdout; // Include any partial output
|
|
449
|
+
|
|
450
|
+
reject(enrichedError);
|
|
451
|
+
}
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
claude.on('error', (error) => {
|
|
455
|
+
clearTimeout(timer); // Clear timeout
|
|
456
|
+
this.currentProcess = null; // Clear process reference
|
|
457
|
+
logger.error('Failed to spawn Claude CLI process', {
|
|
458
|
+
error: error.message,
|
|
459
|
+
code: error.code,
|
|
460
|
+
errno: error.errno,
|
|
461
|
+
syscall: error.syscall
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
const errorInfo = this.errorHandler.categorizeError(error, stderr, null);
|
|
465
|
+
|
|
466
|
+
logger.error('Spawn error categorization:', {
|
|
467
|
+
category: errorInfo.category,
|
|
468
|
+
userMessage: errorInfo.userMessage
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
// Attach categorization to error object
|
|
472
|
+
const enrichedError = new Error(errorInfo.userMessage);
|
|
473
|
+
enrichedError.category = errorInfo.category;
|
|
474
|
+
enrichedError.technicalDetails = errorInfo.technicalDetails;
|
|
475
|
+
enrichedError.partialOutput = stdout; // Include any partial output
|
|
476
|
+
|
|
477
|
+
reject(enrichedError);
|
|
478
|
+
});
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Run Claude Code directly in worktree with raw prompt
|
|
484
|
+
* @param {string} worktreePath - Path to worktree
|
|
485
|
+
* @param {string} prompt - Raw PRD/task description (from job.prompt)
|
|
486
|
+
* @param {Object} job - Job object for progress updates
|
|
487
|
+
* @param {Function} onProgress - Progress callback
|
|
488
|
+
* @returns {Promise<{output: string, branchName: string, duration: number}>}
|
|
489
|
+
*/
|
|
490
|
+
async runClaudeDirectly(worktreePath, prompt, job, onProgress) {
|
|
491
|
+
const startTime = Date.now();
|
|
492
|
+
const timeout = getClaudeTimeout(job);
|
|
493
|
+
|
|
494
|
+
const logData = {
|
|
495
|
+
timeout: formatDuration(timeout),
|
|
496
|
+
workingDirectory: worktreePath,
|
|
497
|
+
promptLength: prompt.length,
|
|
498
|
+
jobTimeoutMinutes: job.timeout_minutes || TIMEOUTS.DEFAULT_TIMEOUT_MINUTES,
|
|
499
|
+
calculatedTimeoutMinutes: Math.floor(timeout / 60000)
|
|
500
|
+
};
|
|
501
|
+
|
|
502
|
+
logger.info(`Running Claude Code in worktree: ${worktreePath}`, logData);
|
|
503
|
+
logger.info(`Using job-specific timeout: ${job.timeout_minutes || TIMEOUTS.DEFAULT_TIMEOUT_MINUTES} minutes (agent will terminate at ${Math.floor(timeout / 60000)} minutes)`);
|
|
504
|
+
|
|
505
|
+
// Send to API/UI
|
|
506
|
+
if (this.apiClient && this.apiClient.sendProgress) {
|
|
507
|
+
this.apiClient.sendProgress(job.id, `Running Claude Code in worktree: ${path.basename(worktreePath)}\n`)
|
|
508
|
+
.catch(err => logger.warn(`Failed to send progress to API: ${err.message}`));
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
logger.event('claude_started', {
|
|
512
|
+
component: 'executor',
|
|
513
|
+
operation: 'claude_direct',
|
|
514
|
+
worktreePath
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
// Spawn Claude with stream-json output for structured progress events
|
|
518
|
+
logger.info('Spawning Claude CLI process with stream-json output');
|
|
519
|
+
|
|
520
|
+
// Send to API/UI
|
|
521
|
+
if (this.apiClient && this.apiClient.sendProgress) {
|
|
522
|
+
this.apiClient.sendProgress(job.id, 'Spawning Claude CLI process with stream-json output\n')
|
|
523
|
+
.catch(err => logger.warn(`Failed to send progress to API: ${err.message}`));
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
const claudeProcess = spawn('claude', ['-p', '--output-format', 'stream-json', '--include-partial-messages', '--permission-mode', 'acceptEdits', '--verbose'], {
|
|
527
|
+
cwd: worktreePath,
|
|
528
|
+
shell: false,
|
|
529
|
+
env: this.getSanitizedEnv()
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
// Set timeout (same as current runClaude)
|
|
533
|
+
const timer = setTimeout(() => {
|
|
534
|
+
logger.error(`Claude CLI timed out after ${timeout}ms`);
|
|
535
|
+
claudeProcess.kill('SIGTERM');
|
|
536
|
+
}, timeout);
|
|
537
|
+
|
|
538
|
+
// Track process for shutdown cleanup
|
|
539
|
+
this.currentProcess = claudeProcess;
|
|
540
|
+
|
|
541
|
+
// Send prompt via stdin (safe from injection)
|
|
542
|
+
try {
|
|
543
|
+
claudeProcess.stdin.write(prompt);
|
|
544
|
+
claudeProcess.stdin.end();
|
|
545
|
+
logger.info('Prompt successfully written to Claude CLI stdin');
|
|
546
|
+
|
|
547
|
+
// Send to API/UI
|
|
548
|
+
if (this.apiClient && this.apiClient.sendProgress) {
|
|
549
|
+
this.apiClient.sendProgress(job.id, 'Prompt successfully written to Claude CLI stdin\n')
|
|
550
|
+
.catch(err => logger.warn(`Failed to send progress to API: ${err.message}`));
|
|
551
|
+
}
|
|
552
|
+
} catch (err) {
|
|
553
|
+
logger.error('Failed to write prompt to Claude CLI stdin', { error: err.message });
|
|
554
|
+
clearTimeout(timer);
|
|
555
|
+
throw new Error(`Failed to write prompt to Claude: ${err.message}`);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
let output = '';
|
|
559
|
+
let errorOutput = '';
|
|
560
|
+
let buffer = ''; // Buffer for incomplete JSON lines
|
|
561
|
+
|
|
562
|
+
return new Promise((resolve, reject) => {
|
|
563
|
+
// Capture stdout and parse stream-json events
|
|
564
|
+
claudeProcess.stdout.on('data', async (data) => {
|
|
565
|
+
const chunk = data.toString();
|
|
566
|
+
buffer += chunk;
|
|
567
|
+
|
|
568
|
+
// Process complete JSON lines
|
|
569
|
+
const lines = buffer.split('\n');
|
|
570
|
+
buffer = lines.pop() || ''; // Keep incomplete line in buffer
|
|
571
|
+
|
|
572
|
+
for (const line of lines) {
|
|
573
|
+
if (!line.trim()) continue;
|
|
574
|
+
|
|
575
|
+
try {
|
|
576
|
+
const event = JSON.parse(line);
|
|
577
|
+
|
|
578
|
+
// Accumulate final output from content blocks or result events
|
|
579
|
+
if (event.type === 'content_block_delta' && event.delta?.text) {
|
|
580
|
+
output += event.delta.text;
|
|
581
|
+
} else if (event.type === 'result' && event.result) {
|
|
582
|
+
// Claude stream-json puts final output in result event
|
|
583
|
+
output = event.result;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// Send structured status events for tool usage
|
|
587
|
+
await this.sendToolStatusEvent(event, job.id);
|
|
588
|
+
|
|
589
|
+
// Send progress updates for all event types
|
|
590
|
+
const progressText = this.formatStreamEvent(event);
|
|
591
|
+
if (progressText) {
|
|
592
|
+
// Write to stdout for terminal visibility
|
|
593
|
+
process.stdout.write(progressText);
|
|
594
|
+
|
|
595
|
+
// Call onProgress callback if provided (it will handle API streaming with throttling)
|
|
596
|
+
if (onProgress) {
|
|
597
|
+
onProgress(progressText);
|
|
598
|
+
} else if (this.apiClient) {
|
|
599
|
+
// Only send directly to API if no onProgress callback (prevents duplicate updates)
|
|
600
|
+
try {
|
|
601
|
+
await this.apiClient.sendProgress(job.id, progressText);
|
|
602
|
+
} catch (err) {
|
|
603
|
+
logger.warn(`Failed to send progress to API: ${err.message}`);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
} catch (err) {
|
|
608
|
+
// If JSON parse fails, treat as raw output
|
|
609
|
+
logger.debug(`Non-JSON stdout line: ${line.slice(0, 100)}`);
|
|
610
|
+
output += line + '\n';
|
|
611
|
+
|
|
612
|
+
// Write to stdout for terminal visibility
|
|
613
|
+
process.stdout.write(line + '\n');
|
|
614
|
+
|
|
615
|
+
if (onProgress) {
|
|
616
|
+
onProgress(line + '\n');
|
|
617
|
+
} else if (this.apiClient) {
|
|
618
|
+
try {
|
|
619
|
+
await this.apiClient.sendProgress(job.id, line + '\n');
|
|
620
|
+
} catch (err) {
|
|
621
|
+
logger.warn(`Failed to send progress to API: ${err.message}`);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
// Capture stderr
|
|
629
|
+
claudeProcess.stderr.on('data', async (data) => {
|
|
630
|
+
const chunk = data.toString();
|
|
631
|
+
errorOutput += chunk;
|
|
632
|
+
// Save to instance variable for log file
|
|
633
|
+
this.capturedStderr = (this.capturedStderr || '') + chunk;
|
|
634
|
+
|
|
635
|
+
// Debug: confirm we're receiving stderr
|
|
636
|
+
logger.debug(`Received Claude stderr chunk (${chunk.length} bytes)`);
|
|
637
|
+
|
|
638
|
+
// Log stderr but don't treat as JSON
|
|
639
|
+
process.stderr.write(chunk);
|
|
640
|
+
|
|
641
|
+
// Call onProgress callback if provided (it will handle API streaming with throttling)
|
|
642
|
+
if (onProgress) {
|
|
643
|
+
onProgress(chunk);
|
|
644
|
+
} else if (this.apiClient) {
|
|
645
|
+
// Only send directly to API if no onProgress callback (prevents duplicate updates)
|
|
646
|
+
try {
|
|
647
|
+
await this.apiClient.sendProgress(job.id, chunk);
|
|
648
|
+
} catch (err) {
|
|
649
|
+
logger.warn(`Failed to send stderr progress to API: ${err.message}`);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
// Wait for completion
|
|
655
|
+
claudeProcess.on('close', async (code) => {
|
|
656
|
+
clearTimeout(timer);
|
|
657
|
+
this.currentProcess = null;
|
|
658
|
+
const duration = Date.now() - startTime;
|
|
659
|
+
|
|
660
|
+
if (code === 0) {
|
|
661
|
+
logger.info(`Claude completed successfully in ${formatDuration(duration)}`, {
|
|
662
|
+
outputLength: output.length,
|
|
663
|
+
outputPreview: output.slice(0, 200)
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
// Warn if output is empty - likely stream parsing issue
|
|
667
|
+
if (!output || output.length === 0) {
|
|
668
|
+
logger.warn('Claude output is empty! Stream-json events may not have been parsed correctly.');
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// Get branch name from worktree
|
|
672
|
+
const branchName = await this.gitHelper.getCurrentBranch(worktreePath);
|
|
673
|
+
|
|
674
|
+
resolve({
|
|
675
|
+
output: output,
|
|
676
|
+
branchName,
|
|
677
|
+
duration
|
|
678
|
+
});
|
|
679
|
+
} else {
|
|
680
|
+
logger.error(`Claude failed with code ${code}`);
|
|
681
|
+
|
|
682
|
+
// Use existing error categorization
|
|
683
|
+
const baseError = new Error(`Claude CLI failed with exit code ${code}: ${errorOutput}`);
|
|
684
|
+
const errorInfo = this.errorHandler.categorizeError(baseError, errorOutput, code);
|
|
685
|
+
|
|
686
|
+
// Create enriched error (same pattern as existing runClaude)
|
|
687
|
+
const enrichedError = new Error(errorInfo.userMessage);
|
|
688
|
+
enrichedError.category = errorInfo.category;
|
|
689
|
+
enrichedError.technicalDetails = errorInfo.technicalDetails;
|
|
690
|
+
enrichedError.partialOutput = output;
|
|
691
|
+
|
|
692
|
+
reject(enrichedError);
|
|
693
|
+
}
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
claudeProcess.on('error', (err) => {
|
|
697
|
+
clearTimeout(timer);
|
|
698
|
+
this.currentProcess = null;
|
|
699
|
+
logger.error(`Failed to spawn Claude CLI: ${err.message}`);
|
|
700
|
+
|
|
701
|
+
// Use existing error categorization
|
|
702
|
+
const errorInfo = this.errorHandler.categorizeError(err, errorOutput, null);
|
|
703
|
+
|
|
704
|
+
const enrichedError = new Error(errorInfo.userMessage);
|
|
705
|
+
enrichedError.category = errorInfo.category;
|
|
706
|
+
enrichedError.technicalDetails = errorInfo.technicalDetails;
|
|
707
|
+
enrichedError.partialOutput = output;
|
|
708
|
+
|
|
709
|
+
reject(enrichedError);
|
|
710
|
+
});
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
module.exports = ClaudeRunner;
|