feat(FN-5256): merge fusion/fn-5256
This commit is contained in:
14
AGENTS.md
14
AGENTS.md
@@ -91,6 +91,19 @@ esbuild leaves the dynamic import in the output, the package isn't installed at
|
||||
|
||||
Hybrid: structured metadata in SQLite (`.fusion/fusion.db`, WAL mode), large blobs (PROMPT.md, attachments) on disk under `.fusion/tasks/{ID}/`. See [docs/storage.md](./docs/storage.md).
|
||||
|
||||
## Dependency Graph Invariant
|
||||
|
||||
Task dependency graphs must remain acyclic. Umbrella / coordination tasks
|
||||
may depend on their foundational children, but foundational children must
|
||||
never depend back on the umbrella parent. Cycle-forming `createTask` /
|
||||
`updateTask` / `createTaskWithReservedId` / `applyReplicatedTaskCreate`
|
||||
calls are rejected at the write boundary with `DependencyCycleError` and
|
||||
emit a `task:dependency-cycle-rejected` run-audit event. Persisted cycles
|
||||
from pre-guard data are surfaced by `reconcileDependencyCycles` in
|
||||
self-healing batch 2, which auto-repairs only the narrow umbrella-back-edge
|
||||
case (`task:auto-reconciled-dependency-cycle`) and leaves ambiguous cycles
|
||||
for operator inspection (`task:dependency-cycle-unrepaired`).
|
||||
|
||||
## Multi-Project Support
|
||||
|
||||
Central registry at `~/.fusion/fusion-central.db`; per-project DB at `.fusion/fusion.db`. See [docs/multi-project.md](./docs/multi-project.md) for CentralCore API, isolation modes, and global concurrency.
|
||||
@@ -561,6 +574,7 @@ Reliability-layer changes are in scope. Interaction regression backstops live in
|
||||
- FN-5168 backstop: `packages/engine/src/__tests__/reliability-interactions/non-progress-churn.test.ts` covers loop→compact recovery followed by ignored-step-update churn escalation, terminal `beforeRequeue(false)` behavior, audit/log payloads, and FN-5147 autoMerge-off composition.
|
||||
- FN-5219 backstop: `packages/engine/src/__tests__/reliability-interactions/in-progress-limbo-recovery.test.ts` covers `recoverInProgressLimbo` composition with `recoverOrphanedExecutions` (no double-recovery), `reconcile-task-worktree-metadata` (live rebindable worktree wins), `recoverMissingWorktreeReviewFailures` (in-review vs in-progress disjoint), and executor task-id claim skip, plus an explicit FN-5149 reproduction case.
|
||||
- FN-5337 backstop: `packages/engine/src/__tests__/reliability-interactions/orphan-detected-no-requeue.test.ts` locks observation-only orphan detection across FN-5279 repro metadata desync, worktree-present and worktree-missing candidates, FN-5219 ordering, FN-5147 in-review isolation, FN-5083 branch-cleared composition, lease-manager non-invocation, and per-sweep idempotent audit emission.
|
||||
- FN-5256 backstop: `packages/engine/src/__tests__/reliability-interactions/dependency-cycle-reconcile.test.ts` covers persisted dependency-cycle detection via `reconcileDependencyCycles`, bounded umbrella-back-edge auto-repair, ambiguous-cycle observe-only behavior, composition ordering with `reconcileSelfDefeatingDependencies`, and the post-sweep write-time guard invariant. Core write-boundary regressions (FN-5240/5241/5242 signature, indirect cycle, umbrella back-edge rejection) live in `packages/core/src/__tests__/store-dependency-cycle.test.ts`.
|
||||
- FN-5325 backstop: `packages/engine/src/__tests__/reliability-interactions/scheduler-overlap-priority-inversion.test.ts` covers queued-overlap priority/age deferral, equal-priority age ordering, FN-4969 fanout composition, and one-shot per-pass `scheduler:overlap-priority-inversion` audit surfacing against running lower-priority blockers.
|
||||
- FN-5223 backstop: `packages/engine/src/__tests__/reliability-interactions/engine-active-since-floor.test.ts` covers engine-activation floor + grace composition across startup, pause/unpause, global-pause gating, and StuckTaskDetector lifecycle interactions.
|
||||
|
||||
|
||||
@@ -614,6 +614,7 @@ See [Memory Plugin Contract](./memory-plugin-contract.md) for the full plan.
|
||||
### Scheduling and execution
|
||||
- `Scheduler` (`scheduler.ts`) — dependency-aware task scheduling that dispatches eligible todo tasks by priority first, then dependency-unblock fanout within the same priority class (FN-4969), then FIFO (`createdAt` ascending) with task-id fallback. `urgent` always stays ahead of lower priorities, and overlap/file-scope blockers are excluded from fanout weighting.
|
||||
- `blockedBy` invariant (FN-3924/FN-4091): the field is only durable when it references a current unresolved explicit dependency (or, for dependency-free tasks, an active overlap blocker). Completion gating now validates `blockedBy` through live task resolution: missing blockers and blockers already in `done`/`archived` are treated as stale, while only still-active blockers continue to prevent `fn_task_done`. If no current blocker remains, scheduler/event reconciliation clears `blockedBy` to `null` and re-evaluates from live task state.
|
||||
- Dependency-cycle invariant (FN-5256): task dependency graphs are acyclic at write time (`DependencyCycleError` in `TaskStore` for `createTask`, `createTaskWithReservedId`, `updateTask`, and `applyReplicatedTaskCreate`) with `task:dependency-cycle-rejected` audit evidence. Self-healing batch 2 adds `reconcileDependencyCycles`, which emits `task:dependency-cycle-detected`, auto-repairs only bounded umbrella-back-edge loops via `task:auto-reconciled-dependency-cycle`, and leaves ambiguous cycles untouched with `task:dependency-cycle-unrepaired` for operator inspection.
|
||||
|
||||
#### BlockedBy stamping invariants
|
||||
- Scheduler writes overlap-based `blockedBy` only when overlap gating is active and there is a live overlapping active scope; otherwise overlap logic does not stamp blockers.
|
||||
|
||||
@@ -75,6 +75,66 @@ describe("TaskStore dependency cycle guard", () => {
|
||||
expect(parent.dependencies).toEqual([childA.id, childB.id]);
|
||||
});
|
||||
|
||||
it("rejects FN-5240/FN-5241/FN-5242 write-time cycle signature", async () => {
|
||||
const store = harness.store();
|
||||
const a = await store.createTask({ title: "FN-5240", description: "A" });
|
||||
const b = await store.createTask({ title: "FN-5241", description: "B" });
|
||||
const c = await store.createTask({ title: "FN-5242", description: "C" });
|
||||
|
||||
await store.updateTask(b.id, { dependencies: [c.id] });
|
||||
await store.updateTask(c.id, { dependencies: [a.id] });
|
||||
|
||||
let error: DependencyCycleError | null = null;
|
||||
try {
|
||||
await store.updateTask(a.id, { dependencies: [b.id] });
|
||||
} catch (caught) {
|
||||
error = caught as DependencyCycleError;
|
||||
}
|
||||
|
||||
expect(error).toBeInstanceOf(DependencyCycleError);
|
||||
expect(error?.cyclePath).toEqual([a.id, b.id, c.id, a.id]);
|
||||
expect(error?.message).toContain(`${a.id} → ${b.id} → ${c.id} → ${a.id}`);
|
||||
|
||||
const refreshedA = await store.getTask(a.id);
|
||||
expect(refreshedA.dependencies).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects umbrella back-edge update and records source metadata", async () => {
|
||||
const store = harness.store();
|
||||
const childA = await store.createTask({ title: "child-a", description: "a" });
|
||||
const childB = await store.createTask({ title: "child-b", description: "b" });
|
||||
const umbrella = await store.createTask({
|
||||
title: "umbrella parent",
|
||||
description: "u",
|
||||
dependencies: [childA.id, childB.id],
|
||||
});
|
||||
|
||||
await expect(store.updateTask(childA.id, { dependencies: [umbrella.id] })).rejects.toBeInstanceOf(DependencyCycleError);
|
||||
|
||||
const rows = (store as any).db
|
||||
.prepare(
|
||||
"SELECT mutationType, metadata FROM runAuditEvents WHERE taskId = ? AND mutationType = ?",
|
||||
)
|
||||
.all(childA.id, "task:dependency-cycle-rejected") as Array<{
|
||||
mutationType: string;
|
||||
metadata: string | { source?: string };
|
||||
}>;
|
||||
expect(rows).toHaveLength(1);
|
||||
const metadata = typeof rows[0].metadata === "string" ? JSON.parse(rows[0].metadata) : rows[0].metadata;
|
||||
expect(metadata.source).toBe("updateTask");
|
||||
});
|
||||
|
||||
it("rejects indirect cycle via existing dependency chain", async () => {
|
||||
const store = harness.store();
|
||||
const a = await store.createTask({ title: "A", description: "A" });
|
||||
const b = await store.createTask({ title: "B", description: "B", dependencies: [a.id] });
|
||||
const c = await store.createTask({ title: "C", description: "C", dependencies: [b.id] });
|
||||
|
||||
await expect(store.updateTask(a.id, { dependencies: [c.id] })).rejects.toMatchObject({
|
||||
cyclePath: [a.id, c.id, b.id, a.id],
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts non-cyclic updates", async () => {
|
||||
const store = harness.store();
|
||||
const a = await store.createTask({ title: "A", description: "A" });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user