feat(FN-5256): merge fusion/fn-5256

This commit is contained in:
gsxdsm
2026-05-21 11:06:26 -07:00
parent a2a643844d
commit 933cea434a
6 changed files with 328 additions and 1 deletions

View File

@@ -0,0 +1,127 @@
import { afterEach, describe, expect, it } from "vitest";
import { DependencyCycleError } from "@fusion/core";
import { hasGit, makeReliabilityFixture } from "./_helpers.js";
const describeIfGit = hasGit ? describe : describe.skip;
describeIfGit("reliability interactions: dependency-cycle reconciliation", () => {
const fixtures: Array<Awaited<ReturnType<typeof makeReliabilityFixture>>> = [];
afterEach(async () => {
while (fixtures.length) await fixtures.pop()!.cleanup();
});
it("auto-repairs umbrella back-edge cycles and records audit/log evidence", async () => {
const fx = await makeReliabilityFixture({ taskId: "FN-5256-U" });
fixtures.push(fx);
const umbrella = await fx.store.createTask({
id: "FN-5256-P",
title: "Umbrella: track FN-5256",
description: "parent",
} as any);
const child = await fx.store.createTask({ id: "FN-5256-C", title: "Foundation child", description: "child" } as any);
fx.store.getDatabase().prepare("UPDATE tasks SET dependencies = ? WHERE id = ?").run(JSON.stringify([child.id]), umbrella.id);
fx.store.getDatabase().prepare("UPDATE tasks SET dependencies = ? WHERE id = ?").run(JSON.stringify([umbrella.id]), child.id);
const recovered = await fx.manager.reconcileDependencyCycles();
expect(recovered).toBe(1);
const updatedChild = await fx.store.getTask(child.id);
const updatedUmbrella = await fx.store.getTask(umbrella.id);
expect(updatedChild?.dependencies).toEqual([]);
expect(updatedUmbrella?.dependencies).toEqual([child.id]);
expect(updatedChild?.log.some((entry) => JSON.stringify(entry).includes("Auto-cleared umbrella back-edge"))).toBe(true);
const repairedAudit = fx.store.getRunAuditEvents({
taskId: child.id,
domain: "database",
mutationType: "task:auto-reconciled-dependency-cycle",
});
expect(repairedAudit).toHaveLength(1);
expect(repairedAudit[0]?.metadata).toMatchObject({
removedDependency: umbrella.id,
reason: "umbrella-back-edge",
});
});
it("detects ambiguous FN-5240/FN-5241/FN-5242 persisted cycle once and leaves it unchanged", async () => {
const fx = await makeReliabilityFixture({ taskId: "FN-5256-A" });
fixtures.push(fx);
const a = await fx.store.createTask({ id: "FN-5240", title: "Task A", description: "A" } as any);
const b = await fx.store.createTask({ id: "FN-5241", title: "Task B", description: "B" } as any);
const c = await fx.store.createTask({ id: "FN-5242", title: "Task C", description: "C" } as any);
fx.store.getDatabase().prepare("UPDATE tasks SET dependencies = ? WHERE id = ?").run(JSON.stringify([b.id]), a.id);
fx.store.getDatabase().prepare("UPDATE tasks SET dependencies = ? WHERE id = ?").run(JSON.stringify([c.id]), b.id);
fx.store.getDatabase().prepare("UPDATE tasks SET dependencies = ? WHERE id = ?").run(JSON.stringify([a.id]), c.id);
const recovered = await fx.manager.reconcileDependencyCycles();
expect(recovered).toBe(0);
expect((await fx.store.getTask(a.id))?.dependencies).toEqual([b.id]);
expect((await fx.store.getTask(b.id))?.dependencies).toEqual([c.id]);
expect((await fx.store.getTask(c.id))?.dependencies).toEqual([a.id]);
const detected = fx.store.getRunAuditEvents({
taskId: a.id,
domain: "database",
mutationType: "task:dependency-cycle-detected",
});
const unrepaired = fx.store.getRunAuditEvents({
taskId: a.id,
domain: "database",
mutationType: "task:dependency-cycle-unrepaired",
});
expect(detected).toHaveLength(1);
expect(unrepaired).toHaveLength(1);
});
it("composes self-defeating cleanup before dependency-cycle cleanup and keeps write-time guard active", async () => {
const fx = await makeReliabilityFixture({ taskId: "FN-5256-COMP" });
fixtures.push(fx);
const child = await fx.store.createTask({ id: "FN-5256-CHILD", title: "child", description: "child" } as any);
const umbrella = await fx.store.createTask({
id: "FN-5256-UMB",
title: "Umbrella coordination",
description: "umbrella",
dependencies: [child.id],
} as any);
fx.store.getDatabase().prepare("UPDATE tasks SET title = ?, dependencies = ? WHERE id = ?").run(
`Finalize ${child.id}: close loop`,
JSON.stringify([child.id]),
child.id,
);
const selfDefRecovered = await fx.manager.reconcileSelfDefeatingDependencies();
expect(selfDefRecovered).toBe(1);
expect((await fx.store.getTask(child.id))?.dependencies).toEqual([]);
fx.store.getDatabase().prepare("UPDATE tasks SET dependencies = ? WHERE id = ?").run(JSON.stringify([umbrella.id]), child.id);
const cycleRecovered = await fx.manager.reconcileDependencyCycles();
expect(cycleRecovered).toBe(1);
const updatedChild = await fx.store.getTask(child.id);
expect(updatedChild?.dependencies).toEqual([]);
const selfDefAudit = fx.store.getRunAuditEvents({
taskId: child.id,
domain: "database",
mutationType: "task:auto-reconciled-self-defeating-dep",
});
const cycleAudit = fx.store.getRunAuditEvents({
taskId: child.id,
domain: "database",
mutationType: "task:auto-reconciled-dependency-cycle",
});
expect(selfDefAudit).toHaveLength(1);
expect(cycleAudit).toHaveLength(1);
await expect(fx.store.updateTask(child.id, { dependencies: [umbrella.id] })).rejects.toBeInstanceOf(DependencyCycleError);
});
});

View File

@@ -216,6 +216,10 @@ export type DatabaseMutationType =
| "task:auto-archived-duplicate"
| "task:broad-scope-flagged-at-triage"
| "task:auto-reconciled-self-defeating-dep"
| "task:dependency-cycle-rejected"
| "task:dependency-cycle-detected"
| "task:auto-reconciled-dependency-cycle"
| "task:dependency-cycle-unrepaired"
/**
* Metadata shape for node:handoff:* and node:lease:* events:
* ```ts

View File

@@ -27,7 +27,7 @@ import { exec, execSync } from "node:child_process";
import { promisify } from "node:util";
import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
import { isAbsolute, join, relative, resolve } from "node:path";
import { IN_REVIEW_STALL_DEADLOCK_LOG_PREFIX, IN_REVIEW_STALL_LOG_PREFIX, countRecentIdenticalStallEntries, detectSelfDefeatingDependency, getInReviewStalledSignal, getInReviewStallReason, getPrimaryPrInfo, getStalePausedReviewSignal, getStalePausedTodoSignal, getTaskHardMergeBlocker, getTaskMergeBlocker, isEphemeralAgent, parseExplicitDuplicateMarker, type AgentStore, type ChatStore, type MessageStore, type TaskStore, type Settings, type Task, type MergeDetails, type TaskPriority, type MergeResult } from "@fusion/core";
import { IN_REVIEW_STALL_DEADLOCK_LOG_PREFIX, IN_REVIEW_STALL_LOG_PREFIX, countRecentIdenticalStallEntries, detectDependencyCycle, detectSelfDefeatingDependency, getInReviewStalledSignal, getInReviewStallReason, getPrimaryPrInfo, getStalePausedReviewSignal, getStalePausedTodoSignal, getTaskHardMergeBlocker, getTaskMergeBlocker, isEphemeralAgent, parseExplicitDuplicateMarker, type AgentStore, type ChatStore, type MessageStore, type TaskStore, type Settings, type Task, type MergeDetails, type TaskPriority, type MergeResult } from "@fusion/core";
import type { MeshLeaseManager } from "./mesh-lease-manager.js";
import { createLogger, schedulerLog } from "./logger.js";
import { RemovalReason, getRegisteredWorktreeBranchMap, getRegisteredWorktreePaths, isUsableTaskWorktree, removeWorktree, resolveWorktreeBackend, scanIdleWorktrees, scanOrphanedBranches } from "./worktree-pool.js";
@@ -661,6 +661,7 @@ export class SelfHealingManager {
{ name: "recover-drifted-agent-task-links", fn: () => this.recoverDriftedAgentTaskLinks().then(() => undefined) },
{ name: "clear-stale-blocked-by", fn: () => this.clearStaleBlockedBy().then(() => undefined) },
{ name: "reconcile-self-defeating-deps", fn: () => this.reconcileSelfDefeatingDependencies().then(() => undefined) },
{ name: "reconcile-dependency-cycles", fn: () => this.reconcileDependencyCycles().then(() => undefined) },
{ name: "reclaim-pr-conflicts", fn: () => this.reclaimPrConflicts().then(() => undefined) },
{ name: "reclaim-self-owned-branch-conflicts", fn: () => this.reclaimSelfOwnedBranchConflicts().then(() => undefined) },
// FN-4962 ordering invariant: metadata reconcile must run before stale-active reclaim.
@@ -1299,6 +1300,7 @@ export class SelfHealingManager {
{ name: "auto-archive-meta-stalled", fn: () => this.autoArchiveStalledMetaTasks() },
{ name: "board-stall-auto-recovery", fn: () => this.runBoardStallAutoRecoverySweep() },
{ name: "reconcile-self-defeating-deps", fn: () => this.reconcileSelfDefeatingDependencies() },
{ name: "reconcile-dependency-cycles", fn: () => this.reconcileDependencyCycles().then(() => undefined) },
{ name: "reclaim-pr-conflicts", fn: () => this.reclaimPrConflicts() },
{ name: "reclaim-self-owned-branch-conflicts", fn: () => this.reclaimSelfOwnedBranchConflicts() },
// FN-4962 ordering invariant: metadata reconcile must run before stale-active reclaim.
@@ -3545,6 +3547,125 @@ export class SelfHealingManager {
return recovered;
}
async reconcileDependencyCycles(): Promise<number> {
const umbrellaPrefix = /^(umbrella|epic|parent|coordinate|coordination|track(?:er)?|meta)\b/i;
let recovered = 0;
let tasks: Task[] = [];
try {
tasks = await this.store.listTasks({ includeArchived: false, slim: true });
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : String(err);
log.warn(`reconcileDependencyCycles: failed to list tasks: ${errorMessage}`);
return recovered;
}
const taskLookup = new Map(tasks.map((task) => [task.id, task] as const));
const dependencyLookup = new Map(tasks.map((task) => [task.id, task.dependencies] as const));
const seenCycleSignatures = new Set<string>();
for (const task of tasks) {
if (!task.dependencies.length) continue;
try {
const cyclePath = detectDependencyCycle(task.id, task.dependencies, (id) => dependencyLookup.get(id));
if (!cyclePath) continue;
const cycleMembers = Array.from(new Set(cyclePath));
const cycleSignature = [...cycleMembers].sort((a, b) => a.localeCompare(b)).join(">");
if (seenCycleSignatures.has(cycleSignature)) continue;
seenCycleSignatures.add(cycleSignature);
const targetTaskId = [...cycleMembers].sort((a, b) => a.localeCompare(b))[0] ?? task.id;
const targetTask = taskLookup.get(targetTaskId) ?? task;
const auditor = createRunAuditor(this.store, {
runId: generateSyntheticRunId("self-heal-dependency-cycle", targetTaskId),
agentId: "system:self-healing",
taskId: targetTaskId,
phase: "reconcile-dependency-cycles",
});
await auditor.database({
type: "task:dependency-cycle-detected",
target: targetTaskId,
metadata: {
taskId: targetTaskId,
cyclePath,
dependencies: targetTask.dependencies,
},
});
const isTwoNodeCycle = cyclePath.length === 3 && cyclePath[0] === cyclePath[2];
const otherNodeId = isTwoNodeCycle
? cyclePath.find((id) => id.toUpperCase() !== targetTaskId.toUpperCase())
: undefined;
const otherNodeTask = otherNodeId ? taskLookup.get(otherNodeId) : undefined;
const targetIsUmbrella = Boolean(targetTask.title && umbrellaPrefix.test(targetTask.title));
const otherIsUmbrella = Boolean(otherNodeTask?.title && umbrellaPrefix.test(otherNodeTask.title));
let foundationChildId: string | undefined;
let umbrellaTaskId: string | undefined;
if (isTwoNodeCycle && otherNodeId) {
if (targetIsUmbrella && !otherIsUmbrella) {
umbrellaTaskId = targetTaskId;
foundationChildId = otherNodeId;
} else if (!targetIsUmbrella && otherIsUmbrella) {
umbrellaTaskId = otherNodeId;
foundationChildId = targetTaskId;
}
}
const foundationTask = foundationChildId ? taskLookup.get(foundationChildId) : undefined;
const umbrellaTask = umbrellaTaskId ? taskLookup.get(umbrellaTaskId) : undefined;
const umbrellaDependsOnFoundation = Boolean(
umbrellaTask && foundationChildId
&& umbrellaTask.dependencies.some((dep) => dep.toUpperCase() === foundationChildId.toUpperCase()),
);
const foundationDependsOnUmbrella = Boolean(
foundationTask && umbrellaTaskId
&& foundationTask.dependencies.some((dep) => dep.toUpperCase() === umbrellaTaskId.toUpperCase()),
);
if (isTwoNodeCycle && foundationTask && foundationChildId && umbrellaTaskId && umbrellaDependsOnFoundation && foundationDependsOnUmbrella) {
const nextDependencies = foundationTask.dependencies.filter((dep) => dep.toUpperCase() !== umbrellaTaskId.toUpperCase());
if (nextDependencies.length !== foundationTask.dependencies.length) {
await this.store.updateTask(foundationChildId, { dependencies: nextDependencies });
await this.store.logEntry(
foundationChildId,
`Auto-cleared umbrella back-edge: removed ${umbrellaTaskId} from dependencies (cycle: ${cyclePath.join(" → ")})`,
);
await auditor.database({
type: "task:auto-reconciled-dependency-cycle",
target: foundationChildId,
metadata: {
removedDependency: umbrellaTaskId,
cyclePath,
reason: "umbrella-back-edge",
},
});
recovered++;
continue;
}
}
await auditor.database({
type: "task:dependency-cycle-unrepaired",
target: targetTaskId,
metadata: {
cyclePath,
reason: "ambiguous-cycle",
},
});
log.warn(`Dependency cycle detected for ${targetTaskId}: ${cyclePath.join(" → ")} — left unchanged (ambiguous)`);
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : String(err);
log.warn(`reconcileDependencyCycles: failed for ${task.id}: ${errorMessage}`);
}
}
return recovered;
}
private async recordIntegrityAudit(taskId: string, mutationType: "task:finalize-unproven-blocked" | "task:integrity-reconcile-modified-files" | "task:integrity-warning" | "task:auto-recover-stale-merger-status", metadata: Record<string, unknown>): Promise<void> {
const auditor = createRunAuditor(this.store, {
runId: generateSyntheticRunId("self-healing-integrity", taskId),