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,255 @@
|
|
|
1
|
+
const { spawn } = require('child_process');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const fs = require('fs').promises;
|
|
4
|
+
const logger = require('./logger');
|
|
5
|
+
|
|
6
|
+
// Timeout constants
|
|
7
|
+
const TIMEOUTS = {
|
|
8
|
+
GIT_COMMAND_MS: 30000, // 30 seconds for git operations
|
|
9
|
+
WORKTREE_CLEANUP_DELAY_MS: 500, // Wait after worktree removal for filesystem consistency
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* WorktreeManager - Manages git worktrees for parallel job execution
|
|
14
|
+
*
|
|
15
|
+
* Each job gets an isolated worktree as a sibling to the repo:
|
|
16
|
+
* <repo-parent>/<repo-name>-worktrees/job-{id}/
|
|
17
|
+
* with a unique branch: blaster/{slugified-task-title}-{task_id}/job-{job_id}
|
|
18
|
+
*
|
|
19
|
+
* Worktrees are created OUTSIDE the repo to prevent git conflicts.
|
|
20
|
+
*/
|
|
21
|
+
class WorktreeManager {
|
|
22
|
+
/**
|
|
23
|
+
* Create a new worktree for a job with retry logic for multi-agent safety
|
|
24
|
+
* @param {Object} job - The job object with id, task, and project
|
|
25
|
+
* @param {number} maxRetries - Maximum number of retry attempts (default: 3)
|
|
26
|
+
* @returns {Promise<string>} - The absolute path to the worktree
|
|
27
|
+
*/
|
|
28
|
+
async createWorktree(job, maxRetries = 3) {
|
|
29
|
+
const worktreePath = this.getWorktreePath(job)
|
|
30
|
+
const branchName = this.getBranchName(job)
|
|
31
|
+
const systemPath = job.project.system_path
|
|
32
|
+
|
|
33
|
+
// Phase 3: Use event for structured logging
|
|
34
|
+
logger.event('worktree.creating', {
|
|
35
|
+
component: 'worktree',
|
|
36
|
+
operation: 'create',
|
|
37
|
+
path: worktreePath,
|
|
38
|
+
branch: branchName,
|
|
39
|
+
systemPath
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
// Retry loop with exponential backoff for handling concurrent worktree operations
|
|
43
|
+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
44
|
+
try {
|
|
45
|
+
// Verify git is available
|
|
46
|
+
await this.execGit(systemPath, ['--version'], 5000)
|
|
47
|
+
|
|
48
|
+
// Check if worktree already exists (from a previous failed run)
|
|
49
|
+
try {
|
|
50
|
+
await fs.access(worktreePath)
|
|
51
|
+
logger.warn(`Worktree already exists at ${worktreePath}, removing stale worktree (attempt ${attempt}/${maxRetries})`)
|
|
52
|
+
await this.removeWorktree(job)
|
|
53
|
+
// Wait a bit after removal to ensure filesystem consistency
|
|
54
|
+
await this.sleep(TIMEOUTS.WORKTREE_CLEANUP_DELAY_MS)
|
|
55
|
+
} catch (err) {
|
|
56
|
+
// Worktree doesn't exist, which is expected
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Create the worktree with a new branch
|
|
60
|
+
// -b creates a new branch, --detach would checkout without branch
|
|
61
|
+
await this.execGit(
|
|
62
|
+
systemPath,
|
|
63
|
+
['worktree', 'add', '-b', branchName, worktreePath, 'HEAD'],
|
|
64
|
+
TIMEOUTS.GIT_COMMAND_MS
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
// Phase 3: Use event for structured logging
|
|
68
|
+
logger.event('worktree.created', {
|
|
69
|
+
component: 'worktree',
|
|
70
|
+
operation: 'create',
|
|
71
|
+
path: worktreePath,
|
|
72
|
+
branch: branchName
|
|
73
|
+
})
|
|
74
|
+
return worktreePath
|
|
75
|
+
} catch (error) {
|
|
76
|
+
const isLastAttempt = attempt === maxRetries
|
|
77
|
+
|
|
78
|
+
// Check if this is a lock/collision error that might be resolved by retrying
|
|
79
|
+
const isRetryableError =
|
|
80
|
+
error.message.includes('already exists') ||
|
|
81
|
+
error.message.includes('already locked') ||
|
|
82
|
+
error.message.includes('unable to create') ||
|
|
83
|
+
error.message.includes('fatal: could not lock');
|
|
84
|
+
|
|
85
|
+
if (isRetryableError && !isLastAttempt) {
|
|
86
|
+
// Exponential backoff: 1s, 2s, 4s
|
|
87
|
+
const backoffMs = 1000 * Math.pow(2, attempt - 1);
|
|
88
|
+
logger.warn(`Worktree creation failed (attempt ${attempt}/${maxRetries}), retrying in ${backoffMs}ms`, {
|
|
89
|
+
error: error.message
|
|
90
|
+
})
|
|
91
|
+
await this.sleep(backoffMs)
|
|
92
|
+
continue
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Non-retryable error or last attempt - throw
|
|
96
|
+
logger.error(`Failed to create worktree for job ${job.id} (attempt ${attempt}/${maxRetries})`, {
|
|
97
|
+
error: error.message,
|
|
98
|
+
worktreePath,
|
|
99
|
+
branchName
|
|
100
|
+
})
|
|
101
|
+
throw new Error(`Failed to create worktree: ${error.message}`)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Should never reach here, but just in case
|
|
106
|
+
throw new Error('Failed to create worktree after all retry attempts')
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Sleep helper for retry logic
|
|
111
|
+
* @param {number} ms - Milliseconds to sleep
|
|
112
|
+
*/
|
|
113
|
+
sleep(ms) {
|
|
114
|
+
return new Promise(resolve => setTimeout(resolve, ms))
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Remove a worktree after job completion
|
|
119
|
+
* @param {Object} job - The job object
|
|
120
|
+
*/
|
|
121
|
+
async removeWorktree(job) {
|
|
122
|
+
const worktreePath = this.getWorktreePath(job)
|
|
123
|
+
const branchName = this.getBranchName(job)
|
|
124
|
+
const systemPath = job.project.system_path
|
|
125
|
+
|
|
126
|
+
// Phase 3: Use event for structured logging
|
|
127
|
+
logger.event('worktree.removing', {
|
|
128
|
+
component: 'worktree',
|
|
129
|
+
operation: 'remove',
|
|
130
|
+
path: worktreePath,
|
|
131
|
+
branch: branchName
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
// Remove the worktree (--force removes even if dirty)
|
|
136
|
+
await this.execGit(
|
|
137
|
+
systemPath,
|
|
138
|
+
['worktree', 'remove', worktreePath, '--force'],
|
|
139
|
+
TIMEOUTS.GIT_COMMAND_MS
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
// Phase 3: Use event for structured logging
|
|
143
|
+
logger.event('worktree.removed', {
|
|
144
|
+
component: 'worktree',
|
|
145
|
+
operation: 'remove',
|
|
146
|
+
path: worktreePath
|
|
147
|
+
})
|
|
148
|
+
} catch (error) {
|
|
149
|
+
// Log error but don't throw - cleanup is best-effort
|
|
150
|
+
logger.error(`Failed to remove worktree for job ${job.id}`, {
|
|
151
|
+
error: error.message,
|
|
152
|
+
worktreePath
|
|
153
|
+
})
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Note: We intentionally don't delete the branch here
|
|
157
|
+
// The branch remains in the repo for history/inspection
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Get the absolute path where the worktree should be created
|
|
162
|
+
* Creates worktree as a sibling to the repo, not inside it
|
|
163
|
+
* @param {Object} job - The job object
|
|
164
|
+
* @returns {string} - Absolute path to worktree
|
|
165
|
+
*/
|
|
166
|
+
getWorktreePath(job) {
|
|
167
|
+
const systemPath = job.project.system_path
|
|
168
|
+
const repoName = path.basename(systemPath)
|
|
169
|
+
const repoParent = path.dirname(systemPath)
|
|
170
|
+
// Create worktree as sibling: /parent/repo-worktrees/job-{id}
|
|
171
|
+
// NOT inside repo: /parent/repo/.ralph-worktrees/job-{id}
|
|
172
|
+
return path.join(repoParent, `${repoName}-worktrees`, `job-${job.id}`)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Get the branch name for this job
|
|
177
|
+
* @param {Object} job - The job object
|
|
178
|
+
* @returns {string} - Branch name in format blaster/ticket-{slugified-title}-{task_id}/job-{job_id}
|
|
179
|
+
*/
|
|
180
|
+
getBranchName(job) {
|
|
181
|
+
const titleSlug = this.slugifyTitle(job.task_title || `task-${job.task_id}`)
|
|
182
|
+
return `blaster/${titleSlug}-${job.task_id}/job-${job.id}`
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Convert a title to a git-safe slug
|
|
187
|
+
* @param {string} title - The task title
|
|
188
|
+
* @returns {string} - Slugified title (lowercase, alphanumeric, hyphens)
|
|
189
|
+
*/
|
|
190
|
+
slugifyTitle(title) {
|
|
191
|
+
return title
|
|
192
|
+
.toLowerCase()
|
|
193
|
+
.trim()
|
|
194
|
+
.replace(/[^a-z0-9\s-]/g, '') // Remove special chars
|
|
195
|
+
.replace(/\s+/g, '-') // Replace spaces with hyphens
|
|
196
|
+
.replace(/-+/g, '-') // Replace multiple hyphens with single
|
|
197
|
+
.replace(/^-|-$/g, '') // Remove leading/trailing hyphens
|
|
198
|
+
.substring(0, 50) // Limit length to 50 chars
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Execute a git command safely
|
|
203
|
+
* @param {string} cwd - Working directory
|
|
204
|
+
* @param {string[]} args - Git command arguments
|
|
205
|
+
* @param {number} timeout - Timeout in milliseconds (default: 30s)
|
|
206
|
+
* @returns {Promise<{stdout: string, stderr: string}>}
|
|
207
|
+
*/
|
|
208
|
+
async execGit(cwd, args, timeout = TIMEOUTS.GIT_COMMAND_MS) {
|
|
209
|
+
return new Promise((resolve, reject) => {
|
|
210
|
+
const process = spawn('git', args, {
|
|
211
|
+
cwd,
|
|
212
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
213
|
+
shell: false // Security: Don't use shell to prevent injection
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
let stdout = ''
|
|
217
|
+
let stderr = ''
|
|
218
|
+
let timedOut = false
|
|
219
|
+
|
|
220
|
+
const timer = setTimeout(() => {
|
|
221
|
+
timedOut = true
|
|
222
|
+
process.kill('SIGTERM')
|
|
223
|
+
reject(new Error(`Git command timed out after ${timeout}ms`))
|
|
224
|
+
}, timeout)
|
|
225
|
+
|
|
226
|
+
process.stdout.on('data', (data) => {
|
|
227
|
+
stdout += data.toString()
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
process.stderr.on('data', (data) => {
|
|
231
|
+
stderr += data.toString()
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
process.on('error', (err) => {
|
|
235
|
+
clearTimeout(timer)
|
|
236
|
+
if (!timedOut) {
|
|
237
|
+
reject(new Error(`Failed to execute git: ${err.message}`))
|
|
238
|
+
}
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
process.on('close', (code) => {
|
|
242
|
+
clearTimeout(timer)
|
|
243
|
+
if (timedOut) return // Already rejected
|
|
244
|
+
|
|
245
|
+
if (code === 0) {
|
|
246
|
+
resolve({ stdout, stderr })
|
|
247
|
+
} else {
|
|
248
|
+
reject(new Error(`Git command failed (exit code ${code}): ${stderr || stdout}`))
|
|
249
|
+
}
|
|
250
|
+
})
|
|
251
|
+
})
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
module.exports = WorktreeManager;
|