feat(FN-4636): complete Step 4 — wire command runners through sandbox backend

Fusion-Task-Id: FN-4636
Fusion-Task-Lineage: 38ff2f48-4cb1-42c2-8f64-eb3b8d3d7f2c
This commit is contained in:
Fusion
2026-05-15 10:09:55 -07:00
committed by gsxdsm
parent fcaa0d3b8f
commit 3c595ce223
3 changed files with 104 additions and 76 deletions

View File

@@ -1,6 +1,7 @@
import { exec, execSync } from "node:child_process";
import { promisify } from "node:util";
// Internal git plumbing intentionally bypasses sandbox backends.
const execAsync = promisify(exec);
import { delimiter, isAbsolute, join, relative, resolve as resolvePath } from "node:path";
import { existsSync, realpathSync } from "node:fs";
@@ -38,6 +39,8 @@ import {
} from "./agent-session-helpers.js";
import { buildSessionSkillContext } from "./session-skill-context.js";
import { reviewStep, type ReviewVerdict } from "./reviewer.js";
import { resolveSandboxBackend } from "./sandbox/index.js";
import type { SandboxBackend } from "./sandbox/types.js";
import { ModelRegistry, SessionManager, type ToolDefinition, type AgentSession } from "@mariozechner/pi-coding-agent";
import { PRIORITY_EXECUTE, type AgentSemaphore } from "./concurrency.js";
import { getRegisteredWorktreePaths, isGitRepository, isInsideWorktreesDir, isRegisteredGitWorktree, isUsableTaskWorktree, type WorktreePool } from "./worktree-pool.js";
@@ -362,51 +365,46 @@ function configuredCommandErrorMessage(result: RunCommandResult): string {
return parts.length ? parts.join("\n") : "Command failed";
}
let configuredCommandSandboxBackend: SandboxBackend | null = null;
function getConfiguredCommandSandboxBackend(): SandboxBackend {
configuredCommandSandboxBackend ??= resolveSandboxBackend();
return configuredCommandSandboxBackend;
}
async function runConfiguredCommand(
command: string,
cwd: string,
timeoutMs: number,
extraEnv?: NodeJS.ProcessEnv,
): Promise<RunCommandResult> {
try {
const { stdout, stderr } = await execAsync(command, {
cwd,
timeout: timeoutMs,
maxBuffer: 10 * 1024 * 1024,
encoding: "utf-8",
...(extraEnv !== undefined && { env: extraEnv }),
});
const backend = getConfiguredCommandSandboxBackend();
const result = await backend.run(command, {
cwd,
timeoutMs,
maxBuffer: 10 * 1024 * 1024,
encoding: "utf-8",
...(extraEnv !== undefined && { env: extraEnv }),
});
return {
stdout: stdout?.toString?.() ?? "",
stderr: stderr?.toString?.() ?? "",
exitCode: 0,
signal: null,
bufferExceeded: false,
timedOut: false,
};
} catch (error) {
const errObj = error as Record<string, unknown>;
const code = errObj?.code;
const status = typeof errObj?.status === "number" ? errObj.status : null;
const exitCode = typeof code === "number" ? code : status;
const message = String(errObj?.message ?? "");
return {
stdout: result.stdout,
stderr: result.stderr,
exitCode: result.exitCode,
signal: result.signal,
bufferExceeded: result.bufferExceeded,
timedOut: result.timedOut,
spawnError: result.spawnError,
};
}
return {
stdout: typeof (errObj?.stdout as { toString?: unknown })?.toString === "function" ? String(errObj.stdout) : "",
stderr: typeof (errObj?.stderr as { toString?: unknown })?.toString === "function" ? String(errObj.stderr) : "",
exitCode,
signal: (errObj?.signal as NodeJS.Signals | null | undefined) ?? null,
bufferExceeded:
code === "ENOBUFS"
|| code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER"
|| message.includes("maxBuffer"),
timedOut:
code === "ETIMEDOUT"
|| (errObj?.killed === true && (errObj?.signal === "SIGTERM" || message.includes("timed out"))),
spawnError: code === "ENOENT" || code === "EACCES" ? (error as Error) : undefined,
};
}
export async function __runConfiguredCommandForTests(
command: string,
cwd: string,
timeoutMs: number,
extraEnv?: NodeJS.ProcessEnv,
): Promise<RunCommandResult> {
return runConfiguredCommand(command, cwd, timeoutMs, extraEnv);
}
// ── Tool parameter schemas (module-level for reuse in ToolDefinition generics) ──

View File

@@ -2,6 +2,7 @@
import { execSync, exec, execFile } from "node:child_process";
import { promisify } from "node:util";
// Internal git plumbing intentionally bypasses sandbox backends.
const execAsync = promisify(exec);
const execFileAsync = promisify(execFile);
import {
@@ -14,6 +15,8 @@ import {
type VerificationCommandResult,
type VerificationResult,
} from "./verification-utils.js";
import { resolveSandboxBackend } from "./sandbox/index.js";
import type { SandboxBackend } from "./sandbox/types.js";
// Re-export for backward compatibility (tests import from merger.ts)
export {
@@ -8935,6 +8938,13 @@ async function runPostMergeWorkflowSteps(
}
}
let postMergeScriptSandboxBackend: SandboxBackend | null = null;
function getPostMergeScriptSandboxBackend(): SandboxBackend {
postMergeScriptSandboxBackend ??= resolveSandboxBackend();
return postMergeScriptSandboxBackend;
}
/** Execute a script-mode post-merge workflow step in the provided execution directory. */
async function executePostMergeScriptStep(
store: TaskStore,
@@ -8951,25 +8961,40 @@ async function executePostMergeScriptStep(
return { success: false, error: `Script '${scriptName}' not found in project settings` };
}
try {
await execAsync(scriptCommand, {
cwd,
encoding: "utf-8",
timeout: 120_000,
maxBuffer: 10 * 1024 * 1024,
});
const backend = getPostMergeScriptSandboxBackend();
const result = await backend.run(scriptCommand, {
cwd,
encoding: "utf-8",
timeoutMs: 120_000,
maxBuffer: 10 * 1024 * 1024,
});
if (result.exitCode === 0 && !result.signal && !result.timedOut && !result.bufferExceeded && !result.spawnError) {
return { success: true, output: `Script '${scriptName}' completed successfully` };
} catch (err: any) {
const stderr = err.stderr?.toString()?.trim() || "";
const stdout = err.stdout?.toString()?.trim() || "";
const exitCode = err.code ?? err.status;
const parts: string[] = [];
if (exitCode !== undefined) parts.push(`Exit code: ${exitCode}`);
}
const stderr = result.stderr.trim();
const stdout = result.stdout.trim();
const parts: string[] = [];
if (result.spawnError) {
parts.push(result.spawnError.message);
} else {
if (result.exitCode !== null) parts.push(`Exit code: ${result.exitCode}`);
if (stdout) parts.push(`stdout: ${truncateWorkflowScriptOutput(stdout)}`);
if (stderr) parts.push(`stderr: ${truncateWorkflowScriptOutput(stderr)}`);
if (!parts.length) parts.push(err.message || "Unknown error");
return { success: false, error: parts.join("\n") };
}
if (!parts.length) parts.push("Unknown error");
return { success: false, error: parts.join("\n") };
}
export async function __executePostMergeScriptStepForTests(
store: TaskStore,
taskId: string,
workflowStep: WorkflowStep,
cwd: string,
settings: Settings,
): Promise<{ success: boolean; output?: string; error?: string }> {
return executePostMergeScriptStep(store, taskId, workflowStep, cwd, settings);
}
/** Execute a prompt-mode post-merge workflow step using an AI agent in the provided execution directory. */

View File

@@ -8,9 +8,7 @@
*/
import { CronExpressionParser } from "cron-parser";
import { exec } from "node:child_process";
import { isInProcessBackupCommand, isInProcessMemoryBackupCommand } from "./cron-runner.js";
import { promisify } from "node:util";
import type {
RoutineStore,
Routine,
@@ -26,13 +24,21 @@ import type { HeartbeatMonitor } from "./agent-heartbeat.js";
import type { AiPromptExecutor } from "./cron-runner.js";
import { createLogger } from "./logger.js";
import { defaultShell } from "./shell-utils.js";
import { resolveSandboxBackend } from "./sandbox/index.js";
import type { SandboxBackend } from "./sandbox/types.js";
const log = createLogger("routine-runner");
const execAsync = promisify(exec);
const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
const MAX_BUFFER = 1024 * 1024;
const MAX_OUTPUT_LENGTH = 10 * 1024;
let routineCommandSandboxBackend: SandboxBackend | null = null;
function getRoutineCommandSandboxBackend(): SandboxBackend {
routineCommandSandboxBackend ??= resolveSandboxBackend();
return routineCommandSandboxBackend;
}
/** Options for RoutineRunner constructor */
export interface RoutineRunnerOptions {
/** RoutineStore for querying and updating routines */
@@ -316,34 +322,33 @@ export class RoutineRunner {
}
}
try {
const { stdout, stderr } = await execAsync(command, {
timeout: timeoutMs ?? DEFAULT_TIMEOUT_MS,
maxBuffer: MAX_BUFFER,
shell: defaultShell,
});
const backend = getRoutineCommandSandboxBackend();
const result = await backend.run(command, {
cwd: this.options.rootDir,
timeoutMs: timeoutMs ?? DEFAULT_TIMEOUT_MS,
maxBuffer: MAX_BUFFER,
shell: defaultShell,
});
if (result.exitCode === 0 && !result.signal && !result.timedOut && !result.bufferExceeded && !result.spawnError) {
return {
success: true,
output: truncateOutput(stdout, stderr),
startedAt,
completedAt: new Date().toISOString(),
};
} catch (err) {
const errObj = err as Record<string, unknown>;
const stdout = typeof errObj.stdout === "string" ? errObj.stdout : "";
const stderr = typeof errObj.stderr === "string" ? errObj.stderr : "";
const error = errObj.killed === true
? `Command timed out after ${(timeoutMs ?? DEFAULT_TIMEOUT_MS) / 1000}s`
: (err instanceof Error ? err.message : null) ?? String(err);
return {
success: false,
output: truncateOutput(stdout, stderr),
error,
output: truncateOutput(result.stdout, result.stderr),
startedAt,
completedAt: new Date().toISOString(),
};
}
const error = result.timedOut
? `Command timed out after ${(timeoutMs ?? DEFAULT_TIMEOUT_MS) / 1000}s`
: result.spawnError?.message ?? "Command failed";
return {
success: false,
output: truncateOutput(result.stdout, result.stderr),
error,
startedAt,
completedAt: new Date().toISOString(),
};
}
private async executeSteps(routine: Routine, startedAt: string): Promise<AutomationRunResult> {