Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
1 change: 0 additions & 1 deletion docs/system-prompt.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,5 +62,4 @@ You are in a git worktree at ${workspacePath}
}
```


{/* END SYSTEM_PROMPT_DOCS */}
74 changes: 62 additions & 12 deletions src/node/runtime/LocalBaseRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,11 @@ import type {
} from "./Runtime";
import { RuntimeError as RuntimeErrorClass } from "./Runtime";
import { NON_INTERACTIVE_ENV_VARS } from "@/common/constants/env";
import { getBashPath } from "@/node/utils/main/bashPath";
import { getPreferredSpawnConfig } from "@/node/utils/main/bashPath";
import { EXIT_CODE_ABORTED, EXIT_CODE_TIMEOUT } from "@/common/constants/exitCodes";
import { DisposableProcess } from "@/node/utils/disposableExec";
import { expandTilde } from "./tildeExpansion";
import { log } from "@/node/services/log";
import {
checkInitHookExists,
getInitHookPath,
Expand Down Expand Up @@ -67,18 +68,39 @@ export abstract class LocalBaseRuntime implements Runtime {
);
}

// Get spawn config for the preferred bash runtime
// This handles Git for Windows, WSL, and Unix/macOS automatically
// For WSL, paths in the command and cwd are translated to /mnt/... format
const {
command: bashCommand,
args: bashArgs,
cwd: spawnCwd,
} = getPreferredSpawnConfig(command, cwd);

// Debug logging for Windows WSL issues (skip noisy git status commands)
const isGitStatusCmd = command.includes("git status") || command.includes("show-branch") || command.includes("PRIMARY_BRANCH");
if (!isGitStatusCmd) {
log.info(`[LocalBaseRuntime.exec] Original command: ${command.substring(0, 100)}${command.length > 100 ? "..." : ""}`);
log.info(`[LocalBaseRuntime.exec] Spawn command: ${bashCommand}`);
log.info(`[LocalBaseRuntime.exec] Spawn args: ${JSON.stringify(bashArgs).substring(0, 200)}...`);
}

// If niceness is specified on Unix/Linux, spawn nice directly to avoid escaping issues
// Windows doesn't have nice command, so just spawn bash directly
const isWindows = process.platform === "win32";
const bashPath = getBashPath();
const spawnCommand = options.niceness !== undefined && !isWindows ? "nice" : bashPath;
const spawnCommand = options.niceness !== undefined && !isWindows ? "nice" : bashCommand;
const spawnArgs =
options.niceness !== undefined && !isWindows
? ["-n", options.niceness.toString(), bashPath, "-c", command]
: ["-c", command];
? ["-n", options.niceness.toString(), bashCommand, ...bashArgs]
: bashArgs;

// On Windows with PowerShell wrapper, detached:true creates a separate console
// which interferes with output capture. Only use detached on non-Windows.
// On Windows, PowerShell's -WindowStyle Hidden handles console hiding.
const useDetached = !isWindows;

const childProcess = spawn(spawnCommand, spawnArgs, {
cwd,
cwd: spawnCwd,
env: {
...process.env,
...(options.env ?? {}),
Expand All @@ -90,7 +112,8 @@ export abstract class LocalBaseRuntime implements Runtime {
// the entire process group (including all backgrounded children) via process.kill(-pid).
// NOTE: detached:true does NOT cause bash to wait for background jobs when using 'exit' event
// instead of 'close' event. The 'exit' event fires when bash exits, ignoring background children.
detached: true,
// WINDOWS NOTE: detached:true causes issues with PowerShell wrapper output capture.
detached: useDetached,
// Prevent console window from appearing on Windows (WSL bash spawns steal focus otherwise)
windowsHide: true,
});
Expand All @@ -110,6 +133,18 @@ export abstract class LocalBaseRuntime implements Runtime {
let timedOut = false;
let aborted = false;

// Debug: log raw stdout/stderr from the child process (only for non-git-status commands)
let debugStdout = "";
let debugStderr = "";
if (!isGitStatusCmd) {
childProcess.stdout?.on("data", (chunk: Buffer) => {
debugStdout += chunk.toString();
});
childProcess.stderr?.on("data", (chunk: Buffer) => {
debugStderr += chunk.toString();
});
}

// Create promises for exit code and duration
// Uses special exit codes (EXIT_CODE_ABORTED, EXIT_CODE_TIMEOUT) for expected error conditions
const exitCode = new Promise<number>((resolve, reject) => {
Expand All @@ -118,6 +153,14 @@ export abstract class LocalBaseRuntime implements Runtime {
// which causes hangs when users spawn background processes like servers.
// The 'exit' event fires when the main bash process exits, which is what we want.
childProcess.on("exit", (code) => {
if (!isGitStatusCmd) {
log.info(`[LocalBaseRuntime.exec] Process exited with code: ${code}`);
log.info(`[LocalBaseRuntime.exec] stdout length: ${debugStdout.length}`);
log.info(`[LocalBaseRuntime.exec] stdout: ${debugStdout.substring(0, 500)}${debugStdout.length > 500 ? "..." : ""}`);
if (debugStderr) {
log.info(`[LocalBaseRuntime.exec] stderr: ${debugStderr.substring(0, 500)}${debugStderr.length > 500 ? "..." : ""}`);
}
}
// Clean up any background processes (process group cleanup)
// This prevents zombie processes when scripts spawn background tasks
if (childProcess.pid !== undefined) {
Expand Down Expand Up @@ -367,9 +410,16 @@ export abstract class LocalBaseRuntime implements Runtime {
const loggers = createLineBufferedLoggers(initLogger);

return new Promise<void>((resolve) => {
const bashPath = getBashPath();
const proc = spawn(bashPath, ["-c", `"${hookPath}"`], {
cwd: workspacePath,
// Get spawn config for the preferred bash runtime
// For WSL, the hook path and cwd are translated to /mnt/... format
const {
command: bashCommand,
args: bashArgs,
cwd: spawnCwd,
} = getPreferredSpawnConfig(`"${hookPath}"`, workspacePath);

const proc = spawn(bashCommand, bashArgs, {
cwd: spawnCwd,
stdio: ["ignore", "pipe", "pipe"],
env: {
...process.env,
Expand All @@ -379,11 +429,11 @@ export abstract class LocalBaseRuntime implements Runtime {
windowsHide: true,
});

proc.stdout.on("data", (data: Buffer) => {
proc.stdout?.on("data", (data: Buffer) => {
loggers.stdout.append(data.toString());
});

proc.stderr.on("data", (data: Buffer) => {
proc.stderr?.on("data", (data: Buffer) => {
loggers.stderr.append(data.toString());
});

Expand Down
20 changes: 14 additions & 6 deletions src/node/services/bashExecutionService.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { spawn } from "child_process";
import type { ChildProcess } from "child_process";
import { log } from "./log";
import { getBashPath } from "@/node/utils/main/bashPath";
import { getPreferredSpawnConfig } from "@/node/utils/main/bashPath";

/**
* Configuration for bash execution
Expand Down Expand Up @@ -121,17 +121,25 @@ export class BashExecutionService {
`BashExecutionService: Script: ${script.substring(0, 100)}${script.length > 100 ? "..." : ""}`
);

// Get spawn config for the preferred bash runtime
// This handles Git for Windows, WSL, and Unix/macOS automatically
// For WSL, paths in the script and cwd are translated to /mnt/... format
const {
command: bashCommand,
args: bashArgs,
cwd: spawnCwd,
} = getPreferredSpawnConfig(script, config.cwd);

// Windows doesn't have nice command, so just spawn bash directly
const isWindows = process.platform === "win32";
const bashPath = getBashPath();
const spawnCommand = config.niceness !== undefined && !isWindows ? "nice" : bashPath;
const spawnCommand = config.niceness !== undefined && !isWindows ? "nice" : bashCommand;
const spawnArgs =
config.niceness !== undefined && !isWindows
? ["-n", config.niceness.toString(), bashPath, "-c", script]
: ["-c", script];
? ["-n", config.niceness.toString(), bashCommand, ...bashArgs]
: bashArgs;

const child = spawn(spawnCommand, spawnArgs, {
cwd: config.cwd,
cwd: spawnCwd,
env: this.createBashEnvironment(config.secrets),
stdio: ["ignore", "pipe", "pipe"],
// Spawn as detached process group leader to prevent zombie processes
Expand Down
33 changes: 31 additions & 2 deletions src/node/utils/disposableExec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { exec } from "child_process";
import { spawn } from "child_process";
import type { ChildProcess } from "child_process";
import { getPreferredSpawnConfig } from "@/node/utils/main/bashPath";
import { log } from "@/node/services/log";

/**
* Disposable wrapper for child processes that ensures immediate cleanup.
Expand Down Expand Up @@ -117,12 +119,32 @@ class DisposableExec implements Disposable {
* Execute command with automatic cleanup via `using` declaration.
* Prevents zombie processes by ensuring child is reaped even on error.
*
* Commands are always wrapped in `bash -c` for consistent behavior across platforms.
* On Windows, this uses the detected bash runtime (Git for Windows or WSL).
* For WSL, Windows paths in the command are automatically translated.
*
* @example
* using proc = execAsync("git status");
* const { stdout } = await proc.result;
*/
export function execAsync(command: string): DisposableExec {
const child = exec(command);
// Wrap command in bash -c for consistent cross-platform behavior
// For WSL, this also translates Windows paths to /mnt/... format
const { command: bashCmd, args } = getPreferredSpawnConfig(command);

// Debug logging for Windows WSL issues
log.info(`[execAsync] Original command: ${command}`);
log.info(`[execAsync] Spawn command: ${bashCmd}`);
log.info(`[execAsync] Spawn args: ${JSON.stringify(args)}`);

const child = spawn(bashCmd, args, {
stdio: ["ignore", "pipe", "pipe"],
// Prevent console window from appearing on Windows
windowsHide: true,
});

log.info(`[execAsync] Spawned process PID: ${child.pid}`);

const promise = new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
let stdout = "";
let stderr = "";
Expand All @@ -141,9 +163,16 @@ export function execAsync(command: string): DisposableExec {
child.on("exit", (code, signal) => {
exitCode = code;
exitSignal = signal;
log.info(`[execAsync] Process exited with code: ${code}, signal: ${signal}`);
});

child.on("close", () => {
log.info(`[execAsync] Process closed. stdout length: ${stdout.length}, stderr length: ${stderr.length}`);
log.info(`[execAsync] stdout: ${stdout.substring(0, 500)}${stdout.length > 500 ? "..." : ""}`);
if (stderr) {
log.info(`[execAsync] stderr: ${stderr.substring(0, 500)}${stderr.length > 500 ? "..." : ""}`);
}

// Only resolve if process exited cleanly (code 0, no signal)
if (exitCode === 0 && exitSignal === null) {
resolve({ stdout, stderr });
Expand Down
Loading
Loading