fix: improve merge verification and dashboard behavior
This commit is contained in:
@@ -8,7 +8,15 @@ const workspaceRoot = join(import.meta.dirname!, "..", "..", "..", "..");
|
||||
function loadWorkflow(name: string): any {
|
||||
const path = join(workspaceRoot, ".github", "workflows", name);
|
||||
const content = readFileSync(path, "utf-8");
|
||||
return { content, parsed: parse(content) };
|
||||
const parsed = parse(content) as Record<string, unknown>;
|
||||
|
||||
// Some YAML parsers treat the unquoted `on:` key as boolean `true`.
|
||||
// Normalize it so tests can consistently read `workflow.on`.
|
||||
if (parsed && parsed.on === undefined) {
|
||||
(parsed as any).on = (parsed as any)["on"] ?? (parsed as any).true ?? (parsed as any)["true"];
|
||||
}
|
||||
|
||||
return { content, parsed };
|
||||
}
|
||||
|
||||
describe("CI workflow (.github/workflows/ci.yml)", () => {
|
||||
@@ -26,12 +34,13 @@ describe("CI workflow (.github/workflows/ci.yml)", () => {
|
||||
expect(typeof workflow).toBe("object");
|
||||
});
|
||||
|
||||
it("has push trigger on main", () => {
|
||||
expect(workflow.on.push.branches).toContain("main");
|
||||
it("uses workflow_dispatch trigger (auto CI disabled)", () => {
|
||||
expect(workflow.on).toHaveProperty("workflow_dispatch");
|
||||
});
|
||||
|
||||
it("has pull_request trigger on main", () => {
|
||||
expect(workflow.on.pull_request.branches).toContain("main");
|
||||
it("does not auto-trigger on push/pull_request", () => {
|
||||
expect(workflow.on.push).toBeUndefined();
|
||||
expect(workflow.on.pull_request).toBeUndefined();
|
||||
});
|
||||
|
||||
it("includes pnpm install step", () => {
|
||||
|
||||
@@ -851,6 +851,47 @@ describe("runDashboard — auto-merge pause exclusion", () => {
|
||||
expect(aiMergeTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("auto-heals stale exit-0 verification buffer failures with exhausted merge retries", async () => {
|
||||
mockStore.getSettings.mockResolvedValue({
|
||||
maxConcurrent: 1,
|
||||
maxWorktrees: 2,
|
||||
autoMerge: true,
|
||||
pollIntervalMs: 60_000,
|
||||
});
|
||||
const task = {
|
||||
id: "FN-BUFFER",
|
||||
column: "in-review",
|
||||
paused: false,
|
||||
mergeRetries: 3,
|
||||
error: "Deterministic test verification failed for FN-BUFFER",
|
||||
steps: [{ name: "Step 1", status: "done" }],
|
||||
log: [
|
||||
{
|
||||
timestamp: "2026-04-10T20:23:18.691Z",
|
||||
action: "[verification] test command failed (exit 0): stdout maxBuffer length exceeded",
|
||||
},
|
||||
],
|
||||
};
|
||||
mockStore.listTasks.mockResolvedValue([task]);
|
||||
mockStore.getTask = vi.fn().mockResolvedValue(task);
|
||||
|
||||
const { aiMergeTask } = await import("@fusion/engine");
|
||||
(aiMergeTask as ReturnType<typeof vi.fn>).mockClear();
|
||||
|
||||
await runDashboard(0, { open: false });
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
expect(mockStore.logEntry).toHaveBeenCalledWith(
|
||||
"FN-BUFFER",
|
||||
"Auto-healing stale deterministic verification buffer failure; retrying merge verification",
|
||||
);
|
||||
expect(mockStore.updateTask).toHaveBeenCalledWith(
|
||||
"FN-BUFFER",
|
||||
{ mergeRetries: 0, error: null, status: null },
|
||||
);
|
||||
expect(aiMergeTask).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not auto-merge in-review tasks with incomplete steps", async () => {
|
||||
mockStore.getSettings.mockResolvedValue({
|
||||
maxConcurrent: 1,
|
||||
@@ -1902,28 +1943,22 @@ describe("runDashboard — lifecycle listener cleanup", () => {
|
||||
expect(process.listenerCount("SIGTERM")).toBe(baselineSigterm);
|
||||
});
|
||||
|
||||
it("does not emit MaxListenersExceededWarning after 12 rapid invocations", async () => {
|
||||
it("does not leak process signal listeners after 12 rapid invocations", async () => {
|
||||
const { TaskStore } = await import("@fusion/core");
|
||||
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => makeMockStore());
|
||||
|
||||
const warnings: string[] = [];
|
||||
const warningHandler = (warning: unknown) => {
|
||||
warnings.push(String(warning));
|
||||
};
|
||||
const baselineSigint = process.listenerCount("SIGINT");
|
||||
const baselineSigterm = process.listenerCount("SIGTERM");
|
||||
|
||||
process.on("warning", warningHandler);
|
||||
|
||||
try {
|
||||
for (let i = 0; i < 12; i += 1) {
|
||||
const { dispose } = await runDashboard(0, { open: false });
|
||||
dispose();
|
||||
}
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
} finally {
|
||||
process.removeListener("warning", warningHandler);
|
||||
for (let i = 0; i < 12; i += 1) {
|
||||
const { dispose } = await runDashboard(0, { open: false });
|
||||
dispose();
|
||||
}
|
||||
|
||||
expect(warnings.some((warning) => warning.includes("MaxListenersExceededWarning"))).toBe(false);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(process.listenerCount("SIGINT")).toBe(baselineSigint);
|
||||
expect(process.listenerCount("SIGTERM")).toBe(baselineSigterm);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -392,9 +392,27 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
let mergeRunning = false;
|
||||
const maxAutoMergeRetries = 3;
|
||||
|
||||
function canAutoMergeTask(task: { mergeRetries?: number | null; column: string; paused?: boolean; status?: string | null; error?: string | null; steps?: Array<{ status: string }>; workflowStepResults?: Array<{ status: string }> }): boolean {
|
||||
function hasAutoHealableVerificationBufferFailure(task: {
|
||||
mergeRetries?: number | null;
|
||||
column: string;
|
||||
error?: string | null;
|
||||
log?: Array<{ action?: string }>;
|
||||
}): boolean {
|
||||
if (task.column !== "in-review") return false;
|
||||
if ((task.mergeRetries ?? 0) < maxAutoMergeRetries) return false;
|
||||
if (!task.error?.includes("Deterministic test verification failed")) return false;
|
||||
|
||||
return task.log?.some((entry) =>
|
||||
entry.action?.includes("[verification] test command failed (exit 0)")
|
||||
|| entry.action?.includes("[verification] build command failed (exit 0)")
|
||||
|| entry.action?.includes("output exceeded buffer"),
|
||||
) ?? false;
|
||||
}
|
||||
|
||||
function canAutoMergeTask(task: { mergeRetries?: number | null; column: string; paused?: boolean; status?: string | null; error?: string | null; steps?: Array<{ status: string }>; workflowStepResults?: Array<{ status: string }>; log?: Array<{ action?: string }> }): boolean {
|
||||
if (getTaskMergeBlocker(task as any)) return false;
|
||||
return (task.mergeRetries ?? 0) < maxAutoMergeRetries;
|
||||
return (task.mergeRetries ?? 0) < maxAutoMergeRetries
|
||||
|| hasAutoHealableVerificationBufferFailure(task);
|
||||
}
|
||||
|
||||
/** Enqueue a task for auto-merge if not already queued/active. */
|
||||
@@ -428,6 +446,13 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
if (!canAutoMergeTask(task as any)) {
|
||||
continue;
|
||||
}
|
||||
if (hasAutoHealableVerificationBufferFailure(task as any)) {
|
||||
await store.logEntry(
|
||||
taskId,
|
||||
"Auto-healing stale deterministic verification buffer failure; retrying merge verification",
|
||||
);
|
||||
await store.updateTask(taskId, { mergeRetries: 0, error: null, status: null });
|
||||
}
|
||||
const mergeStrategy = getMergeStrategy(settings);
|
||||
if (mergeStrategy === "pull-request") {
|
||||
console.log(`[auto-merge] Processing PR flow for ${taskId}...`);
|
||||
@@ -852,7 +877,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
// ── Startup sweep: enqueue any tasks already in "in-review" ───────
|
||||
if (settings.autoMerge) {
|
||||
const existing = await store.listTasks();
|
||||
const inReview = existing.filter((t) => !getTaskMergeBlocker(t));
|
||||
const inReview = existing.filter((t) => canAutoMergeTask(t as any));
|
||||
if (inReview.length > 0) {
|
||||
console.log(
|
||||
`[auto-merge] Startup sweep: enqueueing ${inReview.length} in-review task(s)`,
|
||||
@@ -888,7 +913,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
try {
|
||||
const tasks = await store.listTasks();
|
||||
for (const t of tasks) {
|
||||
if (!getTaskMergeBlocker(t)) {
|
||||
if (canAutoMergeTask(t as any)) {
|
||||
enqueueMerge(t.id);
|
||||
}
|
||||
}
|
||||
@@ -912,7 +937,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
try {
|
||||
const tasks = await store.listTasks();
|
||||
for (const t of tasks) {
|
||||
if (!getTaskMergeBlocker(t)) {
|
||||
if (canAutoMergeTask(t as any)) {
|
||||
enqueueMerge(t.id);
|
||||
}
|
||||
}
|
||||
@@ -961,8 +986,8 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
// ── Periodic retry: catch failed merges on each poll cycle ────────
|
||||
// Uses a setTimeout chain so the interval dynamically follows
|
||||
// settings.pollIntervalMs without requiring an engine restart.
|
||||
// The readiness predicate uses getTaskMergeBlocker() to detect tasks that
|
||||
// have become unblocked (e.g., awaiting-user-review cleared by user).
|
||||
// The readiness predicate uses canAutoMergeTask() to detect tasks that
|
||||
// have become unblocked while respecting retry limits.
|
||||
async function scheduleMergeRetry(): Promise<void> {
|
||||
if (disposed) return;
|
||||
const currentSettings = await store.getSettings().catch(() => settings);
|
||||
@@ -976,7 +1001,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
if (!s.globalPause && !s.enginePaused && s.autoMerge) {
|
||||
const tasks = await store.listTasks();
|
||||
for (const t of tasks) {
|
||||
if (!getTaskMergeBlocker(t)) {
|
||||
if (canAutoMergeTask(t as any)) {
|
||||
enqueueMerge(t.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useState } from "react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { SettingsModal } from "../SettingsModal";
|
||||
import type { Settings } from "@fusion/core";
|
||||
import type { Settings, ThemeMode, ColorTheme } from "@fusion/core";
|
||||
|
||||
const defaultSettings: Settings = {
|
||||
maxConcurrent: 2,
|
||||
@@ -85,6 +86,112 @@ describe("SettingsModal", () => {
|
||||
expect(screen.queryByLabelText("Max Worktrees")).toBeNull();
|
||||
});
|
||||
|
||||
it("invokes appearance callbacks when theme controls are used", async () => {
|
||||
const handleThemeModeChange = vi.fn();
|
||||
const handleColorThemeChange = vi.fn();
|
||||
|
||||
render(
|
||||
<SettingsModal
|
||||
onClose={onClose}
|
||||
addToast={addToast}
|
||||
themeMode="dark"
|
||||
colorTheme="default"
|
||||
onThemeModeChange={handleThemeModeChange}
|
||||
onColorThemeChange={handleColorThemeChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getAllByText("Appearance")[0]);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Light mode" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Forest theme" }));
|
||||
|
||||
expect(handleThemeModeChange).toHaveBeenCalledWith("light");
|
||||
expect(handleColorThemeChange).toHaveBeenCalledWith("forest");
|
||||
});
|
||||
|
||||
it("reflects selected appearance values when parent updates controlled props", async () => {
|
||||
function ControlledAppearanceModal() {
|
||||
const [themeMode, setThemeMode] = useState<ThemeMode>("dark");
|
||||
const [colorTheme, setColorTheme] = useState<ColorTheme>("default");
|
||||
|
||||
return (
|
||||
<SettingsModal
|
||||
onClose={onClose}
|
||||
addToast={addToast}
|
||||
themeMode={themeMode}
|
||||
colorTheme={colorTheme}
|
||||
onThemeModeChange={setThemeMode}
|
||||
onColorThemeChange={setColorTheme}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
render(<ControlledAppearanceModal />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getAllByText("Appearance")[0]);
|
||||
|
||||
const lightModeButton = screen.getByRole("button", { name: "Light mode" });
|
||||
const forestThemeButton = screen.getByRole("button", { name: "Forest theme" });
|
||||
|
||||
fireEvent.click(lightModeButton);
|
||||
fireEvent.click(forestThemeButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lightModeButton).toHaveAttribute("aria-pressed", "true");
|
||||
expect(forestThemeButton).toHaveAttribute("aria-pressed", "true");
|
||||
expect(screen.getByText("Light / Forest")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("Appearance controls invoke theme callbacks and keep UI state in sync", async () => {
|
||||
const user = userEvent.setup();
|
||||
const themeModeSpy = vi.fn();
|
||||
const colorThemeSpy = vi.fn();
|
||||
|
||||
function ThemeHarness() {
|
||||
const [themeMode, setThemeMode] = useState<ThemeMode>("dark");
|
||||
const [colorTheme, setColorTheme] = useState<ColorTheme>("default");
|
||||
|
||||
return (
|
||||
<SettingsModal
|
||||
onClose={onClose}
|
||||
addToast={addToast}
|
||||
initialSection="appearance"
|
||||
themeMode={themeMode}
|
||||
colorTheme={colorTheme}
|
||||
onThemeModeChange={(mode) => {
|
||||
themeModeSpy(mode);
|
||||
setThemeMode(mode);
|
||||
}}
|
||||
onColorThemeChange={(theme) => {
|
||||
colorThemeSpy(theme);
|
||||
setColorTheme(theme);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
render(<ThemeHarness />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
expect(screen.getByText(/Dark \/ Default/)).toBeTruthy();
|
||||
|
||||
await user.click(screen.getByLabelText("Light mode"));
|
||||
await user.click(screen.getByLabelText("Ocean theme"));
|
||||
|
||||
expect(themeModeSpy).toHaveBeenCalledWith("light");
|
||||
expect(colorThemeSpy).toHaveBeenCalledWith("ocean");
|
||||
expect(screen.getByText(/Light \/ Ocean/)).toBeTruthy();
|
||||
|
||||
const lightButton = screen.getByLabelText("Light mode");
|
||||
const oceanButton = screen.getByLabelText("Ocean theme");
|
||||
expect(lightButton.getAttribute("aria-pressed")).toBe("true");
|
||||
expect(oceanButton.getAttribute("aria-pressed")).toBe("true");
|
||||
});
|
||||
|
||||
it("switches section when clicking sidebar item", async () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
@@ -141,6 +141,42 @@ describe("useTheme", () => {
|
||||
expect(localStorageMock[THEME_MODE_STORAGE_KEY]).toBe("dark");
|
||||
});
|
||||
|
||||
it("keeps user-selected theme changes when hydration resolves with stale backend values", async () => {
|
||||
let resolveHydration: (value: Settings) => void;
|
||||
const hydrationPromise = new Promise<Settings>((resolve) => {
|
||||
resolveHydration = resolve;
|
||||
});
|
||||
mockFetchGlobalSettings.mockReturnValue(hydrationPromise);
|
||||
|
||||
const { result } = renderHook(() => useTheme());
|
||||
|
||||
// User changes both fields before initial backend hydration resolves.
|
||||
act(() => {
|
||||
result.current.setThemeMode("light");
|
||||
result.current.setColorTheme("ocean");
|
||||
});
|
||||
|
||||
expect(result.current.themeMode).toBe("light");
|
||||
expect(result.current.colorTheme).toBe("ocean");
|
||||
|
||||
// Hydration resolves with stale values from backend cache.
|
||||
resolveHydration!({ themeMode: "dark", colorTheme: "forest" } as Settings);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchGlobalSettings).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Regression expectation: user selections remain authoritative.
|
||||
expect(result.current.themeMode).toBe("light");
|
||||
expect(result.current.colorTheme).toBe("ocean");
|
||||
expect(localStorageMock[THEME_MODE_STORAGE_KEY]).toBe("light");
|
||||
expect(localStorageMock[COLOR_THEME_STORAGE_KEY]).toBe("ocean");
|
||||
|
||||
// Ensure stale hydration values did not leak through.
|
||||
expect(localStorageMock[THEME_MODE_STORAGE_KEY]).not.toBe("dark");
|
||||
expect(localStorageMock[COLOR_THEME_STORAGE_KEY]).not.toBe("forest");
|
||||
});
|
||||
|
||||
it("keeps localStorage value when backend matches", async () => {
|
||||
localStorageMock[THEME_MODE_STORAGE_KEY] = "dark";
|
||||
mockFetchGlobalSettings.mockResolvedValue({ themeMode: "dark" });
|
||||
|
||||
@@ -189,6 +189,8 @@ export function useTheme(): UseThemeReturn {
|
||||
|
||||
const themeModeRef = useRef(themeMode);
|
||||
const colorThemeRef = useRef(colorTheme);
|
||||
const userSetThemeModeRef = useRef(false);
|
||||
const userSetColorThemeRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
themeModeRef.current = themeMode;
|
||||
@@ -208,8 +210,11 @@ export function useTheme(): UseThemeReturn {
|
||||
.then((globalSettings) => {
|
||||
if (cancelled) return;
|
||||
|
||||
if (isValidThemeMode(globalSettings.themeMode)) {
|
||||
// Hydration should not override user-initiated writes that happened while
|
||||
// fetchGlobalSettings() was in flight. User selections are authoritative.
|
||||
if (isValidThemeMode(globalSettings.themeMode) && !userSetThemeModeRef.current) {
|
||||
if (themeModeRef.current !== globalSettings.themeMode) {
|
||||
themeModeRef.current = globalSettings.themeMode;
|
||||
setThemeModeState(globalSettings.themeMode);
|
||||
}
|
||||
if (readCachedThemeMode() !== globalSettings.themeMode) {
|
||||
@@ -217,8 +222,13 @@ export function useTheme(): UseThemeReturn {
|
||||
}
|
||||
}
|
||||
|
||||
if (globalSettings.colorTheme && VALID_COLOR_THEMES.includes(globalSettings.colorTheme)) {
|
||||
if (
|
||||
globalSettings.colorTheme
|
||||
&& VALID_COLOR_THEMES.includes(globalSettings.colorTheme)
|
||||
&& !userSetColorThemeRef.current
|
||||
) {
|
||||
if (colorThemeRef.current !== globalSettings.colorTheme) {
|
||||
colorThemeRef.current = globalSettings.colorTheme;
|
||||
setColorThemeState(globalSettings.colorTheme);
|
||||
}
|
||||
if (readCachedColorTheme() !== globalSettings.colorTheme) {
|
||||
@@ -271,6 +281,9 @@ export function useTheme(): UseThemeReturn {
|
||||
|
||||
// Wrapper setters with write-through persistence.
|
||||
const setThemeMode = useCallback((mode: ThemeMode) => {
|
||||
// Mark user intent immediately so in-flight hydration cannot overwrite it.
|
||||
userSetThemeModeRef.current = true;
|
||||
themeModeRef.current = mode;
|
||||
setThemeModeState(mode);
|
||||
writeCachedThemeMode(mode);
|
||||
|
||||
@@ -280,6 +293,9 @@ export function useTheme(): UseThemeReturn {
|
||||
}, []);
|
||||
|
||||
const setColorTheme = useCallback((theme: ColorTheme) => {
|
||||
// Mark user intent immediately so in-flight hydration cannot overwrite it.
|
||||
userSetColorThemeRef.current = true;
|
||||
colorThemeRef.current = theme;
|
||||
setColorThemeState(theme);
|
||||
writeCachedColorTheme(theme);
|
||||
|
||||
|
||||
@@ -37,6 +37,10 @@ class MockStore extends EventEmitter {
|
||||
return "/tmp/fn-1222";
|
||||
}
|
||||
|
||||
getFusionDir(): string {
|
||||
return "/tmp/fn-1222/.fusion";
|
||||
}
|
||||
|
||||
getDatabase() {
|
||||
return {
|
||||
exec: vi.fn(),
|
||||
|
||||
@@ -44,6 +44,10 @@ class MockStore extends EventEmitter {
|
||||
return "/tmp/fn-1224";
|
||||
}
|
||||
|
||||
getFusionDir(): string {
|
||||
return "/tmp/fn-1224/.fusion";
|
||||
}
|
||||
|
||||
getDatabase() {
|
||||
return {
|
||||
exec: vi.fn(),
|
||||
|
||||
@@ -43,6 +43,10 @@ class MockStore extends EventEmitter {
|
||||
return "/tmp/fn-1080";
|
||||
}
|
||||
|
||||
getFusionDir(): string {
|
||||
return "/tmp/fn-1080/.fusion";
|
||||
}
|
||||
|
||||
getDatabase() {
|
||||
return {
|
||||
exec: vi.fn(),
|
||||
|
||||
@@ -458,6 +458,10 @@ class MockStoreForRoutes extends EventEmitter {
|
||||
return "/tmp/fn-944";
|
||||
}
|
||||
|
||||
getFusionDir(): string {
|
||||
return "/tmp/fn-944/.fusion";
|
||||
}
|
||||
|
||||
getDatabase() {
|
||||
return {
|
||||
exec: vi.fn(),
|
||||
|
||||
@@ -33,6 +33,10 @@ class MockStore extends EventEmitter {
|
||||
return "/tmp/fn-679";
|
||||
}
|
||||
|
||||
getFusionDir(): string {
|
||||
return "/tmp/fn-679/.fusion";
|
||||
}
|
||||
|
||||
getDatabase() {
|
||||
return {
|
||||
exec: vi.fn(),
|
||||
|
||||
@@ -42,6 +42,10 @@ class MockStore extends EventEmitter {
|
||||
return this.rootDir;
|
||||
}
|
||||
|
||||
getFusionDir(): string {
|
||||
return this.rootDir + "/.fusion";
|
||||
}
|
||||
|
||||
getDatabase() {
|
||||
return {
|
||||
exec: vi.fn(),
|
||||
|
||||
@@ -48,6 +48,10 @@ class MockStore extends EventEmitter {
|
||||
return process.cwd();
|
||||
}
|
||||
|
||||
getFusionDir(): string {
|
||||
return process.cwd() + "/.fusion";
|
||||
}
|
||||
|
||||
getDatabase() {
|
||||
return {
|
||||
exec: vi.fn(),
|
||||
|
||||
@@ -346,10 +346,16 @@ describe("agent-generation module", () => {
|
||||
vi.doMock("@fusion/engine", () => ({
|
||||
createKbAgent: vi.fn(async (options: { cwd: string; systemPrompt: string; tools: string }) => {
|
||||
capturedSystemPrompt = options.systemPrompt;
|
||||
const messages: Array<{ role: string; content: string }> = [];
|
||||
return {
|
||||
session: {
|
||||
state: { messages: [] },
|
||||
prompt: vi.fn(async () => {}),
|
||||
state: { messages },
|
||||
prompt: vi.fn(async () => {
|
||||
messages.push({
|
||||
role: "assistant",
|
||||
content: JSON.stringify({ title: "Test Agent", description: "A test agent", systemPrompt: "Test prompt", tools: [], maxTurns: 10, tags: [] }),
|
||||
});
|
||||
}),
|
||||
dispose: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -8276,13 +8276,33 @@ describe("Routine routes", () => {
|
||||
};
|
||||
}
|
||||
|
||||
function createMockRoutineRunner() {
|
||||
return {
|
||||
triggerManual: vi.fn().mockResolvedValue({
|
||||
routineId: "routine-001",
|
||||
success: true,
|
||||
triggerType: "cron" as const,
|
||||
startedAt: new Date().toISOString(),
|
||||
completedAt: new Date().toISOString(),
|
||||
} satisfies RoutineExecutionResult),
|
||||
triggerWebhook: vi.fn().mockResolvedValue({
|
||||
routineId: "routine-001",
|
||||
success: true,
|
||||
triggerType: "webhook" as const,
|
||||
startedAt: new Date().toISOString(),
|
||||
completedAt: new Date().toISOString(),
|
||||
} satisfies RoutineExecutionResult),
|
||||
};
|
||||
}
|
||||
|
||||
function buildRoutineApp(routineStoreOverride?: ReturnType<typeof createMockRoutineStore>) {
|
||||
const store = createMockStore();
|
||||
const routineStore = routineStoreOverride ?? createMockRoutineStore();
|
||||
const routineRunner = createMockRoutineRunner();
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store, { routineStore: routineStore as any }));
|
||||
return { app, routineStore };
|
||||
app.use("/api", createApiRoutes(store, { routineStore: routineStore as any, routineRunner }));
|
||||
return { app, routineStore, routineRunner };
|
||||
}
|
||||
|
||||
describe("GET /routines", () => {
|
||||
@@ -8602,10 +8622,11 @@ describe("Routine routes", () => {
|
||||
function buildRoutineApp(routineStoreOverride?: ReturnType<typeof createMockRoutineStore>) {
|
||||
const store = createMockStore();
|
||||
const routineStore = routineStoreOverride ?? createMockRoutineStore();
|
||||
const routineRunner = createMockRoutineRunner();
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store, { routineStore: routineStore as any }));
|
||||
return { app, routineStore };
|
||||
app.use("/api", createApiRoutes(store, { routineStore: routineStore as any, routineRunner }));
|
||||
return { app, routineStore, routineRunner };
|
||||
}
|
||||
|
||||
it("triggers a webhook routine without secret", async () => {
|
||||
|
||||
@@ -8085,9 +8085,6 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
if (!name?.trim()) {
|
||||
throw badRequest("Name is required");
|
||||
}
|
||||
if (!agentId?.trim()) {
|
||||
throw badRequest("agentId is required");
|
||||
}
|
||||
if (!trigger) {
|
||||
throw badRequest("Trigger is required");
|
||||
}
|
||||
@@ -8248,6 +8245,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
|
||||
// Execute via RoutineRunner
|
||||
const result = await routineRunner.triggerManual(id);
|
||||
await routineStore.recordRun(id, result);
|
||||
const updated = await routineStore.getRoutine(id);
|
||||
res.json({ routine: updated, result });
|
||||
} catch (err: any) {
|
||||
@@ -8324,6 +8322,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
// Execute via RoutineRunner
|
||||
const payload = req.body;
|
||||
const result = await routineRunner.triggerWebhook(id, payload, signatureHeader);
|
||||
await routineStore.recordRun(id, result);
|
||||
const updated = await routineStore.getRoutine(id);
|
||||
res.json({ routine: updated, result });
|
||||
} catch (err: any) {
|
||||
|
||||
@@ -2152,6 +2152,57 @@ describe("aiMergeTask — deterministic merge verification", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("does not fail verification when verbose test output exceeds buffer after exit 0", async () => {
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
const cmdStr = String(cmd);
|
||||
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
|
||||
if (cmdStr.includes("git log")) return "- feat: something" as any;
|
||||
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
|
||||
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
|
||||
if (cmdStr.includes("merge --squash")) return Buffer.from("");
|
||||
if (cmdStr.includes("vitest run")) {
|
||||
const error = new Error("stdout maxBuffer length exceeded") as any;
|
||||
error.code = "ENOBUFS";
|
||||
error.status = 0;
|
||||
error.stdout = "tests passed but output was verbose";
|
||||
error.stderr = "";
|
||||
throw error;
|
||||
}
|
||||
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
|
||||
if (cmdStr.includes("diff --cached") && !cmdStr.includes("--quiet")) return "" as any;
|
||||
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
|
||||
if (cmdStr.includes("worktree remove")) return Buffer.from("");
|
||||
if (cmdStr === "git rev-parse HEAD" || cmdStr.startsWith("git rev-parse HEAD ")) return "mergedcommit123";
|
||||
if (cmdStr.includes("show --shortstat")) return "1 file changed, 1 insertion(+)" as any;
|
||||
return Buffer.from("");
|
||||
});
|
||||
|
||||
mockedCreateHaiAgent.mockImplementation(async () => ({
|
||||
session: {
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn(),
|
||||
},
|
||||
}) as any);
|
||||
|
||||
const store = createMockStore(
|
||||
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
|
||||
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
|
||||
);
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
...DEFAULT_SETTINGS,
|
||||
testCommand: "vitest run",
|
||||
});
|
||||
|
||||
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
|
||||
|
||||
expect(result.merged).toBe(true);
|
||||
expect(store.moveTask).toHaveBeenCalledWith("FN-050", "done");
|
||||
expect(store.logEntry).toHaveBeenCalledWith(
|
||||
"FN-050",
|
||||
"[verification] test command succeeded (exit 0, output exceeded buffer)",
|
||||
);
|
||||
});
|
||||
|
||||
it("fails merge when buildCommand fails and does not move task to done", async () => {
|
||||
// Setup exec mock that will be updated after agent commits
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
|
||||
@@ -63,6 +63,14 @@ const DEPENDENCY_SYNC_TRIGGER_PATTERNS = [
|
||||
"packages/*/package.json",
|
||||
];
|
||||
|
||||
const VERIFICATION_COMMAND_MAX_BUFFER = 50 * 1024 * 1024;
|
||||
const VERIFICATION_LOG_MAX_CHARS = 20_000;
|
||||
|
||||
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)}`;
|
||||
}
|
||||
|
||||
/** Check if a path matches a glob pattern (simple glob support: * and **) */
|
||||
function matchGlob(path: string, pattern: string): boolean {
|
||||
// Handle ** which matches across directory boundaries (must do before single *)
|
||||
@@ -306,6 +314,7 @@ async function runVerificationCommand(
|
||||
const output = execSync(command, {
|
||||
cwd: rootDir,
|
||||
encoding: "utf-8",
|
||||
maxBuffer: VERIFICATION_COMMAND_MAX_BUFFER,
|
||||
timeout: 300_000, // 5 minute timeout for verification commands
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
@@ -318,10 +327,22 @@ async function runVerificationCommand(
|
||||
result.stdout = error.stdout?.toString() || "";
|
||||
result.stderr = error.stderr?.toString() || "";
|
||||
result.exitCode = error.status ?? null;
|
||||
result.success = false;
|
||||
const maxBufferExceeded = error.code === "ENOBUFS"
|
||||
|| error.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER"
|
||||
|| error.message?.includes("maxBuffer");
|
||||
result.success = maxBufferExceeded && result.exitCode === 0;
|
||||
|
||||
if (result.success) {
|
||||
mergerLog.log(`${taskId}: ${type} command succeeded (exit 0, output exceeded buffer)`);
|
||||
await store.logEntry(
|
||||
taskId,
|
||||
`[verification] ${type} command succeeded (exit 0, output exceeded buffer)`,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Build a useful error summary
|
||||
const summary = result.stderr || result.stdout || error.message || "Unknown error";
|
||||
const summary = truncateVerificationOutput(result.stderr || result.stdout || error.message || "Unknown error");
|
||||
mergerLog.error(`${taskId}: ${type} command failed (exit ${result.exitCode}): ${summary.trim()}`);
|
||||
await store.logEntry(
|
||||
taskId,
|
||||
|
||||
Reference in New Issue
Block a user