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:
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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> = {
|
||||||
|
|||||||
@@ -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:*",
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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,9 +703,20 @@ 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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user