From c67e786f48507e037a29423c152a849e97fa4f61 Mon Sep 17 00:00:00 2001 From: Fusion Date: Tue, 5 May 2026 13:42:39 -0700 Subject: [PATCH] feat(FN-3498): add self-healing and ownership-aware done-task merge reconci The merge completes FN-3498 across three steps: adds ownership-aware done-task reconciliation to the merger, prevents branch-missing head SHA pollution during merge operations, and restores workspace typecheck compatibility. Core changes touch the merger (103 lines) and self-healing module (67 lines Fusion-Task-Id: FN-3498 --- docs/architecture.md | 1 + packages/core/src/eval-automation.ts | 13 ++- packages/core/src/settings-schema.ts | 6 - packages/core/src/types.ts | 12 -- packages/dashboard/app/App.tsx | 8 +- .../PlanningModeModal.planning-flow.test.tsx | 2 +- .../app/plugins/pluginViewRegistry.tsx | 2 +- packages/engine/src/__tests__/merger.test.ts | 60 +++++++--- .../engine/src/__tests__/self-healing.test.ts | 76 +++++++++++++ packages/engine/src/merger.ts | 103 ++++++++++++++---- packages/engine/src/self-healing.ts | 67 +++++++++++- 11 files changed, 277 insertions(+), 73 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 09c856875..4b95bb261 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -405,6 +405,7 @@ See [Memory Plugin Contract](./memory-plugin-contract.md) for the full plan. - `SelfHealingManager` (`self-healing.ts`) — auto-unpause/maintenance recovery actions - `recoverGhostReviewTasks()` is a fallback only for idle, non-terminal `in-review` states. Terminal/actionable states (notably `status: "failed"`) are preserved and **not** auto-kicked back to `todo`. - `recoverMergeableReviewTasks()` only re-enqueues truly eligible tasks; retry-exhausted review tasks are skipped to avoid re-enqueue/no-op loops that keep refreshing `updatedAt`. + - Merge commit attribution is ownership-aware: a `mergeDetails.commitSha` is trusted only when reachable from `HEAD` **and** attributable to the task via `Fusion-Task-Id` trailer or task-ID-bearing subject. Reachable-but-unowned SHAs are rejected to prevent sibling done tasks from sharing misleading merge metadata. - `ProjectEngine` settings lifecycle handlers (`project-engine.ts`) treat `enginePaused` as a soft pause: clearing it dispatches runtime resume and, when `autoMerge` is enabled, performs an `in-review` eligibility sweep to requeue mergeable review tasks. - `UsageLimitPauser` (`usage-limit-detector.ts`) and `withRateLimitRetry` (`rate-limit-retry.ts`) diff --git a/packages/core/src/eval-automation.ts b/packages/core/src/eval-automation.ts index 4dda9f99b..7556b8a79 100644 --- a/packages/core/src/eval-automation.ts +++ b/packages/core/src/eval-automation.ts @@ -20,13 +20,14 @@ export interface ResolvedTaskEvaluationSettings { export function resolveTaskEvaluationSettings( settings: Partial, ): ResolvedTaskEvaluationSettings { + const evalSettings = settings as Partial; return { - taskEvaluationEnabled: settings.taskEvaluationEnabled ?? false, - taskEvaluationSchedule: settings.taskEvaluationSchedule ?? DEFAULT_TASK_EVALUATION_SCHEDULE, - taskEvaluationProvider: settings.taskEvaluationProvider, - taskEvaluationModelId: settings.taskEvaluationModelId, - taskEvaluationFollowUpPolicy: settings.taskEvaluationFollowUpPolicy ?? "off", - taskEvaluationRetention: settings.taskEvaluationRetention, + taskEvaluationEnabled: evalSettings.taskEvaluationEnabled ?? false, + taskEvaluationSchedule: evalSettings.taskEvaluationSchedule ?? DEFAULT_TASK_EVALUATION_SCHEDULE, + taskEvaluationProvider: evalSettings.taskEvaluationProvider, + taskEvaluationModelId: evalSettings.taskEvaluationModelId, + taskEvaluationFollowUpPolicy: evalSettings.taskEvaluationFollowUpPolicy ?? "off", + taskEvaluationRetention: evalSettings.taskEvaluationRetention, }; } diff --git a/packages/core/src/settings-schema.ts b/packages/core/src/settings-schema.ts index 70a05efa7..5de9a7cb6 100644 --- a/packages/core/src/settings-schema.ts +++ b/packages/core/src/settings-schema.ts @@ -292,12 +292,6 @@ export const DEFAULT_PROJECT_SETTINGS = { researchDefaultTimeout: 300000, researchMaxSourcesPerRun: 20, researchMaxSynthesisRounds: 2, - taskEvaluationEnabled: false, - taskEvaluationSchedule: "0 5 * * *", - taskEvaluationProvider: undefined, - taskEvaluationModelId: undefined, - taskEvaluationFollowUpPolicy: "off", - taskEvaluationRetention: undefined, } satisfies CompleteSettings; /** diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index b7e059592..81aee8c94 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1724,18 +1724,6 @@ export interface ProjectSettings { testCommand?: string; /** Custom build command for the project (e.g. "pnpm build") */ buildCommand?: string; - /** Enables automated scheduled evaluation of completed tasks. */ - taskEvaluationEnabled?: boolean; - /** Cron expression for scheduled task evaluation batches. */ - taskEvaluationSchedule?: string; - /** Optional provider override for task evaluation. */ - taskEvaluationProvider?: string; - /** Optional model override for task evaluation. */ - taskEvaluationModelId?: string; - /** Follow-up behavior for evaluation findings. */ - taskEvaluationFollowUpPolicy?: "off" | "suggest" | "create"; - /** Number of days to retain evaluation data. */ - taskEvaluationRetention?: number; /** When true, completed task worktrees are returned to an idle pool instead * of being deleted. New tasks acquire a warm worktree from the pool, * preserving build caches (node_modules, target/, dist/). Default: false. */ diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index 28a6a36aa..c06f4d1b9 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -36,7 +36,7 @@ import { useCurrentProject } from "./hooks/useCurrentProject"; import { ToastProvider, useToast } from "./hooks/useToast"; import { ConfirmDialogProvider } from "./hooks/useConfirm"; import { useTheme } from "./hooks/useTheme"; -import { useModalManager, type DetailTaskOrigin } from "./hooks/useModalManager"; +import { useModalManager, type DetailTaskOrigin, type DetailTaskTab } from "./hooks/useModalManager"; import { useAppSettings } from "./hooks/useAppSettings"; import { useDeepLink } from "./hooks/useDeepLink"; import { useFavorites } from "./hooks/useFavorites"; @@ -879,10 +879,8 @@ function AppInner() { tasks: isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks, workflowSteps, openTaskDetail: isMobile - ? (task: Task | TaskDetail, initialTab?: Parameters[1]) => - openDetailTaskWithHistory(task, initialTab) - : (task: Task | TaskDetail, initialTab?: Parameters[1]) => - modalManager.openDetailTask(task, initialTab), + ? (task: Task | TaskDetail, initialTab?: DetailTaskTab) => openDetailTaskWithHistory(task, initialTab) + : (task: Task | TaskDetail, initialTab?: DetailTaskTab) => modalManager.openDetailTask(task, initialTab), renderTaskCard: (task: Task | TaskDetail) => ( { await waitFor(() => { expect(screen.getByText("What are the key requirements?")).toBeDefined(); - }); + }, { timeout: 5000 }); expect(screen.getByTestId("conversation-history")).toBeDefined(); expect(screen.getByText("What is the scope?")).toBeDefined(); diff --git a/packages/dashboard/app/plugins/pluginViewRegistry.tsx b/packages/dashboard/app/plugins/pluginViewRegistry.tsx index f72fb51f0..5443e328d 100644 --- a/packages/dashboard/app/plugins/pluginViewRegistry.tsx +++ b/packages/dashboard/app/plugins/pluginViewRegistry.tsx @@ -5,7 +5,7 @@ import "./pluginViewRegistry.css"; export type PluginTaskView = `plugin:${string}:${string}`; -type PluginViewComponent = LazyExoticComponent<() => ReactNode>; +type PluginViewComponent = LazyExoticComponent<() => ReactElement>; const registry = new Map(); diff --git a/packages/engine/src/__tests__/merger.test.ts b/packages/engine/src/__tests__/merger.test.ts index 64116878f..0b0e82d53 100644 --- a/packages/engine/src/__tests__/merger.test.ts +++ b/packages/engine/src/__tests__/merger.test.ts @@ -5362,38 +5362,62 @@ describe("aiMergeTask — merge details collection", () => { expect(mergeDetailsCall?.[1].mergeDetails.mergeCommitMessage).toBe("- feat: something"); }); - it("stores partial mergeDetails when branch is not found", async () => { + it("recovers owned landed commit when branch is not found", async () => { const store = createMockStore( - { id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" }, - [{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task], + { + id: "FN-3469", + worktree: "/tmp/root/.worktrees/FN-3469", + baseCommitSha: "base3469", + mergeDetails: { commitSha: "a47b1e5d78d626f8b480f1e90d3d64be2625ff6a" } as any, + }, + [{ id: "FN-3469", worktree: "/tmp/root/.worktrees/FN-3469", column: "in-review" } as Task], ); mockedExecSync.mockImplementation((cmd: any) => { const cmdStr = String(cmd); - // Branch verification fails → branch not found if (cmdStr.includes("rev-parse --verify")) throw new Error("not found"); - // But rev-parse HEAD still works → can capture commitSha (encoding: utf-8 → string) - if (cmdStr === "git rev-parse HEAD" || cmdStr.startsWith("git rev-parse HEAD ")) - return "existingheadsha999"; + if (cmdStr.includes("merge-base --is-ancestor a47b1e5d78d626f8b480f1e90d3d64be2625ff6a HEAD")) return Buffer.from(""); + if (cmdStr.includes("log -1 --format=%H%x1f%s%x1f%b a47b1e5d78d626f8b480f1e90d3d64be2625ff6a")) { + return "a47b1e5d78d626f8b480f1e90d3d64be2625ff6a\u001ffix(FN-3469): title\u001fFusion-Task-Id: FN-3469" as any; + } + if (cmdStr.includes("show --shortstat --format= a47b1e5d78d626f8b480f1e90d3d64be2625ff6a")) { + return "2 files changed, 84 insertions(+), 2 deletions(-)" as any; + } return Buffer.from(""); }); - const result = await aiMergeTask(store, "/tmp/root", "FN-050"); - + const result = await aiMergeTask(store, "/tmp/root", "FN-3469"); expect(result.merged).toBe(false); - expect(result.error).toContain("not found"); - // Find the updateTask call that set mergeDetails - const updateCalls = (store.updateTask as ReturnType).mock.calls; - const mergeDetailsCall = updateCalls.find( + const mergeDetailsCall = (store.updateTask as ReturnType).mock.calls.find( (call: any[]) => call[1]?.mergeDetails !== undefined, ); - expect(mergeDetailsCall).toBeDefined(); + expect(mergeDetailsCall?.[1].mergeDetails).toEqual(expect.objectContaining({ + commitSha: "a47b1e5d78d626f8b480f1e90d3d64be2625ff6a", + mergeCommitMessage: "fix(FN-3469): title", + mergeConfirmed: true, + })); + }); - const mergeDetails = mergeDetailsCall![1].mergeDetails; - expect(mergeDetails.commitSha).toBe("existingheadsha999"); - expect(mergeDetails.mergedAt).toBeDefined(); - expect(mergeDetails.mergeConfirmed).toBe(false); + it("does not persist misleading mergeDetails when branch is not found and no owned commit exists", async () => { + const store = createMockStore( + { id: "FN-3373", worktree: "/tmp/root/.worktrees/FN-3373" }, + [{ id: "FN-3373", worktree: "/tmp/root/.worktrees/FN-3373", column: "in-review" } as Task], + ); + + mockedExecSync.mockImplementation((cmd: any) => { + const cmdStr = String(cmd); + if (cmdStr.includes("rev-parse --verify")) throw new Error("not found"); + return Buffer.from(""); + }); + + const result = await aiMergeTask(store, "/tmp/root", "FN-3373"); + expect(result.merged).toBe(false); + + const mergeDetailsCall = (store.updateTask as ReturnType).mock.calls.find( + (call: any[]) => call[1]?.mergeDetails !== undefined, + ); + expect(mergeDetailsCall).toBeUndefined(); }); it("completes merge even when git commands fail during merge details collection", async () => { diff --git a/packages/engine/src/__tests__/self-healing.test.ts b/packages/engine/src/__tests__/self-healing.test.ts index a127734e7..c878cfd1f 100644 --- a/packages/engine/src/__tests__/self-healing.test.ts +++ b/packages/engine/src/__tests__/self-healing.test.ts @@ -3006,6 +3006,82 @@ describe("stale triage processing eviction before recovery", () => { // ── Maintenance cycle concurrency ────────────────────────────────── +describe("recoverDoneTaskMergeMetadata", () => { + it("upgrades done task metadata to an owned landed commit", async () => { + const store = createMockStore(); + const manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project" }); + + (store.listTasks as ReturnType).mockResolvedValue([ + { + id: "FN-3469", + column: "done", + paused: false, + baseCommitSha: "base", + mergeDetails: { commitSha: "sharedsha", mergeConfirmed: false }, + modifiedFiles: ["AGENTS.md"], + }, + ]); + + mockedExecSync.mockImplementation((command) => { + const cmd = String(command); + if (cmd.includes("merge-base --is-ancestor sharedsha HEAD")) return "" as any; + if (cmd.includes("log -1 --format=%H%x1f%s%x1f%b sharedsha")) { + return "sharedsha\u001ffix(FN-3468): other\u001fFusion-Task-Id: FN-3468" as any; + } + if (cmd.includes("Fusion-Task-Id: FN-3469")) { + return "a47b1e5\u001ffix(FN-3469): correct lazy-loaded views\n" as any; + } + if (cmd.includes("show --shortstat --format= a47b1e5")) { + return "2 files changed, 84 insertions(+), 2 deletions(-)" as any; + } + return "" as any; + }); + + const repaired = await manager.recoverDoneTaskMergeMetadata(); + + expect(repaired).toBe(1); + expect(store.updateTask).toHaveBeenCalledWith("FN-3469", { + mergeDetails: expect.objectContaining({ + commitSha: "a47b1e5", + mergeConfirmed: true, + }), + }); + + manager.stop(); + }); + + it("clears unowned shared SHA for done task when no owned landed commit exists", async () => { + const store = createMockStore(); + const manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project" }); + + (store.listTasks as ReturnType).mockResolvedValue([ + { + id: "FN-3373", + column: "done", + paused: false, + mergeDetails: { commitSha: "196adbd", mergeConfirmed: false }, + modifiedFiles: ["packages/cli/src/extension.ts"], + }, + ]); + + mockedExecSync.mockImplementation((command) => { + const cmd = String(command); + if (cmd.includes("merge-base --is-ancestor 196adbd HEAD")) return "" as any; + if (cmd.includes("log -1 --format=%H%x1f%s%x1f%b 196adbd")) { + return "196adbd\u001ffeat(FN-3372): add safety net\u001fFusion-Task-Id: FN-3372" as any; + } + return "" as any; + }); + + const repaired = await manager.recoverDoneTaskMergeMetadata(); + + expect(repaired).toBe(1); + expect(store.updateTask).toHaveBeenCalledWith("FN-3373", { mergeDetails: undefined }); + + manager.stop(); + }); +}); + describe("maintenance cycle concurrency", () => { let store: TaskStore & EventEmitter; let manager: SelfHealingManager; diff --git a/packages/engine/src/merger.ts b/packages/engine/src/merger.ts index 7682a602d..e0781d29a 100644 --- a/packages/engine/src/merger.ts +++ b/packages/engine/src/merger.ts @@ -46,6 +46,7 @@ import { type AgentPromptsConfig, type CanonicalMergeConflictStrategy, type TaskSourceIssue, + type Task, } from "@fusion/core"; import { describeModel, promptWithFallback } from "./pi.js"; import { accumulateSessionTokenUsage } from "./session-token-usage.js"; @@ -250,6 +251,72 @@ interface InferredTestCommand { buildSource?: "explicit" | "inferred"; } +interface OwnedLandedCommit { + sha: string; + subject?: string; + filesChanged?: number; + insertions?: number; + deletions?: number; +} + +function commitOwnedByTask(taskId: string, subject: string, body: string): boolean { + return body.includes(`${FUSION_TASK_ID_TRAILER_KEY}: ${taskId}`) || subject.includes(taskId); +} + +async function findOwnedLandedCommitForTask(rootDir: string, task: Task): Promise { + const tryHydrate = async (sha: string): Promise => { + try { + await execFileAsync("git", ["merge-base", "--is-ancestor", sha, "HEAD"], { cwd: rootDir }); + const { stdout } = await execFileAsync("git", ["log", "-1", "--format=%H%x1f%s%x1f%b", sha], { + cwd: rootDir, + encoding: "utf-8", + }); + const [resolvedSha, subject = "", body = ""] = stdout.trim().split("\x1f"); + if (!resolvedSha || !commitOwnedByTask(task.id, subject, body)) return null; + const owned: OwnedLandedCommit = { sha: resolvedSha, subject }; + try { + const { stdout: statsOut } = await execFileAsync("git", ["show", "--shortstat", "--format=", resolvedSha], { + cwd: rootDir, + encoding: "utf-8", + }); + Object.assign(owned, parseDiffStat(statsOut)); + } catch { + // stats optional + } + return owned; + } catch { + return null; + } + }; + + if (task.mergeDetails?.commitSha) { + const ownedStored = await tryHydrate(task.mergeDetails.commitSha); + if (ownedStored) return ownedStored; + } + + const trailer = `${FUSION_TASK_ID_TRAILER_KEY}: ${task.id}`; + const searches: string[][] = [ + ["log", "--format=%H%x1f%s", "--max-count=20", "--fixed-strings", `--grep=${trailer}`, "HEAD"], + ["log", "--format=%H%x1f%s", "--max-count=20", "--fixed-strings", `--grep=${task.id}`, "HEAD"], + ]; + + for (const args of searches) { + try { + const { stdout } = await execFileAsync("git", args, { cwd: rootDir, encoding: "utf-8" }); + const first = stdout.trim().split("\n").find(Boolean); + if (!first) continue; + const [sha] = first.split("\x1f"); + if (!sha) continue; + const owned = await tryHydrate(sha); + if (owned) return owned; + } catch { + // continue + } + } + + return null; +} + /** * Infer a default test command based on project files. * Returns the command and whether it was explicitly configured or inferred. @@ -2662,25 +2729,23 @@ export async function aiMergeTask( }); } catch { result.error = `Branch '${branch}' not found — moving to done without merge`; - // Best-effort: try to capture current HEAD commitSha even though branch is missing - try { - const commitSha = execSyncText("git rev-parse HEAD", { - cwd: rootDir, - stdio: "pipe", - encoding: "utf-8", - }).trim() || undefined; - if (commitSha) { - await store.updateTask(taskId, { - mergeDetails: { - commitSha, - mergedAt: new Date().toISOString(), - mergeConfirmed: false, - }, - }); - mergerLog.log(`${taskId}: branch not found but captured commitSha ${commitSha.slice(0, 8)}`); - } - } catch { - // No commit SHA available — task will show summary fallback + // Branch is gone; never infer ownership from raw HEAD. Only persist commit + // metadata when we can prove a landed commit belongs to this task. + const ownedCommit = await findOwnedLandedCommitForTask(rootDir, task); + if (ownedCommit) { + await store.updateTask(taskId, { + mergeDetails: { + commitSha: ownedCommit.sha, + filesChanged: ownedCommit.filesChanged, + insertions: ownedCommit.insertions, + deletions: ownedCommit.deletions, + mergeCommitMessage: ownedCommit.subject, + mergedAt: new Date().toISOString(), + mergeConfirmed: true, + prNumber: task.prInfo?.number, + }, + }); + mergerLog.log(`${taskId}: branch missing; recovered owned landed commit ${ownedCommit.sha.slice(0, 8)}`); } // Audit trail: record merge completion (FN-1404) await audit.database({ type: "task:move", target: taskId, metadata: { to: "done", merged: false } }); diff --git a/packages/engine/src/self-healing.ts b/packages/engine/src/self-healing.ts index 8f8827035..d8cc3da81 100644 --- a/packages/engine/src/self-healing.ts +++ b/packages/engine/src/self-healing.ts @@ -117,6 +117,10 @@ interface LandedTaskCommit { deletions?: number; } +function commitOwnedByTask(taskId: string, subject: string, body: string): boolean { + return body.includes(`Fusion-Task-Id: ${taskId}`) || subject.includes(taskId); +} + function shellQuote(value: string): string { return `'${value.replace(/'/g, "'\\''")}'`; } @@ -193,6 +197,7 @@ export class SelfHealingManager { { name: "stale-incomplete-review", fn: () => this.recoverStaleIncompleteReviewTasks().then(() => undefined) }, { name: "failed-pre-merge-steps", fn: () => this.recoverReviewTasksWithFailedPreMergeSteps().then(() => undefined) }, { name: "interrupted-merging", fn: () => this.recoverInterruptedMergingTasks().then(() => undefined) }, + { name: "done-merge-metadata", fn: () => this.recoverDoneTaskMergeMetadata().then(() => undefined) }, { name: "misclassified-failures", fn: () => this.recoverMisclassifiedFailures().then(() => undefined) }, { name: "partial-progress-no-task-done", fn: () => this.recoverPartialProgressNoTaskDoneFailures().then(() => undefined) }, { name: "orphaned-executions", fn: () => this.recoverOrphanedExecutions().then(() => undefined) }, @@ -467,18 +472,16 @@ export class SelfHealingManager { const storedSha = task.mergeDetails?.commitSha; if (storedSha) { try { - // Reachable from HEAD? Use --quiet --exit-code on rev-list. await execAsync( `git merge-base --is-ancestor ${shellQuote(storedSha)} HEAD`, { cwd: this.options.rootDir }, ); - // Yes — fetch its subject + stats. const { stdout } = await execAsync( - `git log -1 --format=%H%x1f%s ${shellQuote(storedSha)}`, + `git log -1 --format=%H%x1f%s%x1f%b ${shellQuote(storedSha)}`, { cwd: this.options.rootDir, maxBuffer: 1024 * 1024 }, ); - const [sha, subject] = stdout.trim().split("\x1f"); - if (sha) { + const [sha, subject = "", body = ""] = stdout.trim().split("\x1f"); + if (sha && commitOwnedByTask(task.id, subject, body)) { const commit: LandedTaskCommit = { sha, subject }; try { const stats = await execAsync(`git show --shortstat --format= ${shellQuote(sha)}`, { @@ -644,6 +647,7 @@ export class SelfHealingManager { { name: "recover-stale-incomplete-review", fn: () => this.recoverStaleIncompleteReviewTasks() }, { name: "recover-failed-pre-merge-steps", fn: () => this.recoverReviewTasksWithFailedPreMergeSteps() }, { name: "recover-interrupted-merging", fn: () => this.recoverInterruptedMergingTasks() }, + { name: "recover-done-merge-metadata", fn: () => this.recoverDoneTaskMergeMetadata() }, { name: "recover-mergeable-review", fn: () => this.recoverMergeableReviewTasks() }, { name: "recover-merged-review", fn: () => this.recoverMergedReviewTasks() }, { name: "recover-misclassified-failures", fn: () => this.recoverMisclassifiedFailures() }, @@ -1218,6 +1222,59 @@ export class SelfHealingManager { } } + async recoverDoneTaskMergeMetadata(): Promise { + try { + const tasks = await this.store.listTasks({ column: "done", slim: true }); + const candidates = tasks.filter((task) => task.column === "done" && !task.paused && Boolean(task.mergeDetails?.commitSha)); + if (candidates.length === 0) return 0; + + let repaired = 0; + for (const task of candidates) { + try { + const landed = await this.findLandedTaskCommit(task); + if (!landed) { + if (task.mergeDetails?.mergeConfirmed === false) { + await this.store.updateTask(task.id, { mergeDetails: undefined }); + await this.store.logEntry(task.id, "Auto-recovered: cleared unowned done-task mergeDetails commitSha"); + repaired++; + } + continue; + } + + const needsRepair = + task.mergeDetails?.commitSha !== landed.sha || + task.mergeDetails?.mergeConfirmed !== true || + task.mergeDetails?.filesChanged === undefined; + + if (!needsRepair) continue; + + await this.store.updateTask(task.id, { + mergeDetails: { + ...task.mergeDetails, + commitSha: landed.sha, + filesChanged: landed.filesChanged, + insertions: landed.insertions, + deletions: landed.deletions, + mergeCommitMessage: landed.subject, + mergedAt: task.mergeDetails?.mergedAt ?? new Date().toISOString(), + mergeConfirmed: true, + prNumber: task.prInfo?.number, + }, + }); + await this.store.logEntry(task.id, `Auto-recovered: reconciled done-task mergeDetails to owned commit ${landed.sha.slice(0, 8)}`); + repaired++; + } catch (err: unknown) { + log.error(`Failed done-task merge metadata recovery for ${task.id}: ${err instanceof Error ? err.message : String(err)}`); + } + } + + return repaired; + } catch (err: unknown) { + log.error(`Done-task merge metadata recovery failed: ${err instanceof Error ? err.message : String(err)}`); + return 0; + } + } + // ── Misclassified failure recovery ─────────────────────────────── /**