feat(FN-4891): merge fusion/fn-4891
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { hasGit, makeReliabilityFixture } from "./_helpers.js";
|
||||
|
||||
const describeIfGit = hasGit ? describe : describe.skip;
|
||||
|
||||
describeIfGit("reliability interactions: self-defeating dep reconciliation", () => {
|
||||
const fixtures: Array<Awaited<ReturnType<typeof makeReliabilityFixture>>> = [];
|
||||
|
||||
afterEach(async () => {
|
||||
while (fixtures.length) await fixtures.pop()!.cleanup();
|
||||
});
|
||||
|
||||
it("reconciles pre-existing offender in todo and records audit + task log", async () => {
|
||||
const fx = await makeReliabilityFixture({
|
||||
taskId: "FN-4891-A",
|
||||
task: {
|
||||
column: "todo",
|
||||
title: "safe title",
|
||||
dependencies: ["FN-100", "FN-200"],
|
||||
} as any,
|
||||
});
|
||||
fixtures.push(fx);
|
||||
|
||||
await fx.store.updateTask(fx.task.id, { title: "Finalize FN-100: close loop" });
|
||||
|
||||
const recovered = await fx.manager.reconcileSelfDefeatingDependencies();
|
||||
expect(recovered).toBe(1);
|
||||
|
||||
const updated = await fx.store.getTask(fx.task.id);
|
||||
expect(updated?.dependencies).toEqual(["FN-200"]);
|
||||
expect(
|
||||
updated?.log.some((entry) => JSON.stringify(entry).includes("Auto-reconciled self-defeating dependency")),
|
||||
).toBe(true);
|
||||
|
||||
const events = fx.store.getRunAuditEvents({
|
||||
taskId: fx.task.id,
|
||||
domain: "database",
|
||||
mutationType: "task:auto-reconciled-self-defeating-dep",
|
||||
});
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]?.target).toBe(fx.task.id);
|
||||
expect(events[0]?.metadata).toMatchObject({
|
||||
matchedVerb: "finalize",
|
||||
operandTaskId: "FN-100",
|
||||
originalDependencies: ["FN-100", "FN-200"],
|
||||
nextDependencies: ["FN-200"],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not reconcile non-operational test title", async () => {
|
||||
const fx = await makeReliabilityFixture({
|
||||
taskId: "FN-4891-B",
|
||||
task: {
|
||||
column: "todo",
|
||||
title: "Test FN-100",
|
||||
dependencies: ["FN-100"],
|
||||
} as any,
|
||||
});
|
||||
fixtures.push(fx);
|
||||
|
||||
const recovered = await fx.manager.reconcileSelfDefeatingDependencies();
|
||||
expect(recovered).toBe(0);
|
||||
|
||||
const updated = await fx.store.getTask(fx.task.id);
|
||||
expect(updated?.dependencies).toEqual(["FN-100"]);
|
||||
});
|
||||
|
||||
it("does not touch in-progress offenders", async () => {
|
||||
const fx = await makeReliabilityFixture({
|
||||
taskId: "FN-4891-C",
|
||||
task: {
|
||||
column: "in-progress",
|
||||
title: "safe title",
|
||||
dependencies: ["FN-100"],
|
||||
} as any,
|
||||
});
|
||||
fixtures.push(fx);
|
||||
|
||||
await fx.store.updateTask(fx.task.id, { title: "Finalize FN-100" });
|
||||
|
||||
const recovered = await fx.manager.reconcileSelfDefeatingDependencies();
|
||||
expect(recovered).toBe(0);
|
||||
|
||||
const updated = await fx.store.getTask(fx.task.id);
|
||||
expect(updated?.dependencies).toEqual(["FN-100"]);
|
||||
});
|
||||
});
|
||||
@@ -667,6 +667,13 @@ export function createTaskCreateTool(
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
if (err instanceof Error && (err as { code?: string }).code === "SELF_DEFEATING_DEPENDENCY") {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `ERROR: ${err.message}` }],
|
||||
details: {},
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -135,6 +135,7 @@ export type DatabaseMutationType =
|
||||
| "task:auto-recover-finalize-already-on-main"
|
||||
| "task:auto-recover-branch-misbound"
|
||||
| "task:auto-recover-node-unreachable"
|
||||
| "task:auto-reconciled-self-defeating-dep"
|
||||
/**
|
||||
* Metadata shape for node:handoff:* and node:lease:* events:
|
||||
* ```ts
|
||||
|
||||
@@ -26,7 +26,7 @@ import { exec, execSync } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { existsSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||
import { isAbsolute, join, relative, resolve } from "node:path";
|
||||
import { getInReviewStallReason, getStalePausedReviewSignal, getTaskHardMergeBlocker, getTaskMergeBlocker, isEphemeralAgent, type AgentStore, type ChatStore, type MessageStore, type TaskStore, type Settings, type Task, type MergeDetails, type TaskPriority } from "@fusion/core";
|
||||
import { detectSelfDefeatingDependency, getInReviewStallReason, getStalePausedReviewSignal, getTaskHardMergeBlocker, getTaskMergeBlocker, isEphemeralAgent, type AgentStore, type ChatStore, type MessageStore, type TaskStore, type Settings, type Task, type MergeDetails, type TaskPriority } from "@fusion/core";
|
||||
import type { MeshLeaseManager } from "./mesh-lease-manager.js";
|
||||
import { createLogger } from "./logger.js";
|
||||
import { RemovalReason, getRegisteredWorktreePaths, isUsableTaskWorktree, removeWorktree, resolveWorktreeBackend, scanIdleWorktrees, scanOrphanedBranches } from "./worktree-pool.js";
|
||||
@@ -472,6 +472,7 @@ export class SelfHealingManager {
|
||||
{ name: "recover-running-on-inactive-tasks", fn: () => this.recoverAgentsRunningOnInactiveTasks().then(() => undefined) },
|
||||
{ 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: "reclaim-self-owned-branch-conflicts", fn: () => this.reclaimSelfOwnedBranchConflicts().then(() => undefined) },
|
||||
{ name: "reclaim-stale-active-branches", fn: () => this.reclaimStaleActiveBranches().then(() => undefined) },
|
||||
{ name: "surface-in-review-stalls", fn: () => this.surfaceInReviewStalls().then(() => undefined) },
|
||||
@@ -1045,6 +1046,7 @@ export class SelfHealingManager {
|
||||
{ name: "recover-running-on-inactive-tasks", fn: () => this.recoverAgentsRunningOnInactiveTasks() },
|
||||
{ name: "recover-drifted-agent-task-links", fn: () => this.recoverDriftedAgentTaskLinks() },
|
||||
{ name: "clear-stale-blocked-by", fn: () => this.clearStaleBlockedBy() },
|
||||
{ name: "reconcile-self-defeating-deps", fn: () => this.reconcileSelfDefeatingDependencies() },
|
||||
{ name: "reclaim-self-owned-branch-conflicts", fn: () => this.reclaimSelfOwnedBranchConflicts() },
|
||||
{ name: "reclaim-stale-active-branches", fn: () => this.reclaimStaleActiveBranches() },
|
||||
{ name: "surface-in-review-stalls", fn: () => this.surfaceInReviewStalls() },
|
||||
@@ -2233,6 +2235,64 @@ export class SelfHealingManager {
|
||||
}
|
||||
}
|
||||
|
||||
async reconcileSelfDefeatingDependencies(): Promise<number> {
|
||||
const targetColumns: Array<Task["column"]> = ["triage", "planning", "todo"];
|
||||
let recovered = 0;
|
||||
|
||||
for (const column of targetColumns) {
|
||||
let tasks: Task[] = [];
|
||||
try {
|
||||
tasks = await this.store.listTasks({ column, slim: true });
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
log.warn(`reconcileSelfDefeatingDependencies: failed to list ${column} tasks: ${errorMessage}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const task of tasks) {
|
||||
if (!task.dependencies.length) continue;
|
||||
|
||||
const match = detectSelfDefeatingDependency(task.title, task.dependencies);
|
||||
if (!match) continue;
|
||||
|
||||
const originalDependencies = [...task.dependencies];
|
||||
const nextDependencies = originalDependencies.filter((dep) => dep.toUpperCase() !== match.operandTaskId.toUpperCase());
|
||||
if (nextDependencies.length === originalDependencies.length) continue;
|
||||
|
||||
try {
|
||||
await this.store.updateTask(task.id, { dependencies: nextDependencies });
|
||||
await this.store.logEntry(
|
||||
task.id,
|
||||
`Auto-reconciled self-defeating dependency: removed ${match.operandTaskId} (matched verb: "${match.matchedVerb}") from dependencies.`,
|
||||
);
|
||||
|
||||
const auditor = createRunAuditor(this.store, {
|
||||
runId: generateSyntheticRunId("self-heal-self-defeating-dep"),
|
||||
agentId: "system:self-healing",
|
||||
taskId: task.id,
|
||||
phase: "reconcile-self-defeating-dep",
|
||||
});
|
||||
await auditor.database({
|
||||
type: "task:auto-reconciled-self-defeating-dep",
|
||||
target: task.id,
|
||||
metadata: {
|
||||
matchedVerb: match.matchedVerb,
|
||||
operandTaskId: match.operandTaskId,
|
||||
originalDependencies,
|
||||
nextDependencies,
|
||||
},
|
||||
});
|
||||
recovered++;
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
log.warn(`reconcileSelfDefeatingDependencies: 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", metadata: Record<string, unknown>): Promise<void> {
|
||||
const auditor = createRunAuditor(this.store, {
|
||||
runId: generateSyntheticRunId("self-healing-integrity", taskId),
|
||||
|
||||
Reference in New Issue
Block a user