From a302b12c76b998c354b3da83847d58d8ce3ad214 Mon Sep 17 00:00:00 2001 From: Fusion Date: Wed, 13 May 2026 14:07:47 -0700 Subject: [PATCH] =?UTF-8?q?test(FN-4379):=20complete=20Step=205=20?= =?UTF-8?q?=E2=80=94=20real-git=20orphan-only=20recovery=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fusion-Task-Id: FN-4379 Fusion-Task-Lineage: 88ee0f49-1db2-40e6-8781-7ecbc4dcbdc8 --- ...healing-orphan-only-scope.real-git.test.ts | 139 ++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 packages/engine/src/__tests__/self-healing-orphan-only-scope.real-git.test.ts diff --git a/packages/engine/src/__tests__/self-healing-orphan-only-scope.real-git.test.ts b/packages/engine/src/__tests__/self-healing-orphan-only-scope.real-git.test.ts new file mode 100644 index 000000000..a2305e0bd --- /dev/null +++ b/packages/engine/src/__tests__/self-healing-orphan-only-scope.real-git.test.ts @@ -0,0 +1,139 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { execSync, spawnSync } from "node:child_process"; +import { existsSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { EventEmitter } from "node:events"; +import type { Settings, Task, TaskStore, AgentLogEntry } from "@fusion/core"; +import { SelfHealingManager } from "../self-healing.js"; + +const hasGit = spawnSync("git", ["--version"], { stdio: "pipe" }).status === 0; +const describeIfGit = hasGit ? describe : describe.skip; + +function git(repo: string, command: string): string { + return execSync(command, { cwd: repo, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim(); +} + +type TaskMap = Map; + +function makeTask(overrides: Partial & Pick): Task { + const { id, ...rest } = overrides; + return { + id, + title: id, + description: id, + column: "in-review", + dependencies: [], + steps: [], + currentStep: 0, + log: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + ...rest, + } as Task; +} + +function createStore(tasks: TaskMap, logsByTask: Map): TaskStore & EventEmitter { + const emitter = new EventEmitter(); + const settings: Settings = { globalPause: false, enginePaused: false, maintenanceIntervalMs: 0 } as Settings; + return Object.assign(emitter, { + getSettings: vi.fn(async () => settings), + listTasks: vi.fn(async ({ column }: { column?: string } = {}) => [...tasks.values()].filter((t) => !column || t.column === column)), + updateTask: vi.fn(async (id: string, updates: Partial) => { + const current = tasks.get(id)!; + tasks.set(id, { ...current, ...updates, updatedAt: new Date().toISOString() } as Task); + return tasks.get(id); + }), + moveTask: vi.fn(async (id: string, column: Task["column"]) => { + const current = tasks.get(id)!; + tasks.set(id, { ...current, column, updatedAt: new Date().toISOString() } as Task); + }), + logEntry: vi.fn(async (id: string, action: string) => { + const current = tasks.get(id)!; + tasks.set(id, { ...current, log: [...(current.log ?? []), { timestamp: new Date().toISOString(), action }] as any } as Task); + }), + getAgentLogs: vi.fn(async (taskId: string) => logsByTask.get(taskId) ?? []), + walCheckpoint: vi.fn(() => ({ busy: 0, log: 0, checkpointed: 0 })), + archiveTaskAndCleanup: vi.fn(async () => ({})), + clearStaleExecutionStartBranchReferences: vi.fn(() => []), + getTask: vi.fn(async (id: string) => tasks.get(id)), + updateSettings: vi.fn(async () => settings), + mergeTask: vi.fn(async () => undefined), + getRootDir: vi.fn(() => ""), + }) as unknown as TaskStore & EventEmitter; +} + +describeIfGit("recoverOrphanOnlyScopeViolations (real git)", () => { + const repos: string[] = []; + + afterEach(() => { + for (const repo of repos.splice(0)) rmSync(repo, { recursive: true, force: true }); + }); + + function setupRepo(): string { + const repo = mkdtempSync(path.join(os.tmpdir(), "fn-4379-")); + repos.push(repo); + git(repo, "git init -b main"); + git(repo, 'git config user.email "test@example.com"'); + git(repo, 'git config user.name "Test"'); + git(repo, "git commit --allow-empty -m 'init'"); + return repo; + } + + it("finalizes orphan-only scope violation as no-op when task work is already on main (FN-4350)", async () => { + const repo = setupRepo(); + mkdirSync(path.join(repo, "packages/dashboard/app/components"), { recursive: true }); + writeFileSync(path.join(repo, "packages/dashboard/app/components/QuickChatFAB.tsx"), "export const QuickChatFAB = () => null;\n", "utf-8"); + git(repo, "git add packages/dashboard/app/components/QuickChatFAB.tsx && git commit -m 'landed task work' -m 'Fusion-Task-Id: FN-TEST-4379'"); + const landedSha = git(repo, "git rev-parse HEAD"); + + const worktreePath = path.join(repo, ".worktrees", "fn-test-4379"); + mkdirSync(path.dirname(worktreePath), { recursive: true }); + git(repo, `git worktree add ${JSON.stringify(worktreePath)} -b fusion/fn-test-4379`); + mkdirSync(path.join(worktreePath, "packages/dashboard/app/components/__tests__"), { recursive: true }); + writeFileSync(path.join(worktreePath, "packages/dashboard/app/components/__tests__/QuickChatFAB.test.tsx"), "test('orphan', () => {});\n", "utf-8"); + + const task = makeTask({ + id: "FN-TEST-4379", + status: "failed", + paused: false, + scopeOverride: false, + baseBranch: "main", + branch: "fusion/fn-test-4379", + worktree: worktreePath, + error: "File-scope invariant violation for FN-TEST-4379: staged files [packages/dashboard/app/components/__tests__/QuickChatFAB.test.tsx] have zero overlap with declared File Scope [packages/dashboard/app/components/QuickChatFAB.tsx]. Refile genuinely out-of-scope work as a follow-up task via fn_task_create before retrying this merge.", + }); + const tasks: TaskMap = new Map([[task.id, task]]); + const logsByTask = new Map([[ + task.id, + [{ + timestamp: new Date().toISOString(), + taskId: task.id, + type: "tool_error", + text: "FileScopeViolationError", + detail: [ + `taskId: ${task.id}`, + "declaredScope:", + "- packages/dashboard/app/components/QuickChatFAB.tsx", + "stagedFiles:", + "- packages/dashboard/app/components/__tests__/QuickChatFAB.test.tsx", + ].join("\n"), + }], + ]]); + + const store = createStore(tasks, logsByTask); + const manager = new SelfHealingManager(store, { rootDir: repo, getExecutingTaskIds: () => new Set() }); + + const recovered = await manager.recoverOrphanOnlyScopeViolations(); + + expect(recovered).toBe(1); + const updated = tasks.get(task.id)!; + expect(updated.column).toBe("done"); + expect(updated.status).toBeNull(); + expect(updated.mergeDetails?.commitSha).toBe(landedSha); + expect(updated.mergeDetails?.mergeConfirmed).toBe(true); + expect(updated.mergeDetails?.resolutionStrategy).toBe("orphan-discard-no-op"); + expect(existsSync(worktreePath)).toBe(false); + expect(git(repo, "git log --oneline -- packages/dashboard/app/components/__tests__/QuickChatFAB.test.tsx")).toBe(""); + }, 20000); +});