ralphblaster-agent 1.2.0
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 +253 -0
- package/bin/ralph-agent.js +87 -0
- package/package.json +59 -0
- package/src/api-client.js +344 -0
- package/src/config.js +21 -0
- package/src/executor.js +1014 -0
- package/src/index.js +243 -0
- package/src/logger.js +96 -0
- package/src/ralph/prompt.md +165 -0
- package/src/ralph/ralph.sh +239 -0
- package/src/ralph-instance-manager.js +171 -0
- package/src/worktree-manager.js +170 -0
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Ralph Wiggum - Long-running AI agent loop
|
|
3
|
+
# Usage: ./ralph.sh [max_iterations]
|
|
4
|
+
|
|
5
|
+
set -e
|
|
6
|
+
|
|
7
|
+
MAX_ITERATIONS=${1:-10}
|
|
8
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
9
|
+
PRD_FILE="$SCRIPT_DIR/prd.json"
|
|
10
|
+
PROGRESS_FILE="$SCRIPT_DIR/progress.txt"
|
|
11
|
+
ARCHIVE_DIR="$SCRIPT_DIR/archive"
|
|
12
|
+
LAST_BRANCH_FILE="$SCRIPT_DIR/.last-branch"
|
|
13
|
+
|
|
14
|
+
# Worktree configuration - auto-detect git repository
|
|
15
|
+
# Navigate up from instance directory to find git root
|
|
16
|
+
CURRENT_DIR="$SCRIPT_DIR"
|
|
17
|
+
while [ "$CURRENT_DIR" != "/" ]; do
|
|
18
|
+
if [ -d "$CURRENT_DIR/.git" ] || git -C "$CURRENT_DIR" rev-parse --git-dir >/dev/null 2>&1; then
|
|
19
|
+
MAIN_REPO_PATH="$(cd "$CURRENT_DIR" && git rev-parse --show-toplevel)"
|
|
20
|
+
break
|
|
21
|
+
fi
|
|
22
|
+
CURRENT_DIR="$(dirname "$CURRENT_DIR")"
|
|
23
|
+
done
|
|
24
|
+
|
|
25
|
+
if [ -z "$MAIN_REPO_PATH" ]; then
|
|
26
|
+
echo "Error: Could not find git repository. Ralph instances must be within a git repository."
|
|
27
|
+
exit 1
|
|
28
|
+
fi
|
|
29
|
+
|
|
30
|
+
# Derive worktree parent directory (sibling to repo)
|
|
31
|
+
REPO_NAME="$(basename "$MAIN_REPO_PATH")"
|
|
32
|
+
REPO_PARENT="$(dirname "$MAIN_REPO_PATH")"
|
|
33
|
+
WORKTREE_PARENT="$REPO_PARENT/${REPO_NAME}-worktrees"
|
|
34
|
+
WORKTREE_PATH_FILE="$SCRIPT_DIR/.worktree-path"
|
|
35
|
+
|
|
36
|
+
# Setup worktree for the branch specified in prd.json
|
|
37
|
+
setup_worktree() {
|
|
38
|
+
local branch_name="$1"
|
|
39
|
+
|
|
40
|
+
if [ -z "$branch_name" ]; then
|
|
41
|
+
echo "Error: No branch name provided to setup_worktree"
|
|
42
|
+
return 1
|
|
43
|
+
fi
|
|
44
|
+
|
|
45
|
+
# If RALPH_WORKTREE_PATH is set and valid, use it
|
|
46
|
+
if [ -n "$RALPH_WORKTREE_PATH" ] && [ -d "$RALPH_WORKTREE_PATH" ]; then
|
|
47
|
+
if git -C "$RALPH_WORKTREE_PATH" rev-parse --git-dir >/dev/null 2>&1; then
|
|
48
|
+
echo "✓ Using provided worktree: $RALPH_WORKTREE_PATH"
|
|
49
|
+
echo "$RALPH_WORKTREE_PATH" > "$WORKTREE_PATH_FILE"
|
|
50
|
+
export RALPH_WORKTREE_PATH
|
|
51
|
+
return 0
|
|
52
|
+
fi
|
|
53
|
+
fi
|
|
54
|
+
|
|
55
|
+
# Fall back to calculating worktree path (existing logic)
|
|
56
|
+
# Derive worktree path (strip ralph/ prefix if present)
|
|
57
|
+
local worktree_name=$(echo "$branch_name" | sed 's|^ralph/||')
|
|
58
|
+
local worktree_path="$WORKTREE_PARENT/$worktree_name"
|
|
59
|
+
|
|
60
|
+
# Create parent directory if it doesn't exist
|
|
61
|
+
mkdir -p "$WORKTREE_PARENT"
|
|
62
|
+
|
|
63
|
+
# Check if worktree already exists
|
|
64
|
+
if [ -d "$worktree_path" ]; then
|
|
65
|
+
# Verify it's a valid git worktree
|
|
66
|
+
if git -C "$worktree_path" rev-parse --git-dir >/dev/null 2>&1; then
|
|
67
|
+
echo "✓ Using existing worktree: $worktree_path"
|
|
68
|
+
echo "$worktree_path" > "$WORKTREE_PATH_FILE"
|
|
69
|
+
return 0
|
|
70
|
+
else
|
|
71
|
+
echo "⚠ Invalid worktree found, removing: $worktree_path"
|
|
72
|
+
rm -rf "$worktree_path"
|
|
73
|
+
fi
|
|
74
|
+
fi
|
|
75
|
+
|
|
76
|
+
# Create new worktree
|
|
77
|
+
echo "Creating worktree for branch: $branch_name"
|
|
78
|
+
cd "$MAIN_REPO_PATH"
|
|
79
|
+
|
|
80
|
+
# Check if branch exists
|
|
81
|
+
if git show-ref --verify --quiet "refs/heads/$branch_name"; then
|
|
82
|
+
# Branch exists, checkout to worktree
|
|
83
|
+
git worktree add "$worktree_path" "$branch_name"
|
|
84
|
+
else
|
|
85
|
+
# Branch doesn't exist, create from main
|
|
86
|
+
git worktree add "$worktree_path" -b "$branch_name" main
|
|
87
|
+
fi
|
|
88
|
+
|
|
89
|
+
if [ $? -eq 0 ]; then
|
|
90
|
+
echo "✓ Worktree created: $worktree_path"
|
|
91
|
+
echo "$worktree_path" > "$WORKTREE_PATH_FILE"
|
|
92
|
+
return 0
|
|
93
|
+
else
|
|
94
|
+
echo "✗ Failed to create worktree"
|
|
95
|
+
return 1
|
|
96
|
+
fi
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
# Archive previous run if branch changed
|
|
100
|
+
if [ -f "$PRD_FILE" ] && [ -f "$LAST_BRANCH_FILE" ]; then
|
|
101
|
+
CURRENT_BRANCH=$(jq -r '.branchName // empty' "$PRD_FILE" 2>/dev/null || echo "")
|
|
102
|
+
LAST_BRANCH=$(cat "$LAST_BRANCH_FILE" 2>/dev/null || echo "")
|
|
103
|
+
|
|
104
|
+
if [ -n "$CURRENT_BRANCH" ] && [ -n "$LAST_BRANCH" ] && [ "$CURRENT_BRANCH" != "$LAST_BRANCH" ]; then
|
|
105
|
+
# Archive the previous run
|
|
106
|
+
DATE=$(date +%Y-%m-%d)
|
|
107
|
+
# Strip "ralph/" prefix from branch name for folder
|
|
108
|
+
FOLDER_NAME=$(echo "$LAST_BRANCH" | sed 's|^ralph/||')
|
|
109
|
+
ARCHIVE_FOLDER="$ARCHIVE_DIR/$DATE-$FOLDER_NAME"
|
|
110
|
+
|
|
111
|
+
echo "Archiving previous run: $LAST_BRANCH"
|
|
112
|
+
mkdir -p "$ARCHIVE_FOLDER"
|
|
113
|
+
[ -f "$PRD_FILE" ] && cp "$PRD_FILE" "$ARCHIVE_FOLDER/"
|
|
114
|
+
[ -f "$PROGRESS_FILE" ] && cp "$PROGRESS_FILE" "$ARCHIVE_FOLDER/"
|
|
115
|
+
echo " Archived to: $ARCHIVE_FOLDER"
|
|
116
|
+
|
|
117
|
+
# Reset progress file for new run
|
|
118
|
+
echo "# Ralph Progress Log" > "$PROGRESS_FILE"
|
|
119
|
+
echo "Started: $(date)" >> "$PROGRESS_FILE"
|
|
120
|
+
echo "---" >> "$PROGRESS_FILE"
|
|
121
|
+
fi
|
|
122
|
+
fi
|
|
123
|
+
|
|
124
|
+
# Track current branch
|
|
125
|
+
if [ -f "$PRD_FILE" ]; then
|
|
126
|
+
CURRENT_BRANCH=$(jq -r '.branchName // empty' "$PRD_FILE" 2>/dev/null || echo "")
|
|
127
|
+
if [ -n "$CURRENT_BRANCH" ]; then
|
|
128
|
+
echo "$CURRENT_BRANCH" > "$LAST_BRANCH_FILE"
|
|
129
|
+
fi
|
|
130
|
+
fi
|
|
131
|
+
|
|
132
|
+
# Initialize progress file if it doesn't exist
|
|
133
|
+
if [ ! -f "$PROGRESS_FILE" ]; then
|
|
134
|
+
echo "# Ralph Progress Log" > "$PROGRESS_FILE"
|
|
135
|
+
echo "Started: $(date)" >> "$PROGRESS_FILE"
|
|
136
|
+
echo "---" >> "$PROGRESS_FILE"
|
|
137
|
+
fi
|
|
138
|
+
|
|
139
|
+
# Setup worktree before starting iterations
|
|
140
|
+
if [ -f "$PRD_FILE" ]; then
|
|
141
|
+
BRANCH_NAME=$(jq -r '.branchName // empty' "$PRD_FILE" 2>/dev/null || echo "")
|
|
142
|
+
if [ -n "$BRANCH_NAME" ]; then
|
|
143
|
+
setup_worktree "$BRANCH_NAME"
|
|
144
|
+
if [ $? -ne 0 ]; then
|
|
145
|
+
echo "Failed to setup worktree"
|
|
146
|
+
exit 1
|
|
147
|
+
fi
|
|
148
|
+
else
|
|
149
|
+
echo "Error: No branchName found in prd.json"
|
|
150
|
+
exit 1
|
|
151
|
+
fi
|
|
152
|
+
else
|
|
153
|
+
echo "Error: prd.json not found at $PRD_FILE"
|
|
154
|
+
exit 1
|
|
155
|
+
fi
|
|
156
|
+
|
|
157
|
+
# Export environment variables for Claude
|
|
158
|
+
WORKTREE_PATH=$(cat "$WORKTREE_PATH_FILE" 2>/dev/null || echo "")
|
|
159
|
+
if [ -z "$WORKTREE_PATH" ]; then
|
|
160
|
+
echo "Error: Worktree path not found"
|
|
161
|
+
exit 1
|
|
162
|
+
fi
|
|
163
|
+
|
|
164
|
+
export RALPH_WORKTREE_PATH="$WORKTREE_PATH"
|
|
165
|
+
export RALPH_INSTANCE_DIR="$SCRIPT_DIR"
|
|
166
|
+
export RALPH_MAIN_REPO="$MAIN_REPO_PATH"
|
|
167
|
+
|
|
168
|
+
echo "Starting Ralph - Max iterations: $MAX_ITERATIONS"
|
|
169
|
+
echo "Worktree: $RALPH_WORKTREE_PATH"
|
|
170
|
+
echo "Instance: $RALPH_INSTANCE_DIR"
|
|
171
|
+
|
|
172
|
+
for i in $(seq 1 $MAX_ITERATIONS); do
|
|
173
|
+
echo ""
|
|
174
|
+
echo "═══════════════════════════════════════════════════════"
|
|
175
|
+
echo " Ralph Iteration $i of $MAX_ITERATIONS"
|
|
176
|
+
echo "═══════════════════════════════════════════════════════"
|
|
177
|
+
echo "Started: $(date '+%H:%M:%S')"
|
|
178
|
+
echo ""
|
|
179
|
+
echo "💬 Executing Claude..."
|
|
180
|
+
echo ""
|
|
181
|
+
|
|
182
|
+
# Start tailing progress file in background to show real-time updates
|
|
183
|
+
if [ -f "$PROGRESS_FILE" ]; then
|
|
184
|
+
# Get current line count to show only new additions
|
|
185
|
+
PROGRESS_LINES_BEFORE=$(wc -l < "$PROGRESS_FILE" 2>/dev/null || echo "0")
|
|
186
|
+
tail -f "$PROGRESS_FILE" &
|
|
187
|
+
TAIL_PID=$!
|
|
188
|
+
fi
|
|
189
|
+
|
|
190
|
+
# Run claude with the ralph prompt from the instance directory
|
|
191
|
+
# Use --continue for iterations after the first to maintain context
|
|
192
|
+
# Use stdbuf to unbuffer output for real-time visibility
|
|
193
|
+
if [ "$i" -eq 1 ]; then
|
|
194
|
+
OUTPUT=$(cd "$SCRIPT_DIR" && cat prompt.md | stdbuf -oL -eL claude --dangerously-skip-permissions 2>&1 | tee /dev/stderr) || true
|
|
195
|
+
else
|
|
196
|
+
OUTPUT=$(cd "$SCRIPT_DIR" && cat prompt.md | stdbuf -oL -eL claude --dangerously-skip-permissions --continue 2>&1 | tee /dev/stderr) || true
|
|
197
|
+
fi
|
|
198
|
+
|
|
199
|
+
# Stop tailing progress file
|
|
200
|
+
if [ -n "$TAIL_PID" ]; then
|
|
201
|
+
kill $TAIL_PID 2>/dev/null || true
|
|
202
|
+
wait $TAIL_PID 2>/dev/null || true
|
|
203
|
+
fi
|
|
204
|
+
|
|
205
|
+
# Check for completion signal
|
|
206
|
+
if echo "$OUTPUT" | grep -q "<promise>COMPLETE</promise>"; then
|
|
207
|
+
echo ""
|
|
208
|
+
echo "✅ Ralph completed all tasks!"
|
|
209
|
+
echo "Completed at iteration $i of $MAX_ITERATIONS ($(date '+%H:%M:%S'))"
|
|
210
|
+
exit 0
|
|
211
|
+
fi
|
|
212
|
+
|
|
213
|
+
echo ""
|
|
214
|
+
echo "✓ Iteration $i complete at $(date '+%H:%M:%S')"
|
|
215
|
+
|
|
216
|
+
# Show progress summary from this iteration
|
|
217
|
+
if [ -f "$PROGRESS_FILE" ]; then
|
|
218
|
+
PROGRESS_LINES_AFTER=$(wc -l < "$PROGRESS_FILE" 2>/dev/null || echo "0")
|
|
219
|
+
NEW_LINES=$((PROGRESS_LINES_AFTER - PROGRESS_LINES_BEFORE))
|
|
220
|
+
if [ "$NEW_LINES" -gt 0 ]; then
|
|
221
|
+
echo ""
|
|
222
|
+
echo "📝 Progress update ($NEW_LINES new lines in $PROGRESS_FILE):"
|
|
223
|
+
echo "---"
|
|
224
|
+
tail -n "$NEW_LINES" "$PROGRESS_FILE" | head -20
|
|
225
|
+
if [ "$NEW_LINES" -gt 20 ]; then
|
|
226
|
+
echo "... (showing last 20 of $NEW_LINES new lines)"
|
|
227
|
+
fi
|
|
228
|
+
echo "---"
|
|
229
|
+
fi
|
|
230
|
+
fi
|
|
231
|
+
|
|
232
|
+
echo "Continuing in 2 seconds..."
|
|
233
|
+
sleep 2
|
|
234
|
+
done
|
|
235
|
+
|
|
236
|
+
echo ""
|
|
237
|
+
echo "Ralph reached max iterations ($MAX_ITERATIONS) without completing all tasks."
|
|
238
|
+
echo "Check $PROGRESS_FILE for status."
|
|
239
|
+
exit 1
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
const fs = require('fs').promises;
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { exec } = require('child_process');
|
|
4
|
+
const { promisify } = require('util');
|
|
5
|
+
|
|
6
|
+
const execAsync = promisify(exec);
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Manages Ralph agent instances within git worktrees
|
|
10
|
+
* Creates instance directories, converts PRDs, and manages progress tracking
|
|
11
|
+
*/
|
|
12
|
+
class RalphInstanceManager {
|
|
13
|
+
/**
|
|
14
|
+
* Creates a Ralph instance directory with all required files
|
|
15
|
+
* @param {string} worktreePath - Path to the git worktree
|
|
16
|
+
* @param {string} prompt - The PRD or feature description in markdown
|
|
17
|
+
* @param {string} jobId - Unique job identifier
|
|
18
|
+
* @returns {Promise<string>} Path to the created instance directory
|
|
19
|
+
*/
|
|
20
|
+
async createInstance(worktreePath, prompt, jobId) {
|
|
21
|
+
// Create instance directory inside the worktree
|
|
22
|
+
const instancePath = path.join(worktreePath, 'ralph-instance');
|
|
23
|
+
await fs.mkdir(instancePath, { recursive: true });
|
|
24
|
+
|
|
25
|
+
// Copy bundled ralph.sh and prompt.md to instance
|
|
26
|
+
const bundledRalphDir = path.join(__dirname, 'ralph');
|
|
27
|
+
const ralphShPath = path.join(bundledRalphDir, 'ralph.sh');
|
|
28
|
+
const promptMdPath = path.join(bundledRalphDir, 'prompt.md');
|
|
29
|
+
|
|
30
|
+
await fs.copyFile(ralphShPath, path.join(instancePath, 'ralph.sh'));
|
|
31
|
+
await fs.copyFile(promptMdPath, path.join(instancePath, 'prompt.md'));
|
|
32
|
+
|
|
33
|
+
// Make ralph.sh executable
|
|
34
|
+
await fs.chmod(path.join(instancePath, 'ralph.sh'), 0o755);
|
|
35
|
+
|
|
36
|
+
// Convert prompt to prd.json
|
|
37
|
+
await this.convertPrdToJson(instancePath, prompt);
|
|
38
|
+
|
|
39
|
+
// Initialize progress.txt
|
|
40
|
+
const progressContent = `# Ralph Progress Log - Job ${jobId}
|
|
41
|
+
Started: ${new Date().toISOString()}
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
`;
|
|
45
|
+
await fs.writeFile(path.join(instancePath, 'progress.txt'), progressContent);
|
|
46
|
+
|
|
47
|
+
return instancePath;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Converts markdown PRD to prd.json using Claude /ralph skill
|
|
52
|
+
* @param {string} instancePath - Path to the Ralph instance directory
|
|
53
|
+
* @param {string} prompt - The PRD markdown content
|
|
54
|
+
* @returns {Promise<void>}
|
|
55
|
+
*/
|
|
56
|
+
async convertPrdToJson(instancePath, prompt) {
|
|
57
|
+
// Write the prompt to a temporary file
|
|
58
|
+
const promptFilePath = path.join(instancePath, 'input-prd.md');
|
|
59
|
+
await fs.writeFile(promptFilePath, prompt);
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
// Run claude /ralph to convert the PRD
|
|
63
|
+
const { stdout, stderr } = await execAsync(
|
|
64
|
+
`claude /ralph < "${promptFilePath}"`,
|
|
65
|
+
{
|
|
66
|
+
cwd: instancePath,
|
|
67
|
+
env: {
|
|
68
|
+
...process.env,
|
|
69
|
+
// Ensure Claude CLI can access the instance directory
|
|
70
|
+
PWD: instancePath
|
|
71
|
+
},
|
|
72
|
+
maxBuffer: 10 * 1024 * 1024 // 10MB buffer for large outputs
|
|
73
|
+
}
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
// Verify prd.json was created
|
|
77
|
+
const prdJsonPath = path.join(instancePath, 'prd.json');
|
|
78
|
+
try {
|
|
79
|
+
await fs.access(prdJsonPath);
|
|
80
|
+
|
|
81
|
+
// Validate prd.json structure
|
|
82
|
+
const prdContent = await fs.readFile(prdJsonPath, 'utf8');
|
|
83
|
+
const prd = JSON.parse(prdContent);
|
|
84
|
+
|
|
85
|
+
if (!prd.branchName || !prd.userStories) {
|
|
86
|
+
throw new Error('Invalid prd.json structure: missing branchName or userStories');
|
|
87
|
+
}
|
|
88
|
+
} catch (error) {
|
|
89
|
+
throw new Error(`prd.json validation failed: ${error.message}\nStdout: ${stdout}\nStderr: ${stderr}`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Clean up temporary prompt file
|
|
93
|
+
await fs.unlink(promptFilePath).catch(() => {});
|
|
94
|
+
} catch (error) {
|
|
95
|
+
throw new Error(`Failed to convert PRD to JSON: ${error.message}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Returns environment variables required for ralph.sh execution
|
|
101
|
+
* @param {string} worktreePath - Path to the git worktree
|
|
102
|
+
* @param {string} instancePath - Path to the Ralph instance directory
|
|
103
|
+
* @param {string} mainRepoPath - Path to the main repository
|
|
104
|
+
* @returns {Object} Environment variables object
|
|
105
|
+
*/
|
|
106
|
+
getEnvVars(worktreePath, instancePath, mainRepoPath) {
|
|
107
|
+
return {
|
|
108
|
+
...process.env,
|
|
109
|
+
RALPH_WORKTREE_PATH: worktreePath,
|
|
110
|
+
RALPH_INSTANCE_DIR: instancePath,
|
|
111
|
+
RALPH_MAIN_REPO: mainRepoPath
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Reads and parses the progress summary from progress.txt
|
|
117
|
+
* @param {string} instancePath - Path to the Ralph instance directory
|
|
118
|
+
* @returns {Promise<string>} Summary of progress
|
|
119
|
+
*/
|
|
120
|
+
async readProgressSummary(instancePath) {
|
|
121
|
+
const progressPath = path.join(instancePath, 'progress.txt');
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
const content = await fs.readFile(progressPath, 'utf8');
|
|
125
|
+
|
|
126
|
+
// Extract meaningful summary from progress file
|
|
127
|
+
const lines = content.split('\n');
|
|
128
|
+
const summaryLines = [];
|
|
129
|
+
|
|
130
|
+
for (const line of lines) {
|
|
131
|
+
// Skip header and empty lines
|
|
132
|
+
if (line.startsWith('#') || line.startsWith('Started:') || line === '---' || !line.trim()) {
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
summaryLines.push(line);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return summaryLines.join('\n').trim() || 'No progress recorded yet';
|
|
140
|
+
} catch (error) {
|
|
141
|
+
return `Failed to read progress: ${error.message}`;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Checks if Ralph has completed successfully by looking for completion signal
|
|
147
|
+
* @param {string} output - The output from ralph.sh execution
|
|
148
|
+
* @returns {boolean} True if completion signal found
|
|
149
|
+
*/
|
|
150
|
+
hasCompletionSignal(output) {
|
|
151
|
+
return output.includes('<promise>COMPLETE</promise>');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Extracts the branch name from prd.json
|
|
156
|
+
* @param {string} instancePath - Path to the Ralph instance directory
|
|
157
|
+
* @returns {Promise<string|null>} Branch name or null if not found
|
|
158
|
+
*/
|
|
159
|
+
async getBranchName(instancePath) {
|
|
160
|
+
try {
|
|
161
|
+
const prdJsonPath = path.join(instancePath, 'prd.json');
|
|
162
|
+
const content = await fs.readFile(prdJsonPath, 'utf8');
|
|
163
|
+
const prd = JSON.parse(content);
|
|
164
|
+
return prd.branchName || null;
|
|
165
|
+
} catch (error) {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
module.exports = RalphInstanceManager;
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
const { spawn } = require('child_process');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const fs = require('fs').promises;
|
|
4
|
+
const logger = require('./logger');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* WorktreeManager - Manages git worktrees for parallel job execution
|
|
8
|
+
*
|
|
9
|
+
* Each job gets an isolated worktree in .ralph-worktrees/job-{id}/
|
|
10
|
+
* with a unique branch: ralph/ticket-{task_id}/job-{job_id}
|
|
11
|
+
*/
|
|
12
|
+
class WorktreeManager {
|
|
13
|
+
/**
|
|
14
|
+
* Create a new worktree for a job
|
|
15
|
+
* @param {Object} job - The job object with id, task, and project
|
|
16
|
+
* @returns {Promise<string>} - The absolute path to the worktree
|
|
17
|
+
*/
|
|
18
|
+
async createWorktree(job) {
|
|
19
|
+
const worktreePath = this.getWorktreePath(job)
|
|
20
|
+
const branchName = this.getBranchName(job)
|
|
21
|
+
const systemPath = job.project.system_path
|
|
22
|
+
|
|
23
|
+
logger.info(`Creating worktree for job ${job.id}`, {
|
|
24
|
+
worktreePath,
|
|
25
|
+
branchName,
|
|
26
|
+
systemPath
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
// Verify git is available
|
|
31
|
+
await this.execGit(systemPath, ['--version'], 5000)
|
|
32
|
+
|
|
33
|
+
// Check if worktree already exists (from a previous failed run)
|
|
34
|
+
try {
|
|
35
|
+
await fs.access(worktreePath)
|
|
36
|
+
logger.warn(`Worktree already exists at ${worktreePath}, removing stale worktree`)
|
|
37
|
+
await this.removeWorktree(job)
|
|
38
|
+
} catch (err) {
|
|
39
|
+
// Worktree doesn't exist, which is expected
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Create the worktree with a new branch
|
|
43
|
+
// -b creates a new branch, --detach would checkout without branch
|
|
44
|
+
await this.execGit(
|
|
45
|
+
systemPath,
|
|
46
|
+
['worktree', 'add', '-b', branchName, worktreePath, 'HEAD'],
|
|
47
|
+
30000
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
logger.info(`Created worktree: ${worktreePath}`)
|
|
51
|
+
return worktreePath
|
|
52
|
+
} catch (error) {
|
|
53
|
+
logger.error(`Failed to create worktree for job ${job.id}`, {
|
|
54
|
+
error: error.message,
|
|
55
|
+
worktreePath,
|
|
56
|
+
branchName
|
|
57
|
+
})
|
|
58
|
+
throw new Error(`Failed to create worktree: ${error.message}`)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Remove a worktree after job completion
|
|
64
|
+
* @param {Object} job - The job object
|
|
65
|
+
*/
|
|
66
|
+
async removeWorktree(job) {
|
|
67
|
+
const worktreePath = this.getWorktreePath(job)
|
|
68
|
+
const branchName = this.getBranchName(job)
|
|
69
|
+
const systemPath = job.project.system_path
|
|
70
|
+
|
|
71
|
+
logger.info(`Removing worktree for job ${job.id}`, {
|
|
72
|
+
worktreePath,
|
|
73
|
+
branchName
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
// Remove the worktree (--force removes even if dirty)
|
|
78
|
+
await this.execGit(
|
|
79
|
+
systemPath,
|
|
80
|
+
['worktree', 'remove', worktreePath, '--force'],
|
|
81
|
+
30000
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
logger.info(`Removed worktree: ${worktreePath}`)
|
|
85
|
+
} catch (error) {
|
|
86
|
+
// Log error but don't throw - cleanup is best-effort
|
|
87
|
+
logger.error(`Failed to remove worktree for job ${job.id}`, {
|
|
88
|
+
error: error.message,
|
|
89
|
+
worktreePath
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Note: We intentionally don't delete the branch here
|
|
94
|
+
// The branch remains in the repo for history/inspection
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Get the absolute path where the worktree should be created
|
|
99
|
+
* @param {Object} job - The job object
|
|
100
|
+
* @returns {string} - Absolute path to worktree
|
|
101
|
+
*/
|
|
102
|
+
getWorktreePath(job) {
|
|
103
|
+
const systemPath = job.project.system_path
|
|
104
|
+
return path.join(systemPath, '.ralph-worktrees', `job-${job.id}`)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Get the branch name for this job
|
|
109
|
+
* @param {Object} job - The job object
|
|
110
|
+
* @returns {string} - Branch name in format ralph/ticket-{task_id}/job-{job_id}
|
|
111
|
+
*/
|
|
112
|
+
getBranchName(job) {
|
|
113
|
+
return `ralph/ticket-${job.task_id}/job-${job.id}`
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Execute a git command safely
|
|
118
|
+
* @param {string} cwd - Working directory
|
|
119
|
+
* @param {string[]} args - Git command arguments
|
|
120
|
+
* @param {number} timeout - Timeout in milliseconds (default: 30s)
|
|
121
|
+
* @returns {Promise<{stdout: string, stderr: string}>}
|
|
122
|
+
*/
|
|
123
|
+
async execGit(cwd, args, timeout = 30000) {
|
|
124
|
+
return new Promise((resolve, reject) => {
|
|
125
|
+
const process = spawn('git', args, {
|
|
126
|
+
cwd,
|
|
127
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
128
|
+
shell: false // Security: Don't use shell to prevent injection
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
let stdout = ''
|
|
132
|
+
let stderr = ''
|
|
133
|
+
let timedOut = false
|
|
134
|
+
|
|
135
|
+
const timer = setTimeout(() => {
|
|
136
|
+
timedOut = true
|
|
137
|
+
process.kill('SIGTERM')
|
|
138
|
+
reject(new Error(`Git command timed out after ${timeout}ms`))
|
|
139
|
+
}, timeout)
|
|
140
|
+
|
|
141
|
+
process.stdout.on('data', (data) => {
|
|
142
|
+
stdout += data.toString()
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
process.stderr.on('data', (data) => {
|
|
146
|
+
stderr += data.toString()
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
process.on('error', (err) => {
|
|
150
|
+
clearTimeout(timer)
|
|
151
|
+
if (!timedOut) {
|
|
152
|
+
reject(new Error(`Failed to execute git: ${err.message}`))
|
|
153
|
+
}
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
process.on('close', (code) => {
|
|
157
|
+
clearTimeout(timer)
|
|
158
|
+
if (timedOut) return // Already rejected
|
|
159
|
+
|
|
160
|
+
if (code === 0) {
|
|
161
|
+
resolve({ stdout, stderr })
|
|
162
|
+
} else {
|
|
163
|
+
reject(new Error(`Git command failed (exit code ${code}): ${stderr || stdout}`))
|
|
164
|
+
}
|
|
165
|
+
})
|
|
166
|
+
})
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
module.exports = WorktreeManager;
|