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:
@@ -172,6 +172,13 @@ vi.mock("../step-session-executor.js", () => ({
|
|||||||
vi.mock("../rate-limit-retry.js", () => ({
|
vi.mock("../rate-limit-retry.js", () => ({
|
||||||
withRateLimitRetry: vi.fn((fn: () => Promise<unknown>) => fn()),
|
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", () => {
|
vi.mock("@mariozechner/pi-coding-agent", () => {
|
||||||
const mockSessionManager = {};
|
const mockSessionManager = {};
|
||||||
return {
|
return {
|
||||||
@@ -203,6 +210,7 @@ import { SessionManager } from "@mariozechner/pi-coding-agent";
|
|||||||
import { StepSessionExecutor } from "../step-session-executor.js";
|
import { StepSessionExecutor } from "../step-session-executor.js";
|
||||||
import { executorLog } from "../logger.js";
|
import { executorLog } from "../logger.js";
|
||||||
import { withRateLimitRetry } from "../rate-limit-retry.js";
|
import { withRateLimitRetry } from "../rate-limit-retry.js";
|
||||||
|
import { runVerificationCommand as mockedRunVerificationCommand } from "../verification-utils.js";
|
||||||
|
|
||||||
const mockedCreateFnAgent = vi.mocked(createFnAgent);
|
const mockedCreateFnAgent = vi.mocked(createFnAgent);
|
||||||
const mockedSessionManager = vi.mocked(SessionManager);
|
const mockedSessionManager = vi.mocked(SessionManager);
|
||||||
@@ -13499,3 +13507,354 @@ describe("determineRevisionResetStart", () => {
|
|||||||
expect(determineRevisionResetStart(steps, feedback)).toBe(1);
|
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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -14,6 +14,12 @@ import {
|
|||||||
type RunCommandResult,
|
type RunCommandResult,
|
||||||
} from "@fusion/core";
|
} from "@fusion/core";
|
||||||
import { findWorktreeUser } from "./merger.js";
|
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 { generateWorktreeName, slugify } from "./worktree-names.js";
|
||||||
import { Type, type Static } from "@mariozechner/pi-ai";
|
import { Type, type Static } from "@mariozechner/pi-ai";
|
||||||
import { describeModel, promptWithFallback, compactSessionContext } from "./pi.js";
|
import { describeModel, promptWithFallback, compactSessionContext } from "./pi.js";
|
||||||
@@ -2402,6 +2408,90 @@ export class TaskExecutor {
|
|||||||
return;
|
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
|
// Run workflow steps before moving to in-review — skip in fast mode
|
||||||
if (executionMode !== "fast") {
|
if (executionMode !== "fast") {
|
||||||
const workflowResult = await this.runWorkflowSteps(task, worktreePath, settings);
|
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
|
* @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(
|
private async handleWorkflowStepFailure(
|
||||||
task: Task,
|
task: Task,
|
||||||
worktreePath: string,
|
worktreePath: string,
|
||||||
|
|||||||
@@ -1,136 +1,30 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* 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";
|
import { promisify } from "node:util";
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
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";
|
||||||
|
|
||||||
/**
|
// Re-export for backward compatibility (tests import from merger.ts)
|
||||||
* Run a verification command with a wallclock timeout that reaps the whole
|
export {
|
||||||
* process group on expiry. Node's exec timeout only kills the immediate shell;
|
execWithProcessGroup,
|
||||||
* vitest/pnpm workers can survive and accumulate across retries. Using
|
summarizeVerificationOutput,
|
||||||
* detached + negative-pid signal terminates the full tree.
|
truncateWithEllipsis,
|
||||||
*
|
VERIFICATION_COMMAND_MAX_BUFFER,
|
||||||
* Resolves with stdout/stderr/exitCode mirroring exec's promisified shape.
|
VERIFICATION_COMMAND_TIMEOUT_MS,
|
||||||
* Rejects with an Error tagged `code: "ETIMEDOUT"` and `killed: true` on
|
VERIFICATION_LOG_MAX_CHARS,
|
||||||
* timeout, matching exec's contract so callers don't need to special-case it.
|
type VerificationCommandResult,
|
||||||
*/
|
type VerificationResult,
|
||||||
async function execWithProcessGroup(
|
} from "./verification-utils.js";
|
||||||
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));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import {
|
import {
|
||||||
@@ -216,9 +110,6 @@ const DEPENDENCY_SYNC_TRIGGER_PATTERNS = [
|
|||||||
"packages/*/package.json",
|
"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 WORKFLOW_SCRIPT_OUTPUT_MAX_CHARS = 4_000;
|
||||||
const PULL_REBASE_TIMEOUT_MS = 120_000;
|
const PULL_REBASE_TIMEOUT_MS = 120_000;
|
||||||
const PUSH_TIMEOUT_MS = 60_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;
|
const MERGE_DIFF_STAT_MAX_CHARS = 3000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Truncate text to maxChars with ellipsis indicator.
|
* @deprecated Use summarizeVerificationOutput from verification-utils.js instead
|
||||||
* Returns original text if under limit.
|
|
||||||
*/
|
*/
|
||||||
function truncateWithEllipsis(text: string, maxChars: number): string {
|
export const summarizeVerificationOutputLocal = summarizeVerificationOutput;
|
||||||
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}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function truncateWorkflowScriptOutput(output: string): string {
|
function truncateWorkflowScriptOutput(output: string): string {
|
||||||
if (output.length <= WORKFLOW_SCRIPT_OUTPUT_MAX_CHARS) return output;
|
if (output.length <= WORKFLOW_SCRIPT_OUTPUT_MAX_CHARS) return output;
|
||||||
@@ -598,23 +326,6 @@ export function inferDefaultTestCommand(
|
|||||||
|
|
||||||
// ── Deterministic merge verification ──────────────────────────────────
|
// ── 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.
|
* Run verification commands deterministically in the engine.
|
||||||
* Executes testCommand first, then buildCommand (when both are configured).
|
* Executes testCommand first, then buildCommand (when both are configured).
|
||||||
@@ -762,110 +473,7 @@ async function runVerificationCommand(
|
|||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
): Promise<VerificationCommandResult> {
|
): Promise<VerificationCommandResult> {
|
||||||
throwIfAborted(signal, taskId);
|
throwIfAborted(signal, taskId);
|
||||||
mergerLog.log(`${taskId}: running ${type} command: ${command}`);
|
return runVerificationCommandShared(store, rootDir, taskId, command, type, signal, mergerLog, "merger");
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
437
packages/engine/src/verification-utils.ts
Normal file
437
packages/engine/src/verification-utils.ts
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user