feat(engine): harden review pipeline with strict scope, build retry, and E2E tests

Improve the plan→review→approve→merge agent pipeline:

- Harden verdict extraction with JSON block parsing and anchored regexes
- Consolidate legacy/new merger conflict APIs into thin deprecated wrappers
- Add configurable strict scope enforcement (strictScopeEnforcement setting)
- Add build retry with timeout to merger (buildRetryCount, buildTimeoutMs)
- Add handleChangesRequested to PrCommentHandler for review feedback loop
- Remove dead code: handleFsChange, processTaskChange, unused imports/fields
- Add E2E multi-verdict sequence tests for the full review pipeline
- Fix unused parameter warnings across engine and core packages

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-04 00:29:34 -07:00
parent 1d45cdb82b
commit cf83579a34
11 changed files with 427 additions and 357 deletions

View File

@@ -317,7 +317,7 @@ export class Database {
/** Tracks transaction nesting depth for savepoint-based nested transactions. */ /** Tracks transaction nesting depth for savepoint-based nested transactions. */
private transactionDepth = 0; private transactionDepth = 0;
constructor(private kbDir: string) { constructor(kbDir: string) {
this.dbPath = join(kbDir, "fusion.db"); this.dbPath = join(kbDir, "fusion.db");
// Ensure .fusion directory exists // Ensure .fusion directory exists

View File

@@ -1,10 +1,10 @@
import { EventEmitter } from "node:events"; import { EventEmitter } from "node:events";
import { execSync } from "node:child_process"; import { execSync } from "node:child_process";
import { appendFile, mkdir, readFile, writeFile, readdir, rename, unlink } from "node:fs/promises"; import { appendFile, mkdir, readFile, writeFile, rename, unlink } from "node:fs/promises";
import { join, sep } from "node:path"; import { join } from "node:path";
import { existsSync, watch, type FSWatcher, readFileSync } from "node:fs"; import { existsSync, watch, type FSWatcher } from "node:fs";
import type { Task, TaskDetail, TaskCreateInput, TaskAttachment, AgentLogEntry, BoardConfig, Column, MergeResult, Settings, GlobalSettings, ProjectSettings, ActivityLogEntry, ActivityEventType } from "./types.js"; import type { Task, TaskDetail, TaskCreateInput, TaskAttachment, AgentLogEntry, BoardConfig, Column, MergeResult, Settings, GlobalSettings, ProjectSettings, ActivityLogEntry, ActivityEventType } from "./types.js";
import { VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS } from "./types.js"; import { VALID_TRANSITIONS, DEFAULT_SETTINGS, GLOBAL_SETTINGS_KEYS } from "./types.js";
import { GlobalSettingsStore } from "./global-settings.js"; import { GlobalSettingsStore } from "./global-settings.js";
import { Database, toJson, toJsonNullable, fromJson } from "./db.js"; import { Database, toJson, toJsonNullable, fromJson } from "./db.js";
import { detectLegacyData, migrateFromLegacy } from "./db-migrate.js"; import { detectLegacyData, migrateFromLegacy } from "./db-migrate.js";
@@ -62,8 +62,6 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
private kbDir: string; private kbDir: string;
private tasksDir: string; private tasksDir: string;
private configPath: string; private configPath: string;
private archiveLogPath: string;
private activityLogPath: string;
/** SQLite database for structured data storage */ /** SQLite database for structured data storage */
private _db: Database | null = null; private _db: Database | null = null;
@@ -101,8 +99,6 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
this.kbDir = join(rootDir, ".fusion"); this.kbDir = join(rootDir, ".fusion");
this.tasksDir = join(this.kbDir, "tasks"); this.tasksDir = join(this.kbDir, "tasks");
this.configPath = join(this.kbDir, "config.json"); this.configPath = join(this.kbDir, "config.json");
this.archiveLogPath = join(this.kbDir, "archive.jsonl");
this.activityLogPath = join(this.kbDir, "activity-log.jsonl");
this.globalSettingsStore = new GlobalSettingsStore(globalSettingsDir); this.globalSettingsStore = new GlobalSettingsStore(globalSettingsDir);
} }
@@ -1004,7 +1000,6 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
task.dependencies = updates.dependencies; task.dependencies = updates.dependencies;
if (hasNewDeps && task.column === "todo") { if (hasNewDeps && task.column === "todo") {
const fromColumn = task.column;
task.column = "triage"; task.column = "triage";
task.status = undefined; task.status = undefined;
task.columnMovedAt = new Date().toISOString(); task.columnMovedAt = new Date().toISOString();
@@ -1371,7 +1366,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
}); });
} }
private collectMergeDetails(id: string, branch: string, task: Task, commitMessage: string): import("./types.js").MergeDetails { private collectMergeDetails(_id: string, _branch: string, task: Task, commitMessage: string): import("./types.js").MergeDetails {
const mergedAt = new Date().toISOString(); const mergedAt = new Date().toISOString();
let commitSha: string | undefined; let commitSha: string | undefined;
let filesChanged: number | undefined; let filesChanged: number | undefined;
@@ -1757,7 +1752,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
// Use a sentinel watcher object so existing code that checks `this.watcher` still works // Use a sentinel watcher object so existing code that checks `this.watcher` still works
try { try {
this.watcher = watch(this.tasksDir, { recursive: true }, (_event, filename) => { this.watcher = watch(this.tasksDir, { recursive: true }, (_event, _filename) => {
// No-op - we use polling now, but keep watcher for API compat // No-op - we use polling now, but keep watcher for API compat
}); });
this.watcher.on("error", () => { this.watcher.on("error", () => {
@@ -1849,86 +1844,6 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
}, this.debounceMs + 100); }, this.debounceMs + 100);
} }
/**
* Handle a raw fs.watch callback. `filename` is relative to tasksDir.
*/
private handleFsChange(filename: string): void {
// We only care about task.json files
const parts = filename.split(sep);
// Normalize for platforms that may use forward slashes
const normalizedParts = parts.length === 1 ? filename.split("/") : parts;
if (normalizedParts.length < 2) return;
const taskId = normalizedParts[0];
const file = normalizedParts[normalizedParts.length - 1];
if (file !== "task.json") return;
if (!/^[A-Z]+-\d+$/.test(taskId)) return;
const fullPath = join(this.tasksDir, taskId, "task.json");
// Check suppression
if (this.recentlyWritten.has(fullPath)) return;
// Debounce per task ID
const existing = this.debounceTimers.get(taskId);
if (existing) clearTimeout(existing);
this.debounceTimers.set(
taskId,
setTimeout(() => {
this.debounceTimers.delete(taskId);
this.processTaskChange(taskId, fullPath).catch(() => {
// Ignore errors (file may have been deleted mid-read)
});
}, this.debounceMs),
);
}
/**
* Read a task.json from disk and diff against the cache to emit the right event.
*/
private async processTaskChange(taskId: string, filePath: string): Promise<void> {
const cached = this.taskCache.get(taskId);
if (!existsSync(filePath)) {
// Task was deleted
if (cached) {
this.taskCache.delete(taskId);
this.emit("task:deleted", cached);
}
return;
}
let task: Task;
try {
const taskDir = join(this.tasksDir, taskId);
task = await this.readTaskJson(taskDir);
} catch {
return; // File not readable or invalid JSON
}
if (!cached) {
// New task
this.taskCache.set(taskId, { ...task });
this.emit("task:created", task);
return;
}
// Check for column change → task:moved
if (cached.column !== task.column) {
const from = cached.column;
this.taskCache.set(taskId, { ...task });
this.emit("task:moved", { task, from, to: task.column });
return;
}
// Check for any other field change → task:updated
if (JSON.stringify(cached) !== JSON.stringify(task)) {
this.taskCache.set(taskId, { ...task });
this.emit("task:updated", task);
}
}
private static ALLOWED_MIME_TYPES = new Set([ private static ALLOWED_MIME_TYPES = new Set([
"image/png", "image/png",
"image/jpeg", "image/jpeg",

View File

@@ -628,7 +628,10 @@ export interface GlobalSettings {
* at the very top of model selection dropdowns, before provider groups. Order is * at the very top of model selection dropdowns, before provider groups. Order is
* preserved - earlier entries appear higher. */ * preserved - earlier entries appear higher. */
favoriteModels?: string[]; favoriteModels?: string[];
/** When true, the dashboard eagerly fetches the latest model catalog from
* the OpenRouter API at startup so the model picker shows all available
* OpenRouter models (not just the static built-in list). Default: true. */
openrouterModelSync?: boolean;
} }
/** /**
@@ -739,6 +742,15 @@ export interface ProjectSettings {
* lock files (ours), generated files (theirs), and trivial whitespace conflicts * lock files (ours), generated files (theirs), and trivial whitespace conflicts
* without spawning an AI agent. Default: true. */ * without spawning an AI agent. Default: true. */
smartConflictResolution?: boolean; smartConflictResolution?: boolean;
/** When true, out-of-scope file changes block merge instead of just logging warnings.
* Useful for teams that want strict enforcement of declared File Scope.
* Default: false (soft guardrail — warnings only). */
strictScopeEnforcement?: boolean;
/** Maximum number of build retry attempts during merge when a build fails with a
* transient error. Default: 0 (no retry). Set to 1 to allow one retry. */
buildRetryCount?: number;
/** Timeout in milliseconds for build commands during merge. Default: 300000 (5 min). */
buildTimeoutMs?: number;
/** When enabled, AI-generated task specifications require manual approval /** When enabled, AI-generated task specifications require manual approval
* before the task can move from triage to todo. Tasks with approved specs * before the task can move from triage to todo. Tasks with approved specs
* remain in triage with status "awaiting-approval" until a user approves * remain in triage with status "awaiting-approval" until a user approves
@@ -834,6 +846,7 @@ export const DEFAULT_GLOBAL_SETTINGS: Required<Pick<GlobalSettings, "themeMode"
ntfyTopic: undefined, ntfyTopic: undefined,
ntfyEvents: ["in-review", "merged", "failed"], ntfyEvents: ["in-review", "merged", "failed"],
ntfyDashboardHost: undefined, ntfyDashboardHost: undefined,
openrouterModelSync: true,
}; };
/** Default values for project-level settings. */ /** Default values for project-level settings. */
@@ -864,6 +877,9 @@ export const DEFAULT_PROJECT_SETTINGS: ProjectSettings = {
defaultPresetBySize: {}, defaultPresetBySize: {},
autoResolveConflicts: true, autoResolveConflicts: true,
smartConflictResolution: true, smartConflictResolution: true,
strictScopeEnforcement: false,
buildRetryCount: 0,
buildTimeoutMs: 300_000,
requirePlanApproval: false, requirePlanApproval: false,
taskStuckTimeoutMs: undefined, taskStuckTimeoutMs: undefined,
autoUnpauseEnabled: true, autoUnpauseEnabled: true,
@@ -908,6 +924,7 @@ export const GLOBAL_SETTINGS_KEYS: ReadonlyArray<keyof GlobalSettings> = [
"ntfyEvents", "ntfyEvents",
"ntfyDashboardHost", "ntfyDashboardHost",
"defaultProjectId", "defaultProjectId",
"openrouterModelSync",
] as const; ] as const;
/** Keys that belong to the project settings scope. */ /** Keys that belong to the project settings scope. */
@@ -971,6 +988,8 @@ export interface MergeResult extends MergeDetails {
worktreeRemoved: boolean; worktreeRemoved: boolean;
branchDeleted: boolean; branchDeleted: boolean;
error?: string; error?: string;
/** Internal flag to track if a build retry has been attempted. Not persisted. */
_buildRetried?: boolean;
} }
export const COLUMN_LABELS: Record<Column, string> = { export const COLUMN_LABELS: Record<Column, string> = {

View File

@@ -19,7 +19,7 @@
"build": "tsc", "build": "tsc",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "vitest run --exclude src/executor.test.ts && pnpm run test:executor", "test": "vitest run --exclude src/executor.test.ts && pnpm run test:executor",
"test:executor": "vitest run src/executor.test.ts -t \"TaskExecutor with semaphore|TaskExecutor worktreeInitCommand|TaskExecutor worktree naming\" && vitest run src/executor.test.ts -t \"TaskExecutor worktree recovery\" && vitest run src/executor.test.ts -t \"TaskExecutor dependency-based worktree creation\" && vitest run src/executor.test.ts -t \"TaskExecutor worktree pool integration|WorktreePool capacity|Merger worktree pool integration\" && vitest run src/executor.test.ts -t \"buildExecutionPrompt|summarizeToolArgs|TaskExecutor pause behavior|TaskExecutor global pause behavior|TaskExecutor enginePaused soft pause\" && vitest run src/executor.test.ts -t \"Code review verdict|RETHINK verdict handling|Plan RETHINK verdict handling|task_add_dep tool|TaskExecutor usage limit detection|Per-task model overrides|Invalid transition error handling|TaskExecutor task_done with summary|Workflow Steps Execution|Real-time steering injection\"" "test:executor": "vitest run src/executor.test.ts -t \"TaskExecutor with semaphore|TaskExecutor worktreeInitCommand|TaskExecutor worktree naming\" && vitest run src/executor.test.ts -t \"TaskExecutor worktree recovery\" && vitest run src/executor.test.ts -t \"TaskExecutor dependency-based worktree creation\" && vitest run src/executor.test.ts -t \"TaskExecutor worktree pool integration|WorktreePool capacity|Merger worktree pool integration\" && vitest run src/executor.test.ts -t \"buildExecutionPrompt|summarizeToolArgs|TaskExecutor pause behavior|TaskExecutor global pause behavior|TaskExecutor enginePaused soft pause\" && vitest run src/executor.test.ts -t \"Code review verdict|RETHINK verdict handling|Plan RETHINK verdict handling|E2E review pipeline|task_add_dep tool|TaskExecutor usage limit detection|Per-task model overrides|Invalid transition error handling|TaskExecutor task_done with summary|Workflow Steps Execution|Real-time steering injection\""
}, },
"dependencies": { "dependencies": {
"@fusion/core": "workspace:*", "@fusion/core": "workspace:*",

View File

@@ -3261,6 +3261,237 @@ describe("Plan RETHINK verdict handling", () => {
}); });
}); });
// ── E2E review pipeline sequence tests ─────────────────────────────
describe("E2E review pipeline — multi-verdict sequence", () => {
/**
* Exercises the full review pipeline within a single task execution:
* plan review → APPROVE
* code review → REVISE (blocked)
* code review → APPROVE (unblocked)
* step done → success
*
* Verifies that verdicts compose correctly across the full lifecycle.
*/
function makeStepResult(stepIndex: number, status: string) {
const steps = Array.from({ length: 3 }, (_, i) => ({
name: [`Preflight`, `Implement`, `Tests`][i],
status: i === stepIndex ? status : i < stepIndex ? "done" : "pending",
}));
return { steps };
}
async function captureE2ETools(store: any) {
let capturedTools: any[] = [];
const mockSessionManager = {
getLeafId: vi.fn().mockReturnValue("e2e-checkpoint"),
branchWithSummary: vi.fn(),
};
const mockNavigateTree = vi.fn().mockResolvedValue({ cancelled: false });
const mockSession = {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
sessionManager: mockSessionManager,
navigateTree: mockNavigateTree,
};
mockedCreateHaiAgent.mockImplementation(async (opts: any) => {
capturedTools = opts.customTools || [];
return { session: mockSession } as any;
});
const executor = new TaskExecutor(store, "/tmp/test");
await executor.execute({
id: "FN-E2E",
title: "E2E Test",
description: "E2E pipeline test",
column: "in-progress",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
const tools: Record<string, any> = {};
for (const t of capturedTools) {
tools[t.name] = t.execute;
}
return { tools, mockNavigateTree, mockSessionManager };
}
beforeEach(() => {
vi.clearAllMocks();
mockedExistsSync.mockReturnValue(true);
});
it("full sequence: plan APPROVE → code REVISE (blocked) → code APPROVE (unblocked) → done", async () => {
const store = createMockStore();
store.updateStep.mockImplementation(async (_id: string, step: number, status: string) =>
makeStepResult(step, status),
);
const { tools } = await captureE2ETools(store);
// Step 1: Start the step
await tools.task_update("u1", { step: 1, status: "in-progress" });
// Step 2: Plan review → APPROVE (advisory, no blocking)
mockedReviewStep.mockResolvedValue({ verdict: "APPROVE", review: "Good plan", summary: "Approved" });
const planResult = await tools.review_step("r1", {
step: 1, type: "plan", step_name: "Implement",
});
expect(planResult.content[0].text).toBe("APPROVE");
// Step 3: Code review → REVISE (should block advancement)
mockedReviewStep.mockResolvedValue({
verdict: "REVISE", review: "Missing error handling in fetchUser()", summary: "Needs fixes",
});
const reviseResult = await tools.review_step("r2", {
step: 1, type: "code", step_name: "Implement", baseline: "sha-1",
});
expect(reviseResult.content[0].text).toContain("cannot be marked done");
// Step 4: Attempt to mark done — should be blocked
const blockedResult = await tools.task_update("u2", { step: 1, status: "done" });
expect(blockedResult.content[0].text).toContain("Cannot mark Step 1 as done");
// Step 5: Fix issues, re-submit code review → APPROVE
mockedReviewStep.mockResolvedValue({
verdict: "APPROVE", review: "Error handling added correctly", summary: "All good",
});
const approveResult = await tools.review_step("r3", {
step: 1, type: "code", step_name: "Implement", baseline: "sha-2",
});
expect(approveResult.content[0].text).toBe("APPROVE");
// Step 6: Now marking done should succeed
const doneResult = await tools.task_update("u3", { step: 1, status: "done" });
expect(doneResult.content[0].text).toContain("→ done");
});
it("full sequence: code RETHINK → git reset + session rewind → retry with APPROVE → done", async () => {
const store = createMockStore();
store.updateStep.mockImplementation(async (_id: string, step: number, status: string) =>
makeStepResult(step, status),
);
const { tools, mockNavigateTree } = await captureE2ETools(store);
// Step 1: Start the step (captures checkpoint)
await tools.task_update("u1", { step: 1, status: "in-progress" });
// Step 2: Code review → RETHINK (rewind everything)
mockedReviewStep.mockResolvedValue({
verdict: "RETHINK", review: "Using polling instead of events is wrong", summary: "Bad approach",
});
const rethinkResult = await tools.review_step("r1", {
step: 1, type: "code", step_name: "Implement", baseline: "sha-bad",
});
// Verify RETHINK outcomes
expect(rethinkResult.content[0].text).toContain("RETHINK");
expect(rethinkResult.content[0].text).toContain("Do NOT repeat the rejected strategy");
expect(mockedExecSync).toHaveBeenCalledWith(
"git reset --hard sha-bad",
expect.objectContaining({ cwd: expect.any(String) }),
);
expect(mockNavigateTree).toHaveBeenCalledWith("e2e-checkpoint", { summarize: false });
expect(store.updateStep).toHaveBeenCalledWith("FN-E2E", 1, "pending");
// Step 3: Restart the step (new approach)
await tools.task_update("u2", { step: 1, status: "in-progress" });
// Step 4: Code review → APPROVE on second attempt
mockedReviewStep.mockResolvedValue({
verdict: "APPROVE", review: "Event-driven approach is correct", summary: "Approved",
});
const approveResult = await tools.review_step("r2", {
step: 1, type: "code", step_name: "Implement", baseline: "sha-good",
});
expect(approveResult.content[0].text).toBe("APPROVE");
// Step 5: Mark done — should succeed (no REVISE blocking)
const doneResult = await tools.task_update("u3", { step: 1, status: "done" });
expect(doneResult.content[0].text).toContain("→ done");
});
it("multi-step pipeline: step 1 APPROVE, step 2 REVISE, step 1 remains unaffected", async () => {
const store = createMockStore();
store.updateStep.mockImplementation(async (_id: string, step: number, status: string) =>
makeStepResult(step, status),
);
const { tools } = await captureE2ETools(store);
// Step 1: Complete with APPROVE
await tools.task_update("u1", { step: 1, status: "in-progress" });
mockedReviewStep.mockResolvedValue({ verdict: "APPROVE", review: "OK", summary: "Good" });
await tools.review_step("r1", { step: 1, type: "code", step_name: "Implement", baseline: "sha-1" });
const step1Done = await tools.task_update("u2", { step: 1, status: "done" });
expect(step1Done.content[0].text).toContain("→ done");
// Step 2: Gets REVISE
await tools.task_update("u3", { step: 2, status: "in-progress" });
mockedReviewStep.mockResolvedValue({ verdict: "REVISE", review: "Tests insufficient", summary: "Bad" });
await tools.review_step("r2", { step: 2, type: "code", step_name: "Tests", baseline: "sha-2" });
// Step 2 blocked
const step2Blocked = await tools.task_update("u4", { step: 2, status: "done" });
expect(step2Blocked.content[0].text).toContain("Cannot mark Step 2 as done");
// Step 1 remains unaffected — if agent tries to re-update step 1, it still works
// (step isolation: REVISE on step 2 does not affect step 1)
});
it("plan RETHINK followed by plan APPROVE allows code phase to proceed", async () => {
const store = createMockStore();
store.updateStep.mockImplementation(async (_id: string, step: number, status: string) =>
makeStepResult(step, status),
);
const { tools, mockNavigateTree } = await captureE2ETools(store);
// Start step
await tools.task_update("u1", { step: 1, status: "in-progress" });
// Plan review → RETHINK
mockedReviewStep.mockResolvedValue({
verdict: "RETHINK", review: "Plan ignores edge cases", summary: "Bad plan",
});
const rethinkResult = await tools.review_step("r1", {
step: 1, type: "plan", step_name: "Implement",
});
expect(rethinkResult.content[0].text).toContain("Your plan was rejected");
// Verify plan RETHINK does NOT trigger git reset
const gitResetCalls = mockedExecSync.mock.calls.filter(
(c) => typeof c[0] === "string" && (c[0] as string).includes("git reset --hard"),
);
expect(gitResetCalls).toHaveLength(0);
// Session was rewound
expect(mockNavigateTree).toHaveBeenCalled();
// Restart step with new plan
await tools.task_update("u2", { step: 1, status: "in-progress" });
// Plan review → APPROVE
mockedReviewStep.mockResolvedValue({ verdict: "APPROVE", review: "Good plan", summary: "Approved" });
await tools.review_step("r2", { step: 1, type: "plan", step_name: "Implement" });
// Code phase: APPROVE directly
mockedReviewStep.mockResolvedValue({ verdict: "APPROVE", review: "Clean code", summary: "Good" });
await tools.review_step("r3", { step: 1, type: "code", step_name: "Implement", baseline: "sha-1" });
// Mark done — should succeed (plan reviews are advisory, code APPROVE clears the path)
const doneResult = await tools.task_update("u3", { step: 1, status: "done" });
expect(doneResult.content[0].text).toContain("→ done");
});
});
// ── task_add_dep tool tests ────────────────────────────────────────── // ── task_add_dep tool tests ──────────────────────────────────────────
describe("task_add_dep tool", () => { describe("task_add_dep tool", () => {

View File

@@ -844,7 +844,11 @@ describe("detectResolvableConflicts", () => {
}); });
it("detects coverage/ paths as generated files with 'theirs' strategy", () => { it("detects coverage/ paths as generated files with 'theirs' strategy", () => {
mockedExecSync.mockReturnValue("coverage/lcov-report/index.html\n"); mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("git diff --name-only")) return "coverage/lcov.info\n";
return Buffer.from("");
});
const result = detectResolvableConflicts("/tmp/root"); const result = detectResolvableConflicts("/tmp/root");
expect(result[0]).toMatchObject({ expect(result[0]).toMatchObject({
@@ -855,7 +859,13 @@ describe("detectResolvableConflicts", () => {
}); });
it("marks regular source files as complex conflicts", () => { it("marks regular source files as complex conflicts", () => {
mockedExecSync.mockReturnValue("src/components/App.tsx\n"); mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("git diff --name-only")) return "src/components/App.tsx\n";
// git diff-tree for trivial detection — return real diff content to indicate non-trivial
if (cmdStr.includes("diff-tree")) return "+real change\n-old line\n";
return Buffer.from("");
});
const result = detectResolvableConflicts("/tmp/root"); const result = detectResolvableConflicts("/tmp/root");
expect(result[0]).toMatchObject({ expect(result[0]).toMatchObject({
@@ -866,9 +876,14 @@ describe("detectResolvableConflicts", () => {
}); });
it("handles multiple conflicted files with mixed categories", () => { it("handles multiple conflicted files with mixed categories", () => {
mockedExecSync.mockReturnValue( mockedExecSync.mockImplementation((cmd: any) => {
"package-lock.json\nsrc/components/App.tsx\ndist/bundle.js\n", const cmdStr = String(cmd);
); if (cmdStr.includes("git diff --name-only"))
return "package-lock.json\nsrc/components/App.tsx\ndist/bundle.js\n";
// git diff-tree for trivial detection — return real diff for source files
if (cmdStr.includes("diff-tree")) return "+real change\n-old line\n";
return Buffer.from("");
});
const result = detectResolvableConflicts("/tmp/root"); const result = detectResolvableConflicts("/tmp/root");
expect(result).toHaveLength(3); expect(result).toHaveLength(3);
@@ -999,23 +1014,19 @@ describe("resolveConflicts", () => {
// ── Trivial Conflict Detection Tests ────────────────────────────────────── // ── Trivial Conflict Detection Tests ──────────────────────────────────────
describe("trivial conflict detection (isTrivialConflict via detectResolvableConflicts)", () => { describe("trivial conflict detection (isTrivialWhitespaceConflict via detectResolvableConflicts)", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
it("detects whitespace-only conflicts as trivial", () => { it("detects whitespace-only conflicts as trivial", () => {
mockedExecSync.mockReturnValue("src/utils.ts\n"); mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
const fileContent = `function foo() { if (cmdStr.includes("git diff --name-only")) return "src/utils.ts\n";
<<<<<<< HEAD // git diff-tree with -w returns empty = trivial whitespace
return 1; if (cmdStr.includes("diff-tree")) return "";
======= return Buffer.from("");
return 1; });
>>>>>>> feature-branch
}`;
mockedReadFileSync.mockReturnValue(fileContent);
const result = detectResolvableConflicts("/tmp/root"); const result = detectResolvableConflicts("/tmp/root");
expect(result).toHaveLength(1); expect(result).toHaveLength(1);
@@ -1027,34 +1038,14 @@ describe("trivial conflict detection (isTrivialConflict via detectResolvableConf
}); });
}); });
it("detects conflicts with different line endings as trivial", () => {
mockedExecSync.mockReturnValue("src/utils.ts\n");
// Same content but different line ending style - CRLF vs LF
const fileContent = "const x = 1;\r\n<<<<<<< HEAD\r\nconst y = 2;\r\n=======\r\nconst y = 2;\n>>>>>>> feature-branch";
mockedReadFileSync.mockReturnValue(fileContent);
const result = detectResolvableConflicts("/tmp/root");
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
autoResolvable: true,
reason: "trivial",
});
});
it("marks conflicts with actual content differences as complex", () => { it("marks conflicts with actual content differences as complex", () => {
mockedExecSync.mockReturnValue("src/utils.ts\n"); mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
const fileContent = `function foo() { if (cmdStr.includes("git diff --name-only")) return "src/utils.ts\n";
<<<<<<< HEAD // git diff-tree returns real content changes = non-trivial
return 1; if (cmdStr.includes("diff-tree")) return "+return 2;\n-return 1;\n";
======= return Buffer.from("");
return 2; });
>>>>>>> feature-branch
}`;
mockedReadFileSync.mockReturnValue(fileContent);
const result = detectResolvableConflicts("/tmp/root"); const result = detectResolvableConflicts("/tmp/root");
expect(result).toHaveLength(1); expect(result).toHaveLength(1);
@@ -1065,53 +1056,14 @@ describe("trivial conflict detection (isTrivialConflict via detectResolvableConf
}); });
}); });
it("handles multiple conflict sections - all trivial", () => {
mockedExecSync.mockReturnValue("src/utils.ts\n");
const fileContent = `function foo() {
<<<<<<< HEAD
return 1;
=======
return 1;
>>>>>>> feature-branch
}
function bar() {
<<<<<<< Updated upstream
const x = 2;
=======
const x = 2;
>>>>>>> feature-branch
}`;
mockedReadFileSync.mockReturnValue(fileContent);
const result = detectResolvableConflicts("/tmp/root");
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
autoResolvable: true,
reason: "trivial",
});
});
it("handles multiple conflict sections - one non-trivial makes complex", () => { it("handles multiple conflict sections - one non-trivial makes complex", () => {
mockedExecSync.mockReturnValue("src/utils.ts\n"); mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
const fileContent = `function foo() { if (cmdStr.includes("git diff --name-only")) return "src/utils.ts\n";
<<<<<<< HEAD // Real diff content = non-trivial
return 1; if (cmdStr.includes("diff-tree")) return "+const x = 999;\n-const x = 2;\n";
======= return Buffer.from("");
return 1; });
>>>>>>> feature-branch
}
function bar() {
<<<<<<< Updated upstream
const x = 2;
=======
const x = 999;
>>>>>>> feature-branch
}`;
mockedReadFileSync.mockReturnValue(fileContent);
const result = detectResolvableConflicts("/tmp/root"); const result = detectResolvableConflicts("/tmp/root");
expect(result).toHaveLength(1); expect(result).toHaveLength(1);
@@ -1121,10 +1073,12 @@ function bar() {
}); });
}); });
it("handles file read errors as complex conflicts", () => { it("handles git command errors as complex conflicts", () => {
mockedExecSync.mockReturnValue("src/utils.ts\n"); mockedExecSync.mockImplementation((cmd: any) => {
mockedReadFileSync.mockImplementation(() => { const cmdStr = String(cmd);
throw new Error("ENOENT: no such file"); if (cmdStr.includes("git diff --name-only")) return "src/utils.ts\n";
if (cmdStr.includes("diff-tree")) throw new Error("git error");
return Buffer.from("");
}); });
const result = detectResolvableConflicts("/tmp/root"); const result = detectResolvableConflicts("/tmp/root");

View File

@@ -1,6 +1,6 @@
import { execSync } from "node:child_process"; import { execSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs"; import { existsSync } from "node:fs";
import type { TaskStore, Task, MergeResult } from "@fusion/core"; import type { TaskStore, MergeResult } from "@fusion/core";
import { createKbAgent, promptWithFallback } from "./pi.js"; import { createKbAgent, promptWithFallback } from "./pi.js";
import type { WorktreePool } from "./worktree-pool.js"; import type { WorktreePool } from "./worktree-pool.js";
import { AgentLogger } from "./agent-logger.js"; import { AgentLogger } from "./agent-logger.js";
@@ -184,12 +184,15 @@ function matchesScope(filePath: string, scopePatterns: string[]): boolean {
/** /**
* Validate that the diff stays within the task's declared File Scope. * Validate that the diff stays within the task's declared File Scope.
* Returns warnings for out-of-scope changes, especially large deletions. * Returns warnings for out-of-scope changes, especially large deletions.
* This is a soft guardrail — warnings are logged but do not block merge. *
* When `strict` is true, throws an error on scope violations instead of
* just returning warnings (hard guardrail that blocks merge).
*/ */
export async function validateDiffScope( export async function validateDiffScope(
store: TaskStore, store: TaskStore,
taskId: string, taskId: string,
diffStat: string, diffStat: string,
strict: boolean = false,
): Promise<DiffScopeResult> { ): Promise<DiffScopeResult> {
const result: DiffScopeResult = { warnings: [], outOfScopeFiles: [], largeOutOfScopeDeletions: [] }; const result: DiffScopeResult = { warnings: [], outOfScopeFiles: [], largeOutOfScopeDeletions: [] };
@@ -239,6 +242,13 @@ export async function validateDiffScope(
); );
} }
// In strict mode, scope violations block the merge
if (strict && result.warnings.length > 0) {
throw new Error(
`Scope enforcement failed for ${taskId}: ${result.warnings.join("; ")}`,
);
}
return result; return result;
} }
@@ -364,189 +374,64 @@ export function resolveTrivialWhitespace(filePath: string, cwd: string): void {
} }
} }
// TODO(KB-023 Step 4): Consolidate with new API above. The following legacy API // Legacy types re-exported for backward compatibility (tests may reference them)
// (ConflictCategory, detectResolvableConflicts, isTrivialConflict, autoResolveFile, /** @deprecated Use ConflictType instead */
// resolveConflicts) duplicates functionality with the new Step 2 API. Migrate
// callers to use classifyConflict, resolveWithOurs, resolveWithTheirs, etc.
/** Conflict category for a file with merge conflicts - LEGACY API, see above */
export type ConflictResolution = "ours" | "theirs"; export type ConflictResolution = "ours" | "theirs";
/** @deprecated Use classifyConflict + getConflictedFiles instead */
export interface ConflictCategory { export interface ConflictCategory {
filePath: string; filePath: string;
/** Whether this conflict can be auto-resolved without AI */
autoResolvable: boolean; autoResolvable: boolean;
/** Resolution strategy: 'ours' = take current branch, 'theirs' = take incoming branch */
strategy?: ConflictResolution; strategy?: ConflictResolution;
/** Reason for the categorization */
reason: "lock-file" | "generated-file" | "trivial" | "complex"; reason: "lock-file" | "generated-file" | "trivial" | "complex";
} }
/** Lock file patterns that should auto-resolve using "ours" (keep current branch's version) */
const LOCK_FILE_PATTERNS = [
/package-lock\.json$/,
/pnpm-lock\.yaml$/,
/yarn\.lock$/,
/Gemfile\.lock$/,
/Cargo\.lock$/,
/composer\.lock$/,
/poetry\.lock$/,
/bun\.lockb$/,
/go\.sum$/,
];
/** Generated file patterns that should auto-resolve using "theirs" (keep branch's fresh generation) */
const GENERATED_FILE_PATTERNS = [
/\.gen\.(ts|js|tsx|jsx|mjs|cjs)$/,
/\.min\.(js|css)$/,
/dist\//,
/build\//,
/coverage\//,
/\.next\//,
/\.nuxt\//,
/\.output\//,
/\.cache\//,
/out\//,
/__generated__\//,
/generated\//,
];
/** /**
* Detect and categorize merge conflicts in the working directory. * Detect and categorize merge conflicts. Delegates to the new classifyConflict API.
* Returns array of ConflictCategory for each conflicted file. * @deprecated Use getConflictedFiles() + classifyConflict() instead.
*/ */
export function detectResolvableConflicts(rootDir: string): ConflictCategory[] { export function detectResolvableConflicts(rootDir: string): ConflictCategory[] {
try { const files = getConflictedFiles(rootDir);
// Get list of conflicted files return files.map((filePath): ConflictCategory => {
const conflictedOutput = execSync("git diff --name-only --diff-filter=U", { const type = classifyConflict(filePath, rootDir);
cwd: rootDir, switch (type) {
encoding: "utf-8", case "lockfile-ours":
}).trim(); return { filePath, autoResolvable: true, strategy: "ours", reason: "lock-file" };
case "generated-theirs":
if (!conflictedOutput) { return { filePath, autoResolvable: true, strategy: "theirs", reason: "generated-file" };
return []; case "trivial-whitespace":
return { filePath, autoResolvable: true, strategy: "ours", reason: "trivial" };
case "complex":
return { filePath, autoResolvable: false, reason: "complex" };
} }
});
const conflictedFiles = conflictedOutput.split("\n").filter(Boolean);
return conflictedFiles.map((filePath): ConflictCategory => {
// Check for lock files - always take "ours" (current branch's version)
if (LOCK_FILE_PATTERNS.some((pattern) => pattern.test(filePath))) {
return {
filePath,
autoResolvable: true,
strategy: "ours",
reason: "lock-file",
};
}
// Check for generated files - take "theirs" (keep branch's fresh generation)
if (GENERATED_FILE_PATTERNS.some((pattern) => pattern.test(filePath))) {
return {
filePath,
autoResolvable: true,
strategy: "theirs",
reason: "generated-file",
};
}
// Check for trivial conflicts (whitespace-only)
if (isTrivialConflict(filePath, rootDir)) {
return {
filePath,
autoResolvable: true,
strategy: "ours", // Either would work, but ours is current branch
reason: "trivial",
};
}
// Complex conflicts require AI intervention
return {
filePath,
autoResolvable: false,
reason: "complex",
};
});
} catch (error) {
mergerLog.error(`Failed to detect conflicts: ${error}`);
return [];
}
}
/**
* Check if a conflicted file has only trivial changes (whitespace-only differences).
* Reads the working directory file and compares the conflict sections.
*/
function isTrivialConflict(filePath: string, rootDir: string): boolean {
try {
const fullPath = `${rootDir}/${filePath}`;
const content = readFileSync(fullPath, "utf-8");
// Look for conflict markers - support any text after <<<<<<< (HEAD, ours, Updated upstream, etc.)
const conflictRegex = /<<<<<<<\s+.+?[\s\S]*?^=======([\s\S]*?)^>>>>>>>\s+/gm;
let hasConflicts = false;
for (const match of content.matchAll(conflictRegex)) {
hasConflicts = true;
const fullMatch = match[0];
const theirsContent = match[1];
// Extract "ours" content (between <<<<<<< line and ======= line)
const oursMatch = fullMatch.match(/<<<<<<<\s+.+?\n([\s\S]*?)\n=======/);
if (!oursMatch) continue;
const oursContent = oursMatch[1];
// Normalize: remove all whitespace and compare
const oursNormalized = oursContent.replace(/\s+/g, "");
const theirsNormalized = theirsContent.replace(/\s+/g, "");
// If content is the same after stripping whitespace, it's trivial
if (oursNormalized !== theirsNormalized) {
return false; // Real content difference found
}
}
return hasConflicts; // Only trivial if we found conflicts and they're all trivial
} catch {
return false; // On error, assume complex
}
} }
/** /**
* Auto-resolve a single file using git checkout --ours or --theirs. * Auto-resolve a single file using git checkout --ours or --theirs.
* Stages the resolved file. * @deprecated Use resolveWithOurs() or resolveWithTheirs() instead.
*/ */
export function autoResolveFile( export function autoResolveFile(
filePath: string, filePath: string,
resolution: ConflictResolution, resolution: ConflictResolution,
rootDir: string, rootDir: string,
): void { ): void {
try { if (resolution === "ours") {
execSync(`git checkout --${resolution} "${filePath}"`, { resolveWithOurs(filePath, rootDir);
cwd: rootDir, } else {
stdio: "pipe", resolveWithTheirs(filePath, rootDir);
});
execSync(`git add "${filePath}"`, {
cwd: rootDir,
stdio: "pipe",
});
mergerLog.log(`Auto-resolved ${filePath} using --${resolution}`);
} catch (error) {
throw new Error(`Failed to auto-resolve ${filePath}: ${error}`);
} }
} }
/** /**
* Auto-resolve all resolvable conflicts from the categorization. * Auto-resolve all resolvable conflicts from the categorization.
* Returns the list of remaining complex conflicts that need AI resolution. * @deprecated Use classifyConflict + resolveWithOurs/resolveWithTheirs instead.
*/ */
export function resolveConflicts( export function resolveConflicts(
categories: ConflictCategory[], categories: ConflictCategory[],
rootDir: string, rootDir: string,
): string[] { ): string[] {
const remainingComplex: string[] = []; const remainingComplex: string[] = [];
for (const category of categories) { for (const category of categories) {
if (category.autoResolvable && category.strategy) { if (category.autoResolvable && category.strategy) {
autoResolveFile(category.filePath, category.strategy, rootDir); autoResolveFile(category.filePath, category.strategy, rootDir);
@@ -554,7 +439,6 @@ export function resolveConflicts(
remainingComplex.push(category.filePath); remainingComplex.push(category.filePath);
} }
} }
return remainingComplex; return remainingComplex;
} }
@@ -761,13 +645,18 @@ export async function aiMergeTask(
// 4b. Validate diff scope against task's declared File Scope // 4b. Validate diff scope against task's declared File Scope
try { try {
const scopeResult = await validateDiffScope(store, taskId, diffStat); const scopeResult = await validateDiffScope(store, taskId, diffStat, settings.strictScopeEnforcement);
for (const warning of scopeResult.warnings) { for (const warning of scopeResult.warnings) {
mergerLog.warn(`${taskId}: ${warning}`); mergerLog.warn(`${taskId}: ${warning}`);
await store.logEntry(taskId, warning); await store.logEntry(taskId, warning);
} }
} catch { } catch (scopeError: any) {
// Scope validation is best-effort — never block merge on validation failure if (settings.strictScopeEnforcement && scopeError.message?.includes("Scope enforcement failed")) {
// Strict mode — block the merge
await store.logEntry(taskId, `Merge blocked: ${scopeError.message}`);
throw scopeError;
}
// Soft mode — scope validation is best-effort
} }
// 5. Execute merge with retry logic // 5. Execute merge with retry logic
@@ -814,11 +703,22 @@ export async function aiMergeTask(
return false; return false;
} catch (error: any) { } catch (error: any) {
// Check if it's a build verification failure - don't retry, propagate immediately // Check if it's a build verification failure
if (error.message?.includes("Build verification failed")) { if (error.message?.includes("Build verification failed")) {
throw error; // Fatal - don't retry build failures const buildRetryCount = settings.buildRetryCount ?? 0;
if (buildRetryCount > 0 && !result._buildRetried) {
// Allow one build retry — reset merge state and re-attempt same strategy
mergerLog.log(`${taskId}: build failed, retrying (${buildRetryCount} retry allowed)...`);
await store.logEntry(taskId, "Build failed — retrying merge attempt");
result._buildRetried = true;
try {
execSync("git reset --merge", { cwd: rootDir, stdio: "pipe" });
} catch { /* ignore cleanup errors */ }
return false; // Retry
}
throw error; // No retries left — fatal
} }
// Clean up on error before potentially rethrowing or retrying // Clean up on error before potentially rethrowing or retrying
if (attemptNum < 3 && smartConflictResolution) { if (attemptNum < 3 && smartConflictResolution) {
mergerLog.log(`${taskId}: attempt ${attemptNum} error, cleaning up for retry...`); mergerLog.log(`${taskId}: attempt ${attemptNum} error, cleaning up for retry...`);

View File

@@ -151,6 +151,45 @@ export class PrCommentHandler {
return lines.join("\n"); return lines.join("\n");
} }
/**
* Handle "changes requested" PR review state.
* Moves the task back to in-progress with reviewer feedback as a steering comment,
* closing the feedback loop so the agent can address the requested changes.
*/
async handleChangesRequested(
taskId: string,
prInfo: PrInfo,
reviewerLogin: string,
reviewBody: string,
): Promise<void> {
try {
const task = await this.store.getTask(taskId);
if (task.column !== "in-review") {
prMonitorLog.log(`Task ${taskId} not in-review (${task.column}), skipping changes-requested handling`);
return;
}
// Add reviewer feedback as a steering comment
const feedbackText = [
`**Changes Requested** by @${reviewerLogin} on PR #${prInfo.number}`,
"",
reviewBody ? reviewBody.slice(0, 800) : "(no review body)",
"",
"Please address the requested changes and update the PR.",
].join("\n");
await this.store.addTaskComment(taskId, feedbackText, "agent");
await this.store.moveTask(taskId, "in-progress");
await this.store.logEntry(
taskId,
`PR #${prInfo.number}: changes requested by @${reviewerLogin} — moved back to in-progress`,
);
prMonitorLog.log(`Task ${taskId} moved to in-progress after changes requested on PR #${prInfo.number}`);
} catch (err) {
prMonitorLog.error(`Failed to handle changes-requested for ${taskId}:`, err);
}
}
/** /**
* Create a follow-up task when a PR is closed with unaddressed feedback. * Create a follow-up task when a PR is closed with unaddressed feedback.
* This is called when a PR is merged or closed. * This is called when a PR is merged or closed.

View File

@@ -267,7 +267,7 @@ function buildReviewRequest(
stepName: string, stepName: string,
reviewType: ReviewType, reviewType: ReviewType,
promptContent: string, promptContent: string,
cwd: string, _cwd: string,
baseline?: string, baseline?: string,
): string { ): string {
const parts = [ const parts = [
@@ -338,22 +338,34 @@ function buildReviewRequest(
} }
function extractVerdict(review: string): ReviewVerdict { function extractVerdict(review: string): ReviewVerdict {
// Look for "### Verdict: APPROVE" or "**Verdict: REVISE**" or similar // Strategy 1: Look for a JSON verdict block (structured output)
const verdictMatch = review.match( // Matches: ```json\n{"verdict": "APPROVE"}\n``` or inline {"verdict":"REVISE"}
/(?:###?\s*|[*_]{1,2})Verdict[:\s]*[*_]{0,2}\s*(APPROVE|REVISE|RETHINK)/i, const jsonMatch = review.match(
/\{\s*"verdict"\s*:\s*"(APPROVE|REVISE|RETHINK)"\s*\}/i,
); );
if (verdictMatch) { if (jsonMatch) {
return verdictMatch[1].toUpperCase() as ReviewVerdict; reviewerLog.log(`Verdict extracted via JSON block: ${jsonMatch[1].toUpperCase()}`);
return jsonMatch[1].toUpperCase() as ReviewVerdict;
} }
// Fallback: look for a standalone verdict line like "Verdict: APPROVE" // Strategy 2: Look for verdict in a heading line (### Verdict: APPROVE, **Verdict: REVISE**)
// Only match lines that START with a verdict pattern to avoid matching keywords in body text
const headingMatch = review.match(
/^[>\s]*(?:###?\s*|[*_]{1,2})Verdict[:\s]*[*_]{0,2}\s*(APPROVE|REVISE|RETHINK)\b/im,
);
if (headingMatch) {
return headingMatch[1].toUpperCase() as ReviewVerdict;
}
// Strategy 3: Standalone verdict line like "Verdict: APPROVE" or "Decision: REVISE"
const lineFallback = review.match( const lineFallback = review.match(
/^[>\s]*(?:verdict|decision)[:\s]+(APPROVE|REVISE|RETHINK)\b/im, /^[>\s]*(?:verdict|decision)\s*[-:]\s*(APPROVE|REVISE|RETHINK)\b/im,
); );
if (lineFallback) { if (lineFallback) {
return lineFallback[1].toUpperCase() as ReviewVerdict; return lineFallback[1].toUpperCase() as ReviewVerdict;
} }
reviewerLog.warn(`Could not extract verdict from review (${review.length} chars). Returning UNAVAILABLE.`);
return "UNAVAILABLE"; return "UNAVAILABLE";
} }

View File

@@ -460,7 +460,7 @@ export class InProcessRuntime
/** /**
* Record task completion in CentralCore. * Record task completion in CentralCore.
*/ */
private async recordTaskCompletion(taskId: string, success: boolean): Promise<void> { private async recordTaskCompletion(_taskId: string, success: boolean): Promise<void> {
try { try {
// Estimate duration (simplified - in reality, we'd track start time) // Estimate duration (simplified - in reality, we'd track start time)
const durationMs = 0; // Placeholder const durationMs = 0; // Placeholder

View File

@@ -136,7 +136,7 @@ export class StuckTaskDetector {
* - Moves the task back to "todo" (preserving step progress) * - Moves the task back to "todo" (preserving step progress)
* - Invokes the onStuck callback * - Invokes the onStuck callback
*/ */
async killAndRetry(taskId: string, timeoutMs: number): Promise<void> { async killAndRetry(taskId: string, _timeoutMs: number): Promise<void> {
const entry = this.tracked.get(taskId); const entry = this.tracked.get(taskId);
if (!entry) return; if (!entry) return;