Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
66ccc2e
fix: prevent merge conflicts and improve parallel agent isolation
BasselBlal Jan 28, 2026
1ef4526
Update cli/src/execution/sandbox.ts
BasselBlal Jan 28, 2026
d2371db
Update cli/src/execution/sandbox.ts
BasselBlal Jan 28, 2026
516c7a0
Update cli/src/execution/sandbox.ts
BasselBlal Jan 28, 2026
6da97ea
fix
BasselBlal Jan 28, 2026
8f77556
fix
BasselBlal Jan 28, 2026
706af00
fix
BasselBlal Jan 28, 2026
da390be
fix
BasselBlal Jan 28, 2026
58cef1e
fix
BasselBlal Jan 28, 2026
92137f7
Apply suggestion from @greptile-apps[bot]
BasselBlal Jan 28, 2026
621f74e
Apply suggestions from code review
BasselBlal Jan 28, 2026
29f58d8
fix
BasselBlal Jan 28, 2026
e626465
fix
BasselBlal Jan 28, 2026
7a26a1f
fix
BasselBlal Jan 28, 2026
b19020f
fix
BasselBlal Jan 28, 2026
5e69be7
fix
BasselBlal Jan 28, 2026
b91d9ee
fix
BasselBlal Jan 28, 2026
07c9a96
fix
BasselBlal Jan 28, 2026
97dae0e
fix
BasselBlal Jan 28, 2026
0788b6c
fix
BasselBlal Jan 28, 2026
d2e161f
fix
BasselBlal Jan 28, 2026
31cbe1b
fix
BasselBlal Jan 28, 2026
0662eed
fix
BasselBlal Jan 28, 2026
80655f7
fix
BasselBlal Jan 28, 2026
0c9f68a
fix
BasselBlal Jan 28, 2026
5c0fbcf
fix
BasselBlal Jan 28, 2026
6becc2f
fix
BasselBlal Jan 28, 2026
d3fd0ca
fix
BasselBlal Jan 28, 2026
59f6aba
fix
BasselBlal Jan 28, 2026
e48db90
fix
bassel-blal Jan 28, 2026
3b24462
fix
BasselBlal Jan 28, 2026
9d47d0c
fix
BasselBlal Jan 28, 2026
6684a3d
fix
BasselBlal Jan 28, 2026
03a3812
fix
BasselBlal Jan 28, 2026
cc0d074
fix
BasselBlal Jan 28, 2026
ddfc295
Update
BasselBlal Jan 29, 2026
1a394b6
Update
BasselBlal Jan 29, 2026
89c54c0
Update
BasselBlal Jan 29, 2026
ac6b9e9
Update
BasselBlal Jan 29, 2026
f4a07c9
Update
BasselBlal Jan 29, 2026
fc2f8d6
Update
BasselBlal Jan 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions cli/src/execution/parallel.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { copyFileSync, cpSync, existsSync, mkdirSync } from "node:fs";
import { join } from "node:path";
import { basename, join } from "node:path";
import simpleGit from "simple-git";
import { PROGRESS_FILE, RALPHY_DIR } from "../config/loader.ts";
import { logTaskProgress } from "../config/writer.ts";
Expand Down Expand Up @@ -459,10 +459,24 @@ export async function runParallel(
if (!failureReason && aiResult?.success && agentUsedSandbox && worktreeDir) {
try {
const modifiedFiles = await getModifiedFiles(worktreeDir, workDir);
if (modifiedFiles.length > 0) {
const filteredFiles = modifiedFiles.filter((f) => {
const lowerF = f.toLowerCase();
Comment thread
BasselBlal marked this conversation as resolved.
Outdated
// Skip infrastructure files (.ralphy dir, progress.txt)
if (lowerF.startsWith(RALPHY_DIR.toLowerCase()) || basename(f).toLowerCase() === PROGRESS_FILE.toLowerCase()) {
Comment thread
BasselBlal marked this conversation as resolved.
Outdated
Comment thread
BasselBlal marked this conversation as resolved.
Outdated
logDebug(`Agent ${agentNum}: Filtered infrastructure file: ${f}`);
return false;
}
// Skip invalid Windows paths
if (basename(f).toLowerCase() === "nul" || f.trim() === "") {
logDebug(`Agent ${agentNum}: Filtered invalid/NUL file path: ${f}`);
return false;
}
return true;
});
Comment thread
BasselBlal marked this conversation as resolved.
Comment thread
BasselBlal marked this conversation as resolved.
Comment thread
BasselBlal marked this conversation as resolved.
if (filteredFiles.length > 0) {
const commitResult = await commitSandboxChanges(
workDir,
modifiedFiles,
filteredFiles,
worktreeDir,
task.title,
agentNum,
Expand Down
2 changes: 0 additions & 2 deletions cli/src/execution/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,8 +181,6 @@ export function buildParallelPrompt(options: ParallelPromptOptions): string {
step++;
}

instructions.push(`${step}. Update ${progressFile} with what you did`);
step++;
if (allowCommit) {
instructions.push(`${step}. Commit your changes with a descriptive message`);
} else {
Expand Down
157 changes: 148 additions & 9 deletions cli/src/execution/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,25 @@ import {
import { dirname, join, sep } from "node:path";
import { logDebug } from "../ui/logger.ts";

/**
* Simple glob matcher to avoid adding heavy dependencies.
* Supports: "*.log" (wildcard at start/end), "node_modules" (exact match), "dir/**" (prefix match)
*/
function matchesPattern(filename: string, pattern: string): boolean {
if (pattern === filename) return true;
if (pattern.endsWith("/**") && filename.startsWith(pattern.slice(0, -3))) return true;
Comment thread
BasselBlal marked this conversation as resolved.
Outdated
if (pattern.startsWith("*")) return filename.endsWith(pattern.slice(1));
if (pattern.endsWith("*")) return filename.startsWith(pattern.slice(0, -1));
return false;
Comment thread
BasselBlal marked this conversation as resolved.
Outdated
}

/**
* Check if a file should be ignored based on a list of patterns.
*/
function isIgnored(item: string, patterns: string[]): boolean {
return patterns.some((p) => matchesPattern(item, p));
}

/**
* Default directories to symlink (read-only dependencies).
* These are never modified by agents, so sharing them saves disk space.
Expand Down Expand Up @@ -69,6 +88,17 @@ export const DEFAULT_COPY_PATTERNS = [
"pyproject.toml",
];

/**
* Directories/files that should ALWAYS be ignored (neither copied nor symlinked).
*/
export const DEFAULT_IGNORED = [
".ralphy-sandboxes",
".ralphy-worktrees",
"nul",
"*.log",
"*.sqlite",
];
Comment thread
BasselBlal marked this conversation as resolved.
Comment thread
BasselBlal marked this conversation as resolved.

export interface SandboxOptions {
/** Original working directory */
originalDir: string;
Expand Down Expand Up @@ -119,8 +149,8 @@ export async function createSandbox(options: SandboxOptions): Promise<SandboxRes
mkdirSync(sandboxDir, { recursive: true });

try {
// Get all items in the original directory
const items = readdirSync(originalDir);
// Get all items in the original directory, filtering out ignored items
const items = readdirSync(originalDir).filter((item) => !isIgnored(item, DEFAULT_IGNORED));

// Track which items we've handled
const handled = new Set<string>();
Expand Down Expand Up @@ -153,7 +183,7 @@ export async function createSandbox(options: SandboxOptions): Promise<SandboxRes
if (handled.has(item)) continue;

const originalPath = join(originalDir, item);
const sandboxPath = join(sandboxDir, item);
const sandboxPathItem = join(sandboxDir, item);

// Skip if it's a symlink pointing outside (like node_modules might be)
try {
Expand All @@ -164,20 +194,34 @@ export async function createSandbox(options: SandboxOptions): Promise<SandboxRes
const target = readlinkSync(originalPath);
const resolvedTarget = join(dirname(originalPath), target);
if (existsSync(resolvedTarget)) {
symlinkSync(target, sandboxPath);
symlinkSync(target, sandboxPathItem);
symlinksCreated++;
} else {
logDebug(`Agent ${agentNum}: Skipping broken symlink ${item} -> ${target}`);
}
} else if (stat.isDirectory()) {
// Copy directory recursively, preserving timestamps for change detection
cpSync(originalPath, sandboxPath, { recursive: true, preserveTimestamps: true });
filesCopied++;
// Check if this top-level directory should be symlinked
if (symlinkDirs.includes(item)) {
symlinkSync(originalPath, sandboxPathItem, "junction");
symlinksCreated++;
Comment thread
BasselBlal marked this conversation as resolved.
Outdated
} else {
// Copy directory recursively using smart copy
const stats = copyRecursive(
originalPath,
sandboxPathItem,
DEFAULT_IGNORED,
symlinkDirs,
agentNum,
);
// Count top-level directory as 1 copy (ignoring internal file count)
filesCopied++;
symlinksCreated += stats.symlinks;
}
} else if (stat.isFile()) {
// Copy file and preserve timestamps for change detection
copyFileSync(originalPath, sandboxPath);
copyFileSync(originalPath, sandboxPathItem);
try {
utimesSync(sandboxPath, stat.atime, stat.mtime);
utimesSync(sandboxPathItem, stat.atime, stat.mtime);
} catch (utimeErr) {
logDebug(`Agent ${agentNum}: Failed to preserve timestamps for ${item}: ${utimeErr}`);
}
Expand Down Expand Up @@ -336,3 +380,98 @@ export function getSandboxBase(workDir: string): string {
}
return sandboxBase;
}

/**
* Recursively copy a directory:
* - Skips directories in 'ignoreNames'
* - Creates symlinks for directories in 'symlinkNames' (instead of recursing)
* - Copies everything else
*/
function copyRecursive(
src: string,
dest: string,
ignoreNames: string[],
symlinkNames: string[],
agentNum: number,
): { files: number; symlinks: number } {
let files = 0;
let symlinks = 0;

if (!existsSync(src)) return { files, symlinks };

if (!existsSync(dest)) {
mkdirSync(dest, { recursive: true });
}

const items = readdirSync(src);
for (const item of items) {
// 1. Skip ignored directories (e.g. .ralphy-sandboxes, *.log)
if (isIgnored(item, ignoreNames)) {
continue;
}

const srcPath = join(src, item);
const destPath = join(dest, item);

try {
const stat = lstatSync(srcPath);

if (stat.isDirectory()) {
// 2. Check if this directory should be symlinked instead of copied (e.g. node_modules)
if (symlinkNames.includes(item)) {
try {
// Create a junction/symlink to the SOURCE directory
symlinkSync(srcPath, destPath, "junction");
logDebug(`Agent ${agentNum}: Symlinked nested dir ${item}`);
symlinks++;
Comment thread
BasselBlal marked this conversation as resolved.
} catch (symlinkErr) {
// Fallback: if symlink fails, try to copy responsibly
logDebug(
`Agent ${agentNum}: Failed to symlink nested ${item}, falling back to copy: ${symlinkErr}`,
);
const subStats = copyRecursive(
srcPath,
destPath,
ignoreNames,
symlinkNames,
agentNum,
);
files += subStats.files;
symlinks += subStats.symlinks;
}
Comment thread
BasselBlal marked this conversation as resolved.
} else {
// 3. Normal directory: recurse
const subStats = copyRecursive(
srcPath,
destPath,
ignoreNames,
symlinkNames,
agentNum,
);
files++;
symlinks += subStats.symlinks;
Comment thread
BasselBlal marked this conversation as resolved.
Outdated
}
} else if (stat.isFile()) {
copyFileSync(srcPath, destPath);
try {
utimesSync(destPath, stat.atime, stat.mtime);
} catch {
// Ignore timestamp errors
}
Comment thread
BasselBlal marked this conversation as resolved.
} else if (stat.isSymbolicLink()) {
const target = readlinkSync(srcPath);
try {
// For existing symlinks, preserve them
const type = stat.isDirectory() ? "junction" : "file";
symlinkSync(target, destPath, type);
} catch {
// Ignore errors
}
}
Comment thread
BasselBlal marked this conversation as resolved.
Outdated
} catch (err) {
logDebug(`Agent ${agentNum}: Failed to copy ${item} in recursive copy: ${err}`);
}
}

return { files, symlinks };
}