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 { exec, execSync } from "node:child_process";
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
|
|
||||||
|
// Internal git plumbing intentionally bypasses sandbox backends.
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
import { delimiter, isAbsolute, join, relative, resolve as resolvePath } from "node:path";
|
import { delimiter, isAbsolute, join, relative, resolve as resolvePath } from "node:path";
|
||||||
import { existsSync, realpathSync } from "node:fs";
|
import { existsSync, realpathSync } from "node:fs";
|
||||||
@@ -38,6 +39,8 @@ import {
|
|||||||
} from "./agent-session-helpers.js";
|
} from "./agent-session-helpers.js";
|
||||||
import { buildSessionSkillContext } from "./session-skill-context.js";
|
import { buildSessionSkillContext } from "./session-skill-context.js";
|
||||||
import { reviewStep, type ReviewVerdict } from "./reviewer.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 { ModelRegistry, SessionManager, type ToolDefinition, type AgentSession } from "@mariozechner/pi-coding-agent";
|
||||||
import { PRIORITY_EXECUTE, type AgentSemaphore } from "./concurrency.js";
|
import { PRIORITY_EXECUTE, type AgentSemaphore } from "./concurrency.js";
|
||||||
import { getRegisteredWorktreePaths, isGitRepository, isInsideWorktreesDir, isRegisteredGitWorktree, isUsableTaskWorktree, type WorktreePool } from "./worktree-pool.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";
|
return parts.length ? parts.join("\n") : "Command failed";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let configuredCommandSandboxBackend: SandboxBackend | null = null;
|
||||||
|
|
||||||
|
function getConfiguredCommandSandboxBackend(): SandboxBackend {
|
||||||
|
configuredCommandSandboxBackend ??= resolveSandboxBackend();
|
||||||
|
return configuredCommandSandboxBackend;
|
||||||
|
}
|
||||||
|
|
||||||
async function runConfiguredCommand(
|
async function runConfiguredCommand(
|
||||||
command: string,
|
command: string,
|
||||||
cwd: string,
|
cwd: string,
|
||||||
timeoutMs: number,
|
timeoutMs: number,
|
||||||
extraEnv?: NodeJS.ProcessEnv,
|
extraEnv?: NodeJS.ProcessEnv,
|
||||||
): Promise<RunCommandResult> {
|
): Promise<RunCommandResult> {
|
||||||
try {
|
const backend = getConfiguredCommandSandboxBackend();
|
||||||
const { stdout, stderr } = await execAsync(command, {
|
const result = await backend.run(command, {
|
||||||
cwd,
|
cwd,
|
||||||
timeout: timeoutMs,
|
timeoutMs,
|
||||||
maxBuffer: 10 * 1024 * 1024,
|
maxBuffer: 10 * 1024 * 1024,
|
||||||
encoding: "utf-8",
|
encoding: "utf-8",
|
||||||
...(extraEnv !== undefined && { env: extraEnv }),
|
...(extraEnv !== undefined && { env: extraEnv }),
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
stdout: stdout?.toString?.() ?? "",
|
stdout: result.stdout,
|
||||||
stderr: stderr?.toString?.() ?? "",
|
stderr: result.stderr,
|
||||||
exitCode: 0,
|
exitCode: result.exitCode,
|
||||||
signal: null,
|
signal: result.signal,
|
||||||
bufferExceeded: false,
|
bufferExceeded: result.bufferExceeded,
|
||||||
timedOut: false,
|
timedOut: result.timedOut,
|
||||||
};
|
spawnError: result.spawnError,
|
||||||
} 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 {
|
export async function __runConfiguredCommandForTests(
|
||||||
stdout: typeof (errObj?.stdout as { toString?: unknown })?.toString === "function" ? String(errObj.stdout) : "",
|
command: string,
|
||||||
stderr: typeof (errObj?.stderr as { toString?: unknown })?.toString === "function" ? String(errObj.stderr) : "",
|
cwd: string,
|
||||||
exitCode,
|
timeoutMs: number,
|
||||||
signal: (errObj?.signal as NodeJS.Signals | null | undefined) ?? null,
|
extraEnv?: NodeJS.ProcessEnv,
|
||||||
bufferExceeded:
|
): Promise<RunCommandResult> {
|
||||||
code === "ENOBUFS"
|
return runConfiguredCommand(command, cwd, timeoutMs, extraEnv);
|
||||||
|| 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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Tool parameter schemas (module-level for reuse in ToolDefinition generics) ──
|
// ── Tool parameter schemas (module-level for reuse in ToolDefinition generics) ──
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { execSync, exec, execFile } from "node:child_process";
|
import { execSync, exec, execFile } from "node:child_process";
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
|
|
||||||
|
// Internal git plumbing intentionally bypasses sandbox backends.
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
import {
|
import {
|
||||||
@@ -14,6 +15,8 @@ import {
|
|||||||
type VerificationCommandResult,
|
type VerificationCommandResult,
|
||||||
type VerificationResult,
|
type VerificationResult,
|
||||||
} from "./verification-utils.js";
|
} 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)
|
// Re-export for backward compatibility (tests import from merger.ts)
|
||||||
export {
|
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. */
|
/** Execute a script-mode post-merge workflow step in the provided execution directory. */
|
||||||
async function executePostMergeScriptStep(
|
async function executePostMergeScriptStep(
|
||||||
store: TaskStore,
|
store: TaskStore,
|
||||||
@@ -8951,25 +8961,40 @@ async function executePostMergeScriptStep(
|
|||||||
return { success: false, error: `Script '${scriptName}' not found in project settings` };
|
return { success: false, error: `Script '${scriptName}' not found in project settings` };
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const backend = getPostMergeScriptSandboxBackend();
|
||||||
await execAsync(scriptCommand, {
|
const result = await backend.run(scriptCommand, {
|
||||||
cwd,
|
cwd,
|
||||||
encoding: "utf-8",
|
encoding: "utf-8",
|
||||||
timeout: 120_000,
|
timeoutMs: 120_000,
|
||||||
maxBuffer: 10 * 1024 * 1024,
|
maxBuffer: 10 * 1024 * 1024,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (result.exitCode === 0 && !result.signal && !result.timedOut && !result.bufferExceeded && !result.spawnError) {
|
||||||
return { success: true, output: `Script '${scriptName}' completed successfully` };
|
return { success: true, output: `Script '${scriptName}' completed successfully` };
|
||||||
} catch (err: any) {
|
}
|
||||||
const stderr = err.stderr?.toString()?.trim() || "";
|
|
||||||
const stdout = err.stdout?.toString()?.trim() || "";
|
const stderr = result.stderr.trim();
|
||||||
const exitCode = err.code ?? err.status;
|
const stdout = result.stdout.trim();
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
if (exitCode !== undefined) parts.push(`Exit code: ${exitCode}`);
|
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 (stdout) parts.push(`stdout: ${truncateWorkflowScriptOutput(stdout)}`);
|
||||||
if (stderr) parts.push(`stderr: ${truncateWorkflowScriptOutput(stderr)}`);
|
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. */
|
/** 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 { CronExpressionParser } from "cron-parser";
|
||||||
import { exec } from "node:child_process";
|
|
||||||
import { isInProcessBackupCommand, isInProcessMemoryBackupCommand } from "./cron-runner.js";
|
import { isInProcessBackupCommand, isInProcessMemoryBackupCommand } from "./cron-runner.js";
|
||||||
import { promisify } from "node:util";
|
|
||||||
import type {
|
import type {
|
||||||
RoutineStore,
|
RoutineStore,
|
||||||
Routine,
|
Routine,
|
||||||
@@ -26,13 +24,21 @@ import type { HeartbeatMonitor } from "./agent-heartbeat.js";
|
|||||||
import type { AiPromptExecutor } from "./cron-runner.js";
|
import type { AiPromptExecutor } from "./cron-runner.js";
|
||||||
import { createLogger } from "./logger.js";
|
import { createLogger } from "./logger.js";
|
||||||
import { defaultShell } from "./shell-utils.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 log = createLogger("routine-runner");
|
||||||
const execAsync = promisify(exec);
|
|
||||||
const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
|
const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
|
||||||
const MAX_BUFFER = 1024 * 1024;
|
const MAX_BUFFER = 1024 * 1024;
|
||||||
const MAX_OUTPUT_LENGTH = 10 * 1024;
|
const MAX_OUTPUT_LENGTH = 10 * 1024;
|
||||||
|
|
||||||
|
let routineCommandSandboxBackend: SandboxBackend | null = null;
|
||||||
|
|
||||||
|
function getRoutineCommandSandboxBackend(): SandboxBackend {
|
||||||
|
routineCommandSandboxBackend ??= resolveSandboxBackend();
|
||||||
|
return routineCommandSandboxBackend;
|
||||||
|
}
|
||||||
|
|
||||||
/** Options for RoutineRunner constructor */
|
/** Options for RoutineRunner constructor */
|
||||||
export interface RoutineRunnerOptions {
|
export interface RoutineRunnerOptions {
|
||||||
/** RoutineStore for querying and updating routines */
|
/** RoutineStore for querying and updating routines */
|
||||||
@@ -316,34 +322,33 @@ export class RoutineRunner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const backend = getRoutineCommandSandboxBackend();
|
||||||
const { stdout, stderr } = await execAsync(command, {
|
const result = await backend.run(command, {
|
||||||
timeout: timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
cwd: this.options.rootDir,
|
||||||
maxBuffer: MAX_BUFFER,
|
timeoutMs: timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
||||||
shell: defaultShell,
|
maxBuffer: MAX_BUFFER,
|
||||||
});
|
shell: defaultShell,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.exitCode === 0 && !result.signal && !result.timedOut && !result.bufferExceeded && !result.spawnError) {
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
output: truncateOutput(stdout, stderr),
|
output: truncateOutput(result.stdout, result.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,
|
|
||||||
startedAt,
|
startedAt,
|
||||||
completedAt: new Date().toISOString(),
|
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> {
|
private async executeSteps(routine: Routine, startedAt: string): Promise<AutomationRunResult> {
|
||||||
|
|||||||
Reference in New Issue
Block a user