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:
@@ -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) ──
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user