feat(FN-3345): fix lint and typecheck issues in engine verification-utils

Cleans up lint and type errors in the engine by removing unused imports from executor and merger, and replacing `any` types with proper type annotations in verification-utils.

Fusion-Task-Id: FN-3345
This commit is contained in:
Fusion
2026-05-03 20:08:24 -07:00
committed by gsxdsm
parent f455ee10ec
commit be7be0957d
4 changed files with 1150 additions and 416 deletions

View File

@@ -172,6 +172,13 @@ vi.mock("../step-session-executor.js", () => ({
vi.mock("../rate-limit-retry.js", () => ({
withRateLimitRetry: vi.fn((fn: () => Promise<unknown>) => fn()),
}));
vi.mock("../verification-utils.js", async () => {
const actual = await vi.importActual<typeof import("../verification-utils.js")>("../verification-utils.js");
return {
...actual,
runVerificationCommand: vi.fn(),
};
});
vi.mock("@mariozechner/pi-coding-agent", () => {
const mockSessionManager = {};
return {
@@ -203,6 +210,7 @@ import { SessionManager } from "@mariozechner/pi-coding-agent";
import { StepSessionExecutor } from "../step-session-executor.js";
import { executorLog } from "../logger.js";
import { withRateLimitRetry } from "../rate-limit-retry.js";
import { runVerificationCommand as mockedRunVerificationCommand } from "../verification-utils.js";
const mockedCreateFnAgent = vi.mocked(createFnAgent);
const mockedSessionManager = vi.mocked(SessionManager);
@@ -13499,3 +13507,354 @@ describe("determineRevisionResetStart", () => {
expect(determineRevisionResetStart(steps, feedback)).toBe(1);
});
});
describe("Executor verification gate (FN-3345)", () => {
const mockedVerification = vi.mocked(mockedRunVerificationCommand);
beforeEach(() => {
vi.clearAllMocks();
mockExecuteAll.mockResolvedValue([]);
mockTerminateAllSessions.mockResolvedValue(undefined);
mockCleanup.mockResolvedValue(undefined);
mockedVerification.mockReset();
});
/** Helper to create a step-session store with default settings */
function createVerificationStore(settingsOverrides: Record<string, unknown> = {}) {
const store = createMockStore();
store.getSettings.mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 15000,
groupOverlappingFiles: false,
autoMerge: false,
runStepsInNewSessions: true,
maxParallelSteps: 2,
...settingsOverrides,
});
store.getTask.mockResolvedValue({
id: "FN-3345",
title: "Verification gate test task",
description: "Test",
column: "in-progress",
dependencies: [],
steps: [
{ name: "Step 0", status: "pending" },
{ name: "Step 1", status: "pending" },
],
currentStep: 0,
log: [],
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check\n### Step 1: Implement\n- [ ] code",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
baseCommitSha: "abc123",
enabledWorkflowSteps: [],
});
return store;
}
/** Helper to create a task for step-session mode */
function createVerificationTask(overrides: Partial<Task> = {}): Task {
return {
id: "FN-3345",
title: "Verification gate test task",
description: "Test",
column: "in-progress",
dependencies: [],
steps: [
{ name: "Step 0", status: "pending" },
{ name: "Step 1", status: "pending" },
],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
};
}
it("no testCommand/buildCommand configured → gate skipped → task moves to in-review", async () => {
const store = createVerificationStore({});
mockExecuteAll.mockResolvedValue([
{ stepIndex: 0, success: true, retries: 0 },
{ stepIndex: 1, success: true, retries: 0 },
]);
const executor = new TaskExecutor(store, "/tmp/test", {});
await executor.execute(createVerificationTask());
// Verification command should NOT have been called
expect(mockedVerification).not.toHaveBeenCalled();
// Task should move to in-review normally
expect(store.moveTask).toHaveBeenCalledWith("FN-3345", "in-review");
});
it("testCommand configured, verification passes → task moves to in-review", async () => {
const store = createVerificationStore({ testCommand: "pnpm test" });
mockExecuteAll.mockResolvedValue([
{ stepIndex: 0, success: true, retries: 0 },
{ stepIndex: 1, success: true, retries: 0 },
]);
mockedVerification.mockResolvedValue({
command: "pnpm test",
exitCode: 0,
stdout: "all passed",
stderr: "",
success: true,
});
const executor = new TaskExecutor(store, "/tmp/test", {});
await executor.execute(createVerificationTask());
// Verification command should have been called
expect(mockedVerification).toHaveBeenCalledWith(
expect.anything(),
expect.stringContaining(".worktrees"),
"FN-3345",
"pnpm test",
"test",
undefined,
expect.anything(),
"executor",
);
// Task should move to in-review
expect(store.moveTask).toHaveBeenCalledWith("FN-3345", "in-review");
});
it("verification fails, fix agent succeeds on first attempt → task moves to in-review", async () => {
const store = createVerificationStore({
testCommand: "pnpm test",
verificationFixRetries: 3,
});
mockExecuteAll.mockResolvedValue([
{ stepIndex: 0, success: true, retries: 0 },
{ stepIndex: 1, success: true, retries: 0 },
]);
// First verification fails, then re-verification passes after fix
mockedVerification
.mockResolvedValueOnce({
command: "pnpm test",
exitCode: 1,
stdout: "",
stderr: "1 test failed",
success: false,
})
// Re-verification after fix passes
.mockResolvedValue({
command: "pnpm test",
exitCode: 0,
stdout: "all passed",
stderr: "",
success: true,
});
// Mock the fix agent session
mockedCreateFnAgent.mockResolvedValueOnce({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
subscribe: vi.fn(),
on: vi.fn(),
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-fix-1") },
state: {},
},
} as any);
const executor = new TaskExecutor(store, "/tmp/test", {});
await executor.execute(createVerificationTask());
// First call: initial verification (fails)
// Second call: re-verification after fix (passes)
expect(mockedVerification).toHaveBeenCalledTimes(2);
// Fix agent should have been created
expect(mockedCreateFnAgent).toHaveBeenCalled();
// Task should move to in-review
expect(store.moveTask).toHaveBeenCalledWith("FN-3345", "in-review");
});
it("verification fails, fix agent fails all attempts → task sent back to in-progress", async () => {
const store = createVerificationStore({
testCommand: "pnpm test",
verificationFixRetries: 2, // 2 fix attempts
});
mockExecuteAll.mockResolvedValue([
{ stepIndex: 0, success: true, retries: 0 },
{ stepIndex: 1, success: true, retries: 0 },
]);
// All verification calls fail
mockedVerification.mockResolvedValue({
command: "pnpm test",
exitCode: 1,
stdout: "",
stderr: "1 test failed",
success: false,
});
// Mock the fix agent session (2 attempts)
mockedCreateFnAgent.mockResolvedValue({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
subscribe: vi.fn(),
on: vi.fn(),
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-fix") },
state: {},
},
} as any);
const executor = new TaskExecutor(store, "/tmp/test", {});
await executor.execute(createVerificationTask());
// Fix agent should have been called twice (2 attempts)
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(2);
// Task should NOT move to in-review
expect(store.moveTask).not.toHaveBeenCalledWith("FN-3345", "in-review");
// Task should have been sent back for fix
expect(store.addTaskComment).toHaveBeenCalledWith(
"FN-3345",
expect.stringContaining("Deterministic verification failed"),
"agent",
);
});
it("test fails then fix succeeds → re-verification runs both test AND build", async () => {
const store = createVerificationStore({
testCommand: "pnpm test",
buildCommand: "pnpm build",
verificationFixRetries: 3,
});
mockExecuteAll.mockResolvedValue([
{ stepIndex: 0, success: true, retries: 0 },
{ stepIndex: 1, success: true, retries: 0 },
]);
// Initial verification: test fails (build is never reached because test fails first)
// Re-verification after fix: both test and build pass
mockedVerification
.mockResolvedValueOnce({
command: "pnpm test",
exitCode: 1,
stdout: "",
stderr: "1 test failed",
success: false,
})
// Re-verification: test passes
.mockResolvedValueOnce({
command: "pnpm test",
exitCode: 0,
stdout: "all passed",
stderr: "",
success: true,
})
// Re-verification: build passes
.mockResolvedValueOnce({
command: "pnpm build",
exitCode: 0,
stdout: "build ok",
stderr: "",
success: true,
});
// Mock the fix agent session
mockedCreateFnAgent.mockResolvedValueOnce({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
subscribe: vi.fn(),
on: vi.fn(),
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-fix-1") },
state: {},
},
} as any);
const executor = new TaskExecutor(store, "/tmp/test", {});
await executor.execute(createVerificationTask());
// Verification should have been called 3 times:
// 1. Initial test (fails)
// 2. Re-verification test (passes)
// 3. Re-verification build (passes)
expect(mockedVerification).toHaveBeenCalledTimes(3);
// Second call should be test
expect(mockedVerification).toHaveBeenNthCalledWith(
2,
expect.anything(),
expect.stringContaining(".worktrees"),
"FN-3345",
"pnpm test",
"test",
undefined,
expect.anything(),
"executor",
);
// Third call should be build
expect(mockedVerification).toHaveBeenNthCalledWith(
3,
expect.anything(),
expect.stringContaining(".worktrees"),
"FN-3345",
"pnpm build",
"build",
undefined,
expect.anything(),
"executor",
);
// Task should move to in-review
expect(store.moveTask).toHaveBeenCalledWith("FN-3345", "in-review");
});
it("fast mode → verification gate is skipped", async () => {
const store = createVerificationStore({
testCommand: "pnpm test",
buildCommand: "pnpm build",
});
mockExecuteAll.mockResolvedValue([
{ stepIndex: 0, success: true, retries: 0 },
{ stepIndex: 1, success: true, retries: 0 },
]);
const executor = new TaskExecutor(store, "/tmp/test", {});
await executor.execute(createVerificationTask({ executionMode: "fast" }));
// Verification command should NOT have been called (fast mode)
expect(mockedVerification).not.toHaveBeenCalled();
// Task should move to in-review normally
expect(store.moveTask).toHaveBeenCalledWith("FN-3345", "in-review");
});
it("verificationFixRetries is 0 → task sent back immediately without fix attempt", async () => {
const store = createVerificationStore({
testCommand: "pnpm test",
verificationFixRetries: 0,
});
mockExecuteAll.mockResolvedValue([
{ stepIndex: 0, success: true, retries: 0 },
{ stepIndex: 1, success: true, retries: 0 },
]);
// Verification fails
mockedVerification.mockResolvedValue({
command: "pnpm test",
exitCode: 1,
stdout: "",
stderr: "1 test failed",
success: false,
});
const executor = new TaskExecutor(store, "/tmp/test", {});
await executor.execute(createVerificationTask());
// Fix agent should NOT have been created (0 retries)
expect(mockedCreateFnAgent).not.toHaveBeenCalled();
// Task should NOT move to in-review
expect(store.moveTask).not.toHaveBeenCalledWith("FN-3345", "in-review");
// Task should have been sent back for fix
expect(store.addTaskComment).toHaveBeenCalledWith(
"FN-3345",
expect.stringContaining("Deterministic verification failed"),
"agent",
);
});
});

View File

@@ -14,6 +14,12 @@ import {
type RunCommandResult,
} from "@fusion/core";
import { findWorktreeUser } from "./merger.js";
import {
runVerificationCommand,
summarizeVerificationOutput,
VERIFICATION_LOG_MAX_CHARS,
type VerificationResult,
} from "./verification-utils.js";
import { generateWorktreeName, slugify } from "./worktree-names.js";
import { Type, type Static } from "@mariozechner/pi-ai";
import { describeModel, promptWithFallback, compactSessionContext } from "./pi.js";
@@ -2402,6 +2408,90 @@ export class TaskExecutor {
return;
}
// ── Deterministic verification gate (FN-3345) ──────────
// Run testCommand/buildCommand after all steps succeed but BEFORE
// workflow steps and the in-review transition. Skipped in fast mode
// and when no verification commands are configured.
if (executionMode !== "fast") {
if (settings.testCommand?.trim() || settings.buildCommand?.trim()) {
const verificationResult = await this.runExecutorDeterministicVerification(task, worktreePath, settings);
if (!verificationResult.allPassed) {
const failedType = verificationResult.failedCommand === "testCommand" ? "test" : "build";
const failedResult = failedType === "test" ? verificationResult.testResult! : verificationResult.buildResult!;
const failedCommand = failedResult.command;
const failureOutput = failedResult.stderr || failedResult.stdout || "Unknown error";
const summary = summarizeVerificationOutput(failureOutput, failedType);
executorLog.log(`${task.id}: [verification] ${failedType} failed — attempting fix agent`);
await this.store.logEntry(
task.id,
`[verification] ${failedType} command failed (exit ${failedResult.exitCode}). Attempting fix agent...`,
summary,
this.currentRunContext,
);
const maxFixRetries = Math.min(settings.verificationFixRetries ?? 3, 3);
if (maxFixRetries === 0) {
executorLog.log(`${task.id}: [verification] fix retries set to 0 — sending task back immediately`);
await this.sendTaskBackForFix(
task, worktreePath,
`${failedType} command \`${failedCommand}\` failed (exit ${failedResult.exitCode}):\n${summary}`,
`Verification (${failedType})`,
`Deterministic verification failed (${failedType})`,
);
return;
}
let fixSucceeded = false;
for (let attempt = 1; attempt <= maxFixRetries; attempt++) {
const fixed = await this.attemptExecutorVerificationFix(
task, worktreePath,
{
command: failedCommand,
exitCode: failedResult.exitCode,
output: failureOutput,
type: failedType,
},
settings,
attempt,
maxFixRetries,
);
if (fixed) {
fixSucceeded = true;
executorLog.log(`${task.id}: [verification] fix agent succeeded on attempt ${attempt}/${maxFixRetries}`);
await this.store.logEntry(
task.id,
`[verification] Fix agent succeeded on attempt ${attempt}/${maxFixRetries}. Verification now passing.`,
undefined,
this.currentRunContext,
);
break;
}
executorLog.log(`${task.id}: [verification] fix agent attempt ${attempt}/${maxFixRetries} failed`);
await this.store.logEntry(
task.id,
`[verification] Fix agent attempt ${attempt}/${maxFixRetries} failed`,
undefined,
this.currentRunContext,
);
}
if (!fixSucceeded) {
executorLog.log(`${task.id}: [verification] all fix attempts exhausted (${maxFixRetries}/${maxFixRetries}) — sending task back`);
await this.sendTaskBackForFix(
task, worktreePath,
`${failedType} command \`${failedCommand}\` failed (exit ${failedResult.exitCode}) after ${maxFixRetries} fix attempts:\n${summary}`,
`Verification (${failedType})`,
`Deterministic verification failed after ${maxFixRetries} fix attempts`,
);
return;
}
}
}
}
// Run workflow steps before moving to in-review — skip in fast mode
if (executionMode !== "fast") {
const workflowResult = await this.runWorkflowSteps(task, worktreePath, settings);
@@ -4208,6 +4298,246 @@ ${feedback}
*
* @returns true if a retry was scheduled, false if retries are exhausted
*/
/**
* Run deterministic verification (test + build commands) in the task's worktree.
* Returns a structured result indicating whether all commands passed.
*/
private async runExecutorDeterministicVerification(
task: Task,
worktreePath: string,
settings: Settings,
): Promise<VerificationResult> {
const testCommand = settings.testCommand?.trim();
const buildCommand = settings.buildCommand?.trim();
if (!testCommand && !buildCommand) {
executorLog.log(`${task.id}: no test/build commands configured — skipping verification`);
return { allPassed: true };
}
const parts: string[] = [];
if (testCommand) parts.push(`test: ${testCommand}`);
if (buildCommand) parts.push(`build: ${buildCommand}`);
executorLog.log(`${task.id}: [verification] running deterministic verification (${parts.join(", ")})`);
await this.store.logEntry(
task.id,
`[verification] Running deterministic verification (${parts.join(", ")})`,
undefined,
this.currentRunContext,
);
const result: VerificationResult = { allPassed: true };
// Run test command first if configured
if (testCommand) {
const testResult = await runVerificationCommand(
this.store, worktreePath, task.id, testCommand, "test", undefined, executorLog, "executor",
);
result.testResult = testResult;
if (!testResult.success) {
result.allPassed = false;
result.failedCommand = "testCommand";
executorLog.log(`${task.id}: [verification] test failed (exit ${testResult.exitCode})`);
return result;
}
}
// Run build command second if configured
if (buildCommand) {
const buildResult = await runVerificationCommand(
this.store, worktreePath, task.id, buildCommand, "build", undefined, executorLog, "executor",
);
result.buildResult = buildResult;
if (!buildResult.success) {
result.allPassed = false;
result.failedCommand = "buildCommand";
executorLog.log(`${task.id}: [verification] build failed (exit ${buildResult.exitCode})`);
return result;
}
}
executorLog.log(`${task.id}: [verification] passed`);
await this.store.logEntry(
task.id,
`[verification] Deterministic verification passed`,
undefined,
this.currentRunContext,
);
return result;
}
/**
* Attempt to fix verification failures by spawning a dedicated AI fix agent.
* Follows the pattern established by the merger's attemptInMergeVerificationFix.
* Returns true if verification passes after the fix attempt, false otherwise.
*/
private async attemptExecutorVerificationFix(
task: Task,
worktreePath: string,
failureContext: {
command: string;
exitCode: number | null;
output: string;
type: "test" | "build";
},
settings: Settings,
retryNumber: number,
maxRetries: number,
): Promise<boolean> {
try {
executorLog.log(`${task.id}: spawning executor verification fix agent (attempt ${retryNumber}/${maxRetries})`);
const logger = new AgentLogger({
store: this.store,
taskId: task.id,
agent: "executor",
persistAgentToolOutput: settings.persistAgentToolOutput,
onAgentText: this.options.onAgentText,
onAgentTool: this.options.onAgentTool,
});
// Build skill selection context
let skillContext: Awaited<ReturnType<typeof buildSessionSkillContext>> | undefined;
if (this.options.agentStore) {
try {
skillContext = await buildSessionSkillContext({
agentStore: this.options.agentStore,
task,
sessionPurpose: "executor",
projectRootDir: worktreePath,
pluginRunner: this.options.pluginRunner,
});
} catch {
// Graceful fallback - no skill selection
}
}
// Resolve model using the executor's model hierarchy
const { provider: executorProvider, modelId: executorModelId } = resolveExecutorModelPair(
task.modelProvider,
task.modelId,
settings,
);
// Create the fix agent session
const { session } = await createResolvedAgentSession({
sessionPurpose: "executor",
pluginRunner: this.options.pluginRunner,
cwd: worktreePath, // Run in the task's worktree
systemPrompt: `You are a verification fix agent running during task execution in a worktree.
All step-session steps completed successfully but the deterministic verification command failed. Your job is to fix the failing code directly in the working directory.
## Scope
Only fix what is required to make the failing verification pass.
Do not refactor, rename broadly, or make opportunistic improvements.
## Rules
1. Read the error output carefully to understand what is failing before editing anything
2. Before assuming a code fix is needed, check whether the failure is caused by stale/missing build artifacts in a sibling workspace package — typical signatures: \`Failed to resolve import "./X.js"\` pointing into another package's \`dist/\`, \`Cannot find module\`, or \`ERR_MODULE_NOT_FOUND\` referencing a workspace-internal path. In that case, rebuild the affected package(s) (e.g. \`pnpm --filter <pkg> build\`, or \`pnpm --filter "<scope>/*" build\` for a group) and re-run verification before editing source files.
3. Make targeted fixes to the failing code path
4. After fixing, run the verification command to confirm the fix works
5. Do NOT make any git commits — just fix the code
6. You MAY modify any files needed to make the verification pass, including files unrelated to this task's original change. Pre-existing build/test breakage is in scope: fix it. Prefer the smallest change that makes verification green.
7. If you cannot fix the issue within scope, explain why and what evidence indicates a deeper/root problem`,
tools: "coding",
onText: logger.onText,
onThinking: logger.onThinking,
onToolStart: logger.onToolStart,
onToolEnd: logger.onToolEnd,
defaultProvider: executorProvider,
defaultModelId: executorModelId,
defaultThinkingLevel: settings.defaultThinkingLevel,
...(skillContext?.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}),
});
await this.store.logEntry(
task.id,
`Executor verification fix agent started (model: ${describeModel(session)}, attempt ${retryNumber}/${maxRetries})`,
undefined,
this.currentRunContext,
);
await this.store.appendAgentLog(
task.id,
`Fix agent started (model: ${describeModel(session)}, attempt ${retryNumber}/${maxRetries})`,
"text",
undefined,
"executor",
);
try {
// Build the fix prompt
const fixPrompt = `Fix the failing ${failureContext.type} verification for task ${task.id}.
## Failed command
Command: \`${failureContext.command}\`
Exit code: ${failureContext.exitCode}
## Error output
${failureContext.output.slice(0, VERIFICATION_LOG_MAX_CHARS)}
## Instructions
1. Read the error output and identify the root cause
2. Make targeted fixes to resolve the failure
3. Run the verification command \`${failureContext.command}\` to confirm your fix works
4. If the fix doesn't work, try a different approach
5. Do NOT make any git commits`;
// Run the agent with rate limit retry
await withRateLimitRetry(async () => {
await promptWithFallback(session, fixPrompt);
}, {
onRetry: (attempt, delayMs, error) => {
const delaySec = Math.round(delayMs / 1000);
executorLog.warn(`${task.id} executor fix agent rate limited — retry ${attempt} in ${delaySec}s: ${error.message}`);
},
});
await accumulateSessionTokenUsage(this.store, task.id, session);
// Re-run full deterministic verification (test AND build) after the fix attempt
executorLog.log(`${task.id}: re-running deterministic verification after fix attempt ${retryNumber}/${maxRetries}`);
await this.store.logEntry(
task.id,
`Re-running deterministic verification (attempt ${retryNumber}/${maxRetries})`,
undefined,
this.currentRunContext,
);
await this.store.appendAgentLog(
task.id,
`Re-running verification (attempt ${retryNumber}/${maxRetries})`,
"text",
undefined,
"executor",
);
const reRunResult = await this.runExecutorDeterministicVerification(task, worktreePath, settings);
return reRunResult.allPassed;
} finally {
await logger.flush();
await session.dispose();
}
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : String(err);
executorLog.warn(`${task.id}: executor verification fix agent error: ${errorMessage}`);
await this.store.logEntry(
task.id,
`Executor verification fix agent encountered an error`,
errorMessage,
this.currentRunContext,
);
await this.store.appendAgentLog(
task.id,
"Fix agent encountered an error",
"tool_error",
errorMessage,
"executor",
);
return false;
}
}
private async handleWorkflowStepFailure(
task: Task,
worktreePath: string,

View File

@@ -1,136 +1,30 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { execSync, exec, spawn } from "node:child_process";
import { execSync, exec } from "node:child_process";
import { promisify } from "node:util";
const execAsync = promisify(exec);
import {
runVerificationCommand as runVerificationCommandShared,
summarizeVerificationOutput,
truncateWithEllipsis,
VERIFICATION_COMMAND_MAX_BUFFER,
VERIFICATION_LOG_MAX_CHARS,
type VerificationCommandResult,
type VerificationResult,
} from "./verification-utils.js";
/**
* Run a verification command with a wallclock timeout that reaps the whole
* process group on expiry. Node's exec timeout only kills the immediate shell;
* vitest/pnpm workers can survive and accumulate across retries. Using
* detached + negative-pid signal terminates the full tree.
*
* Resolves with stdout/stderr/exitCode mirroring exec's promisified shape.
* Rejects with an Error tagged `code: "ETIMEDOUT"` and `killed: true` on
* timeout, matching exec's contract so callers don't need to special-case it.
*/
async function execWithProcessGroup(
command: string,
options: { cwd: string; timeout: number; maxBuffer: number; signal?: AbortSignal },
): Promise<{ stdout: string; stderr: string; bufferOverflow: boolean; aborted?: boolean }> {
return new Promise((resolve, reject) => {
if (options.signal?.aborted) {
reject(Object.assign(
new Error(`Command aborted before start: ${command}`),
{ code: "ABORT_ERR", aborted: true, stdout: "", stderr: "" },
));
return;
}
// Re-export for backward compatibility (tests import from merger.ts)
export {
execWithProcessGroup,
summarizeVerificationOutput,
truncateWithEllipsis,
VERIFICATION_COMMAND_MAX_BUFFER,
VERIFICATION_COMMAND_TIMEOUT_MS,
VERIFICATION_LOG_MAX_CHARS,
type VerificationCommandResult,
type VerificationResult,
} from "./verification-utils.js";
const useProcessGroup = process.platform !== "win32";
const child = spawn(command, {
cwd: options.cwd,
shell: true,
detached: useProcessGroup,
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
let stdoutOverflow = false;
let stderrOverflow = false;
let timedOut = false;
let aborted = false;
let settled = false;
const killTree = (sig: NodeJS.Signals) => {
if (child.pid === undefined) return;
try {
if (useProcessGroup) {
process.kill(-child.pid, sig);
} else {
child.kill(sig);
}
} catch { /* group may already be gone */ }
};
const timer = setTimeout(() => {
timedOut = true;
killTree("SIGTERM");
setTimeout(() => {
if (settled) return;
killTree("SIGKILL");
}, 5_000).unref();
}, options.timeout);
timer.unref();
const onAbort = () => {
aborted = true;
killTree("SIGTERM");
setTimeout(() => {
if (settled) return;
killTree("SIGKILL");
}, 5_000).unref();
};
options.signal?.addEventListener("abort", onAbort, { once: true });
child.stdout?.on("data", (chunk: Buffer) => {
if (stdoutOverflow) return;
if (stdout.length + chunk.length > options.maxBuffer) {
stdoutOverflow = true;
stdout += chunk.toString("utf-8", 0, options.maxBuffer - stdout.length);
return;
}
stdout += chunk.toString("utf-8");
});
child.stderr?.on("data", (chunk: Buffer) => {
if (stderrOverflow) return;
if (stderr.length + chunk.length > options.maxBuffer) {
stderrOverflow = true;
stderr += chunk.toString("utf-8", 0, options.maxBuffer - stderr.length);
return;
}
stderr += chunk.toString("utf-8");
});
const finish = (err: NodeJS.ErrnoException | null, code: number | null, signal: NodeJS.Signals | null) => {
if (settled) return;
settled = true;
clearTimeout(timer);
options.signal?.removeEventListener("abort", onAbort);
if (aborted) {
reject(Object.assign(
new Error(`Command aborted: ${command}`),
{ code: "ABORT_ERR", aborted: true, stdout, stderr, killed: true },
));
return;
}
if (timedOut) {
reject(Object.assign(
new Error(`Command timed out after ${options.timeout}ms: ${command}`),
{ code: "ETIMEDOUT", stdout, stderr, killed: true },
));
return;
}
if (err) {
reject(Object.assign(err, { stdout, stderr }));
return;
}
if (code === 0) {
resolve({ stdout, stderr, bufferOverflow: stdoutOverflow || stderrOverflow });
return;
}
reject(Object.assign(
new Error(`Command failed (exit ${code ?? signal ?? "unknown"}): ${command}`),
{ code: code ?? undefined, status: code, stdout, stderr },
));
};
child.on("error", (err) => finish(err, null, null));
child.on("close", (code, signal) => finish(null, code, signal));
});
}
import { existsSync } from "node:fs";
import { join } from "node:path";
import {
@@ -216,9 +110,6 @@ const DEPENDENCY_SYNC_TRIGGER_PATTERNS = [
"packages/*/package.json",
];
const VERIFICATION_COMMAND_MAX_BUFFER = 50 * 1024 * 1024;
const VERIFICATION_COMMAND_TIMEOUT_MS = 600_000;
const VERIFICATION_LOG_MAX_CHARS = 20_000;
const WORKFLOW_SCRIPT_OUTPUT_MAX_CHARS = 4_000;
const PULL_REBASE_TIMEOUT_MS = 120_000;
const PUSH_TIMEOUT_MS = 60_000;
@@ -230,172 +121,9 @@ const MERGE_COMMIT_LOG_MAX_CHARS = 5000;
const MERGE_DIFF_STAT_MAX_CHARS = 3000;
/**
* Truncate text to maxChars with ellipsis indicator.
* Returns original text if under limit.
* @deprecated Use summarizeVerificationOutput from verification-utils.js instead
*/
function truncateWithEllipsis(text: string, maxChars: number): string {
if (text.length <= maxChars) return text;
return `${text.slice(0, maxChars)}\n... (truncated)`;
}
// Kept for potential future diagnostics use (may be helpful for detailed error analysis)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function truncateVerificationOutput(output: string): string {
if (output.length <= VERIFICATION_LOG_MAX_CHARS) return output;
return `... output truncated to last ${VERIFICATION_LOG_MAX_CHARS} characters ...\n${output.slice(-VERIFICATION_LOG_MAX_CHARS)}`;
}
/**
* Summarize test/build verification failure output into a concise message.
* Extracts test counts and failure names from common test runner formats,
* falls back to truncated output for unstructured output.
*
* @param output - The raw command output to summarize
* @param type - The verification type (reserved for future use; currently unused)
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export function summarizeVerificationOutput(output: string, type: "test" | "build"): string {
const lines = output.split("\n");
let summaryLine: string | null = null;
const failureNames = new Set<string>();
// 1. Extract summary line
for (const line of lines) {
// vitest/jest: "Tests: 2 failed, 48 passed, 50 total"
const testsMatch = line.match(/^Tests:\s*(\d+)\s+failed,\s*(\d+)\s+passed(?:,\s*(\d+)\s+total)?/i);
if (testsMatch) {
const failed = testsMatch[1];
const passed = testsMatch[2];
const total = testsMatch[3] ? `, ${testsMatch[3]} total` : "";
summaryLine = `Tests: ${failed} failed, ${passed} passed${total}`;
break;
}
// Generic: "X tests failed, Y passed, Z total"
const genericMatch = line.match(/^(\d+)\s+tests?\s+failed,\s*(\d+)\s+passed,\s*(\d+)\s+total/i);
if (genericMatch) {
summaryLine = `${genericMatch[1]} tests failed, ${genericMatch[2]} passed, ${genericMatch[3]} total`;
break;
}
// Various runners: "X failing" / "X failures" / "X failed"
const failCountMatch = line.match(/^(\d+)\s+(failings?|failures?|failed)/i);
if (failCountMatch) {
summaryLine = `${failCountMatch[1]} ${failCountMatch[2]}`;
break;
}
}
// 2. Extract failure names (up to 5 unique names)
// Priority: markers (✗, ●, -) provide descriptive names, FAIL lines provide file context
// Process markers first (they give actual test names), then FAIL lines (file context)
const markerLines: string[] = [];
const failLines: string[] = [];
for (const line of lines) {
// FAIL <file> — vitest file-level failure header (at start of line)
const failMatch = line.match(/^(FAIL)\s+(.+)/);
if (failMatch) {
failLines.push(failMatch[2].trim());
continue;
}
// Trim leading whitespace for marker detection (vitest indents failure details)
const trimmedLine = line.trimStart();
// Unicode cross markers: ✗ or ✕ or × (possibly indented)
const crossMatch = trimmedLine.match(/^[✗✕×]\s*(.+)/);
if (crossMatch) {
markerLines.push(crossMatch[1].trim());
continue;
}
// Jest failure bullet: ● (possibly indented)
const bulletMatch = trimmedLine.match(/^●\s*(.+)/);
if (bulletMatch) {
markerLines.push(bulletMatch[1].trim());
continue;
}
// Jest/Mocha indented test name: - MyTest should do something (indented)
const dashMatch = trimmedLine.match(/^-\s+(\S[\s\S]*?)$/);
if (dashMatch) {
const potential = dashMatch[1].trim();
// Only include lines that look like test names (contain common test patterns)
if (/[\s>]|(should|cannot|does|doesn|to|not|throws)/i.test(potential)) {
markerLines.push(potential);
}
continue;
}
// AssertionError — generic assertion failures (possibly indented)
const assertionMatch = trimmedLine.match(/^(AssertionError|AssertionError:.*)$/i);
if (assertionMatch) {
markerLines.push(assertionMatch[1]);
}
}
// Add marker names first (higher priority - they give actual test names)
for (const name of markerLines) {
const truncated = name.length > 120 ? name.slice(0, 120) : name;
failureNames.add(truncated);
}
// Fill remaining slots with FAIL file names (lower priority - just file context)
for (const name of failLines) {
const truncated = name.length > 120 ? name.slice(0, 120) : name;
failureNames.add(truncated);
}
// 3. Build the summary string
const footer = "(full output available in engine logs)";
const parts: string[] = [];
if (summaryLine) {
parts.push(summaryLine);
}
if (failureNames.size > 0) {
const names = Array.from(failureNames);
if (names.length <= 5) {
for (const name of names) {
parts.push(`${name}`);
}
} else {
// Show first 5 and note overflow
for (let i = 0; i < 5; i++) {
parts.push(`${names[i]}`);
}
parts.push(` • ... and ${names.length - 5} more failures`);
}
}
if (parts.length > 0) {
parts.push(footer);
return parts.join("\n");
}
// 4. Fallback — no structured data found
const trimmed = output.trim();
if (!trimmed) {
return `Verification command failed with no output\n${footer}`;
}
if (trimmed.length <= 500) {
return `${trimmed}\n${footer}`;
}
// Truncate at last space or newline boundary
let cutoff = 500;
for (let i = 500; i < trimmed.length; i++) {
if (trimmed[i] === " " || trimmed[i] === "\n") {
cutoff = i;
break;
}
}
return `${trimmed.slice(0, cutoff)}...\n${footer}`;
}
export const summarizeVerificationOutputLocal = summarizeVerificationOutput;
function truncateWorkflowScriptOutput(output: string): string {
if (output.length <= WORKFLOW_SCRIPT_OUTPUT_MAX_CHARS) return output;
@@ -598,23 +326,6 @@ export function inferDefaultTestCommand(
// ── Deterministic merge verification ──────────────────────────────────
/** Result of running a single verification command */
export interface VerificationCommandResult {
command: string;
exitCode: number | null;
stdout: string;
stderr: string;
success: boolean;
}
/** Result of running all verification commands */
export interface VerificationResult {
testResult?: VerificationCommandResult;
buildResult?: VerificationCommandResult;
allPassed: boolean;
failedCommand?: string;
}
/**
* Run verification commands deterministically in the engine.
* Executes testCommand first, then buildCommand (when both are configured).
@@ -762,110 +473,7 @@ async function runVerificationCommand(
signal?: AbortSignal,
): Promise<VerificationCommandResult> {
throwIfAborted(signal, taskId);
mergerLog.log(`${taskId}: running ${type} command: ${command}`);
await store.logEntry(taskId, `[verification] Running ${type} command: ${command}`);
await store.appendAgentLog(taskId, `Running ${type} command`, "tool", command, "merger");
const result: VerificationCommandResult = {
command,
exitCode: null,
stdout: "",
stderr: "",
success: false,
};
const verificationStartedAt = Date.now();
try {
const { stdout, stderr, bufferOverflow } = await execWithProcessGroup(command, {
cwd: rootDir,
timeout: VERIFICATION_COMMAND_TIMEOUT_MS,
maxBuffer: VERIFICATION_COMMAND_MAX_BUFFER,
signal,
});
throwIfAborted(signal, taskId);
result.stdout = stdout?.toString?.() || "";
result.stderr = stderr?.toString?.() || "";
result.exitCode = 0;
result.success = true;
const verificationDurationMs = Date.now() - verificationStartedAt;
const timingDetail = `${verificationDurationMs}ms`;
if (bufferOverflow) {
mergerLog.log(`${taskId}: ${type} command succeeded (exit 0, output exceeded buffer) in ${verificationDurationMs}ms`);
await store.logEntry(
taskId,
`[timing] [verification] ${type} command succeeded (exit 0, output exceeded buffer) in ${verificationDurationMs}ms`,
);
await store.appendAgentLog(
taskId,
`${type} command succeeded (exit 0)`,
"tool_result",
timingDetail,
"merger",
);
} else {
mergerLog.log(`${taskId}: ${type} command succeeded in ${verificationDurationMs}ms`);
await store.logEntry(taskId, `[timing] [verification] ${type} command succeeded (exit 0) in ${verificationDurationMs}ms`);
await store.appendAgentLog(
taskId,
`${type} command succeeded (exit 0)`,
"tool_result",
timingDetail,
"merger",
);
}
return result;
} catch (error: any) {
throwIfAborted(signal, taskId);
const verificationDurationMs = Date.now() - verificationStartedAt;
result.stdout = error?.stdout?.toString?.() || "";
result.stderr = error?.stderr?.toString?.() || "";
result.exitCode = typeof error?.status === "number"
? error.status
: (typeof error?.code === "number" ? error.code : null);
const maxBufferExceeded = error?.code === "ENOBUFS"
|| error?.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER"
|| String(error?.message ?? "").includes("maxBuffer");
result.success = maxBufferExceeded && result.exitCode === 0;
if (result.success) {
mergerLog.log(`${taskId}: ${type} command succeeded (exit 0, output exceeded buffer) in ${verificationDurationMs}ms`);
await store.logEntry(
taskId,
`[timing] [verification] ${type} command succeeded (exit 0, output exceeded buffer) in ${verificationDurationMs}ms`,
);
await store.appendAgentLog(
taskId,
`${type} command succeeded (exit 0)`,
"tool_result",
`${verificationDurationMs}ms`,
"merger",
);
return result;
}
// Keep command output out of process logs. The bounded excerpt is stored on
// the task for diagnostics without dumping test output to the engine stdout.
const output = result.stderr || result.stdout || error?.message || "Unknown error";
const summary = summarizeVerificationOutput(output, type);
mergerLog.error(`${taskId}: ${type} command failed (exit ${result.exitCode}) in ${verificationDurationMs}ms; output captured in task log`);
await store.logEntry(
taskId,
`[timing] [verification] ${type} command failed (exit ${result.exitCode}) after ${verificationDurationMs}ms:\n${summary}`,
);
await store.appendAgentLog(
taskId,
`${type} command failed (exit ${result.exitCode})`,
"tool_error",
summary,
"merger",
);
}
return result;
return runVerificationCommandShared(store, rootDir, taskId, command, type, signal, mergerLog, "merger");
}
/**

View File

@@ -0,0 +1,437 @@
/**
* Shared verification utilities for running deterministic test/build commands.
* Used by both the merger and executor verification gates.
*/
import { spawn } from "node:child_process";
import type { TaskStore, AgentRole } from "@fusion/core";
// ── Constants ──────────────────────────────────────────────────────────
export const VERIFICATION_COMMAND_MAX_BUFFER = 50 * 1024 * 1024;
export const VERIFICATION_COMMAND_TIMEOUT_MS = 600_000;
export const VERIFICATION_LOG_MAX_CHARS = 20_000;
// ── Types ──────────────────────────────────────────────────────────────
/** Result of running a single verification command */
export interface VerificationCommandResult {
command: string;
exitCode: number | null;
stdout: string;
stderr: string;
success: boolean;
}
/** Result of running all verification commands */
export interface VerificationResult {
testResult?: VerificationCommandResult;
buildResult?: VerificationCommandResult;
allPassed: boolean;
failedCommand?: string;
}
// ── Process group exec ─────────────────────────────────────────────────
/**
* Run a verification command with a wallclock timeout that reaps the whole
* process group on expiry. Node's exec timeout only kills the immediate shell;
* vitest/pnpm workers can survive and accumulate across retries. Using
* detached + negative-pid signal terminates the full tree.
*/
export async function execWithProcessGroup(
command: string,
options: { cwd: string; timeout: number; maxBuffer: number; signal?: AbortSignal },
): Promise<{ stdout: string; stderr: string; bufferOverflow: boolean; aborted?: boolean }> {
return new Promise((resolve, reject) => {
if (options.signal?.aborted) {
reject(Object.assign(
new Error(`Command aborted before start: ${command}`),
{ code: "ABORT_ERR", aborted: true, stdout: "", stderr: "" },
));
return;
}
const useProcessGroup = process.platform !== "win32";
const child = spawn(command, {
cwd: options.cwd,
shell: true,
detached: useProcessGroup,
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
let stdoutOverflow = false;
let stderrOverflow = false;
let timedOut = false;
let aborted = false;
let settled = false;
const killTree = (sig: NodeJS.Signals) => {
if (child.pid === undefined) return;
try {
if (useProcessGroup) {
process.kill(-child.pid, sig);
} else {
child.kill(sig);
}
} catch { /* group may already be gone */ }
};
const timer = setTimeout(() => {
timedOut = true;
killTree("SIGTERM");
setTimeout(() => {
if (settled) return;
killTree("SIGKILL");
}, 5_000).unref();
}, options.timeout);
timer.unref();
const onAbort = () => {
aborted = true;
killTree("SIGTERM");
setTimeout(() => {
if (settled) return;
killTree("SIGKILL");
}, 5_000).unref();
};
options.signal?.addEventListener("abort", onAbort, { once: true });
child.stdout?.on("data", (chunk: Buffer) => {
if (stdoutOverflow) return;
if (stdout.length + chunk.length > options.maxBuffer) {
stdoutOverflow = true;
stdout += chunk.toString("utf-8", 0, options.maxBuffer - stdout.length);
return;
}
stdout += chunk.toString("utf-8");
});
child.stderr?.on("data", (chunk: Buffer) => {
if (stderrOverflow) return;
if (stderr.length + chunk.length > options.maxBuffer) {
stderrOverflow = true;
stderr += chunk.toString("utf-8", 0, options.maxBuffer - stderr.length);
return;
}
stderr += chunk.toString("utf-8");
});
const finish = (err: NodeJS.ErrnoException | null, code: number | null, signal: NodeJS.Signals | null) => {
if (settled) return;
settled = true;
clearTimeout(timer);
options.signal?.removeEventListener("abort", onAbort);
if (aborted) {
reject(Object.assign(
new Error(`Command aborted: ${command}`),
{ code: "ABORT_ERR", aborted: true, stdout, stderr, killed: true },
));
return;
}
if (timedOut) {
reject(Object.assign(
new Error(`Command timed out after ${options.timeout}ms: ${command}`),
{ code: "ETIMEDOUT", stdout, stderr, killed: true },
));
return;
}
if (err) {
reject(Object.assign(err, { stdout, stderr }));
return;
}
if (code === 0) {
resolve({ stdout, stderr, bufferOverflow: stdoutOverflow || stderrOverflow });
return;
}
reject(Object.assign(
new Error(`Command failed (exit ${code ?? signal ?? "unknown"}): ${command}`),
{ code: code ?? undefined, status: code, stdout, stderr },
));
};
child.on("error", (err) => finish(err, null, null));
child.on("close", (code, signal) => finish(null, code, signal));
});
}
// ── Output summarization ───────────────────────────────────────────────
export function truncateWithEllipsis(text: string, maxChars: number): string {
if (text.length <= maxChars) return text;
return `${text.slice(0, maxChars)}\n... (truncated)`;
}
function truncateOutput(output: string): string {
if (output.length <= VERIFICATION_LOG_MAX_CHARS) return output;
return `... output truncated to last ${VERIFICATION_LOG_MAX_CHARS} characters ...\n${output.slice(-VERIFICATION_LOG_MAX_CHARS)}`;
}
/**
* Summarize verification command output for concise task log entries.
* Extracts test failure names and summary statistics from common test runners.
*/
export function summarizeVerificationOutput(output: string, type: "test" | "build"): string {
const lines = output.split("\n");
let summaryLine: string | null = null;
const failureNames = new Set<string>();
// 1. Extract summary line
for (const line of lines) {
// vitest/jest: "Tests: 2 failed, 48 passed, 50 total"
const testsMatch = line.match(/^Tests:\s*(\d+)\s+failed,\s*(\d+)\s+passed(?:,\s*(\d+)\s+total)?/i);
if (testsMatch) {
const failed = testsMatch[1];
const passed = testsMatch[2];
const total = testsMatch[3] ? `, ${testsMatch[3]} total` : "";
summaryLine = `Tests: ${failed} failed, ${passed} passed${total}`;
break;
}
// Generic: "X tests failed, Y passed, Z total"
const genericMatch = line.match(/^(\d+)\s+tests?\s+failed,\s*(\d+)\s+passed,\s*(\d+)\s+total/i);
if (genericMatch) {
summaryLine = `${genericMatch[1]} tests failed, ${genericMatch[2]} passed, ${genericMatch[3]} total`;
break;
}
// Various runners: "X failing" / "X failures" / "X failed"
const failCountMatch = line.match(/^(\d+)\s+(failings?|failures?|failed)/i);
if (failCountMatch) {
summaryLine = `${failCountMatch[1]} ${failCountMatch[2]}`;
break;
}
}
// 2. Extract failure names (up to 5 unique names)
const markerLines: string[] = [];
const failLines: string[] = [];
for (const line of lines) {
const failMatch = line.match(/^(FAIL)\s+(.+)/);
if (failMatch) {
failLines.push(failMatch[2].trim());
continue;
}
const trimmedLine = line.trimStart();
const crossMatch = trimmedLine.match(/^[✗✕×]\s*(.+)/);
if (crossMatch) {
markerLines.push(crossMatch[1].trim());
continue;
}
const bulletMatch = trimmedLine.match(/^●\s*(.+)/);
if (bulletMatch) {
markerLines.push(bulletMatch[1].trim());
continue;
}
const dashMatch = trimmedLine.match(/^-\s+(\S[\s\S]*?)$/);
if (dashMatch) {
const potential = dashMatch[1].trim();
if (/[\s>]|(should|cannot|does|doesn|to|not|throws)/i.test(potential)) {
markerLines.push(potential);
}
continue;
}
const assertionMatch = trimmedLine.match(/^(AssertionError|AssertionError:.*)$/i);
if (assertionMatch) {
markerLines.push(assertionMatch[1]);
}
}
for (const name of markerLines) {
const truncated = name.length > 120 ? name.slice(0, 120) : name;
failureNames.add(truncated);
}
for (const name of failLines) {
const truncated = name.length > 120 ? name.slice(0, 120) : name;
failureNames.add(truncated);
}
// 3. Build the summary string
const footer = "(full output available in engine logs)";
if (type === "build") {
const buildError = output.length > 500 ? `${output.slice(0, 500)}\n... (truncated)` : output;
return `Build output:\n${buildError}\n${footer}`;
}
const parts: string[] = [];
if (summaryLine) {
parts.push(summaryLine);
}
if (failureNames.size > 0) {
const names = Array.from(failureNames);
if (names.length <= 5) {
for (const name of names) {
parts.push(`${name}`);
}
} else {
for (let i = 0; i < 5; i++) {
parts.push(`${names[i]}`);
}
parts.push(` • ... and ${names.length - 5} more failures`);
}
}
if (parts.length === 0) {
if (output.trim().length === 0) {
return `no output\n${footer}`;
}
return `${truncateOutput(output)}\n${footer}`;
}
return parts.join("\n") + `\n${footer}`;
}
// ── Single command runner ──────────────────────────────────────────────
/**
* Run a single verification command (test or build) and return the result.
* Logs progress to the task store. Uses logger for structured output.
*/
export async function runVerificationCommand(
store: TaskStore,
rootDir: string,
taskId: string,
command: string,
type: "test" | "build",
signal: AbortSignal | undefined,
/** Optional logger — defaults to console */
log?: { log: (message: string, ...args: unknown[]) => void; error: (message: string, ...args: unknown[]) => void; warn: (message: string, ...args: unknown[]) => void },
/** Optional agent label for store log entries (e.g. "merger", "executor") */
agentLabel?: string,
): Promise<VerificationCommandResult> {
const logger = log ?? { log: console.log, error: console.error, warn: console.warn };
const label = (agentLabel ?? "merger") as AgentRole;
if (signal?.aborted) {
throw Object.assign(
new Error(`Command aborted before start: ${command}`),
{ code: "ABORT_ERR", aborted: true },
);
}
logger.log(`${taskId}: running ${type} command: ${command}`);
await store.logEntry(taskId, `[verification] Running ${type} command: ${command}`);
await store.appendAgentLog(taskId, `Running ${type} command`, "tool", command, label);
const result: VerificationCommandResult = {
command,
exitCode: null,
stdout: "",
stderr: "",
success: false,
};
const verificationStartedAt = Date.now();
try {
const { stdout, stderr, bufferOverflow } = await execWithProcessGroup(command, {
cwd: rootDir,
timeout: VERIFICATION_COMMAND_TIMEOUT_MS,
maxBuffer: VERIFICATION_COMMAND_MAX_BUFFER,
signal,
});
if (signal?.aborted) {
throw Object.assign(
new Error(`Command aborted: ${command}`),
{ code: "ABORT_ERR", aborted: true },
);
}
result.stdout = stdout?.toString?.() || "";
result.stderr = stderr?.toString?.() || "";
result.exitCode = 0;
result.success = true;
const verificationDurationMs = Date.now() - verificationStartedAt;
const timingDetail = `${verificationDurationMs}ms`;
if (bufferOverflow) {
logger.log(`${taskId}: ${type} command succeeded (exit 0, output exceeded buffer) in ${verificationDurationMs}ms`);
await store.logEntry(
taskId,
`[timing] [verification] ${type} command succeeded (exit 0, output exceeded buffer) in ${verificationDurationMs}ms`,
);
await store.appendAgentLog(
taskId,
`${type} command succeeded (exit 0)`,
"tool_result",
timingDetail,
label,
);
} else {
logger.log(`${taskId}: ${type} command succeeded in ${verificationDurationMs}ms`);
await store.logEntry(taskId, `[timing] [verification] ${type} command succeeded (exit 0) in ${verificationDurationMs}ms`);
await store.appendAgentLog(
taskId,
`${type} command succeeded (exit 0)`,
"tool_result",
timingDetail,
label,
);
}
return result;
} catch (error: unknown) {
if (signal?.aborted) {
throw Object.assign(
new Error(`Command aborted: ${command}`),
{ code: "ABORT_ERR", aborted: true },
);
}
const verificationDurationMs = Date.now() - verificationStartedAt;
const err = error as { stdout?: string | Buffer; stderr?: string | Buffer; status?: number; code?: number | string; message?: string };
result.stdout = err?.stdout?.toString?.() || "";
result.stderr = err?.stderr?.toString?.() || "";
result.exitCode = typeof err?.status === "number"
? err.status
: (typeof err?.code === "number" ? err.code : null);
const maxBufferExceeded = err?.code === "ENOBUFS"
|| err?.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER"
|| String(err?.message ?? "").includes("maxBuffer");
result.success = maxBufferExceeded && result.exitCode === 0;
if (result.success) {
logger.log(`${taskId}: ${type} command succeeded (exit 0, output exceeded buffer) in ${verificationDurationMs}ms`);
await store.logEntry(
taskId,
`[timing] [verification] ${type} command succeeded (exit 0, output exceeded buffer) in ${verificationDurationMs}ms`,
);
await store.appendAgentLog(
taskId,
`${type} command succeeded (exit 0)`,
"tool_result",
`${verificationDurationMs}ms`,
label,
);
return result;
}
const output = result.stderr || result.stdout || err?.message || "Unknown error";
const summary = summarizeVerificationOutput(output, type);
logger.error(`${taskId}: ${type} command failed (exit ${result.exitCode}) in ${verificationDurationMs}ms; output captured in task log`);
await store.logEntry(
taskId,
`[timing] [verification] ${type} command failed (exit ${result.exitCode}) after ${verificationDurationMs}ms:\n${summary}`,
);
await store.appendAgentLog(
taskId,
`${type} command failed (exit ${result.exitCode})`,
"tool_error",
summary,
label,
);
}
return result;
}