feat(FN-5220): guard explicit duplicate markers in triage and self-healing
Adds an explicit duplicate-marker guard (FN-5220) spanning core helper, dashboard API endpoint, triage short-circuit, and self-healing sweep to detect and handle duplicate task creation attempts; includes comprehensive test coverage across unit, API, and integration layers plus documentation. Fusion-Task-Id: FN-5220
This commit is contained in:
committed by
gsxdsm
parent
9eea9f48f0
commit
8f2d5e7e61
12
.changeset/FN-5220-explicit-duplicate-marker-guard.md
Normal file
12
.changeset/FN-5220-explicit-duplicate-marker-guard.md
Normal file
@@ -0,0 +1,12 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Block explicit `DUPLICATE: FN-NNNN` redirect tasks from consuming planning
|
||||
cycles. The triage planning loop now short-circuits when the generated
|
||||
PROMPT.md is a one-line duplicate marker (bypassing the `fn_review_spec`
|
||||
APPROVE gate), a self-healing sweep resolves already-stuck duplicate-marker
|
||||
tasks in `triage`/`todo`, and the dashboard `POST /api/tasks` route
|
||||
surfaces a `409 duplicate_candidates` with `reason: "explicit-marker"` when
|
||||
the description is exactly a duplicate redirect. Layered on top of
|
||||
FN-4829 / FN-4918 / FN-5152; fails open.
|
||||
@@ -96,6 +96,44 @@ Near-duplicate archival is reversible and leaves lineage markers behind:
|
||||
|
||||
This layer complements, rather than replaces, FN-4829 similarity detection, FN-4918 deterministic deduplication, and FN-4892 same-agent intake heuristics.
|
||||
|
||||
#### Explicit duplicate-marker guard (FN-5220)
|
||||
|
||||
Fusion also recognizes the canonical one-line redirect marker:
|
||||
|
||||
- `DUPLICATE: FN-1234`
|
||||
- `` `DUPLICATE: FN-1234` ``
|
||||
- `**DUPLICATE: FN-1234**`
|
||||
- fenced single-line wrappers such as:
|
||||
|
||||
```text
|
||||
DUPLICATE: FN-1234
|
||||
```
|
||||
|
||||
The shared parser lives in `packages/core/src/explicit-duplicate-marker.ts` (`parseExplicitDuplicateMarker`). It is intentionally strict: after trimming outer whitespace and one optional wrapper layer, the content must reduce to exactly one substantive line matching `^DUPLICATE:\s*FN-\d+$`. Any extra prose, multiple markers, or full PROMPT bodies that merely mention duplicate text are ignored.
|
||||
|
||||
This guard adds three fail-open layers on top of the existing duplicate stack, in final order:
|
||||
|
||||
1. Deterministic fingerprint guard (FN-4918 / FN-5060)
|
||||
2. Similarity warning gate (FN-4829)
|
||||
3. Near-duplicate intent guard (FN-5152)
|
||||
4. Explicit duplicate-marker guard (FN-5220)
|
||||
|
||||
Layer behavior:
|
||||
|
||||
- **Dashboard intake (`POST /api/tasks`)** — after deterministic/similarity/near-duplicate checks and before `createTask`, intake returns `409 duplicate_candidates` with `reason: "explicit-marker"` when the combined title/description is exactly a canonical redirect and the canonical target exists. `acknowledgedDuplicates` and `bypassDuplicateCheck: true` both suppress the conflict. Because this guard runs before task creation, the activity breadcrumb is attached to the canonical target.
|
||||
- **Triage planning loop** — after triage reads the generated `PROMPT.md` but before the `fn_review_spec()` APPROVE gate, an exact redirect marker short-circuits directly into `finalizeApprovedTask()`. This prevents one-line redirect specs from burning review reminders or fallback planning retries.
|
||||
- **Self-healing sweep** — maintenance Batch 2 runs `resolveExplicitDuplicateMarkerTasks()` across `triage`/`todo` tasks to clean up older stuck marker tasks. The sweep is best-effort, capped at 50 marker tasks per cycle, and can be disabled with the internal setting `resolveExplicitDuplicateMarkerEnabled: false` (default `true`).
|
||||
|
||||
All three layers fail open: parse errors, task lookup failures, file-read failures, activity-recording errors, or other unexpected exceptions log a warning and continue normal intake/triage/self-healing flow instead of blocking task creation or recovery.
|
||||
|
||||
Activity uses the existing `task:auto-archived-duplicate` event with `metadata.source` disambiguators:
|
||||
|
||||
- `explicit-marker` — triage short-circuit / duplicate finalize path
|
||||
- `explicit-marker-sweep` — self-healing maintenance sweep
|
||||
- `explicit-marker-intake` — dashboard intake rejection breadcrumb
|
||||
|
||||
The duplicate-close task log line remains `Duplicate of <canonicalTaskId> — closed` for triage/sweep paths.
|
||||
|
||||
### Intake auto-archive (ghost-bug preflight + same-agent duplicate)
|
||||
|
||||
Fusion applies two conservative intake heuristics that may auto-archive newly filed tasks before execution starts:
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { parseExplicitDuplicateMarker } from "../explicit-duplicate-marker.js";
|
||||
|
||||
const FULL_PROMPT = `# Task: FN-5211 - Example
|
||||
|
||||
## Mission
|
||||
This is a duplicate-handling task, but it is a full spec body.
|
||||
|
||||
- Implement the fix
|
||||
- Verify the result
|
||||
`;
|
||||
|
||||
describe("parseExplicitDuplicateMarker", () => {
|
||||
it("parses a canonical marker", () => {
|
||||
expect(parseExplicitDuplicateMarker("DUPLICATE: FN-5211")).toEqual({
|
||||
canonicalId: "FN-5211",
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes case and surrounding whitespace", () => {
|
||||
expect(parseExplicitDuplicateMarker(" duplicate: fn-5211\n")).toEqual({
|
||||
canonicalId: "FN-5211",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses a backtick-wrapped marker", () => {
|
||||
expect(parseExplicitDuplicateMarker("`DUPLICATE: FN-5211`")).toEqual({
|
||||
canonicalId: "FN-5211",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses a bold-wrapped marker", () => {
|
||||
expect(parseExplicitDuplicateMarker("**DUPLICATE: FN-5211**")).toEqual({
|
||||
canonicalId: "FN-5211",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses a marker padded by blank lines", () => {
|
||||
expect(parseExplicitDuplicateMarker("\n\n\nDUPLICATE: FN-5211\n\n")).toEqual({
|
||||
canonicalId: "FN-5211",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses a fenced marker", () => {
|
||||
expect(parseExplicitDuplicateMarker("```text\nDUPLICATE: FN-5211\n```")).toEqual({
|
||||
canonicalId: "FN-5211",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a full prompt body that merely mentions duplicate", () => {
|
||||
expect(parseExplicitDuplicateMarker(FULL_PROMPT)).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects extra prose after the marker", () => {
|
||||
expect(parseExplicitDuplicateMarker("DUPLICATE: FN-5211\n\nSee also FN-5212")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects multiple markers", () => {
|
||||
expect(parseExplicitDuplicateMarker("DUPLICATE: FN-5211\nDUPLICATE: FN-5212")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects empty input", () => {
|
||||
expect(parseExplicitDuplicateMarker(" ")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects non-FN identifiers", () => {
|
||||
expect(parseExplicitDuplicateMarker("DUPLICATE: NOT-1234")).toBeNull();
|
||||
});
|
||||
});
|
||||
52
packages/core/src/explicit-duplicate-marker.ts
Normal file
52
packages/core/src/explicit-duplicate-marker.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
export interface ExplicitDuplicateMarker {
|
||||
canonicalId: string;
|
||||
}
|
||||
|
||||
function stripCodeFenceLayer(content: string): string {
|
||||
const fenceMatch = content.match(/^```(?:[\t ]*(?:text|markdown))?[\t ]*\n([\s\S]*?)\n```$/i);
|
||||
if (!fenceMatch) {
|
||||
return content;
|
||||
}
|
||||
return fenceMatch[1] ?? "";
|
||||
}
|
||||
|
||||
function stripSingleWrapper(line: string): string {
|
||||
if (line.startsWith("`") && line.endsWith("`") && line.length >= 2) {
|
||||
return line.slice(1, -1).trim();
|
||||
}
|
||||
if (line.startsWith("**") && line.endsWith("**") && line.length >= 4) {
|
||||
return line.slice(2, -2).trim();
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects the canonical triage "redirect" marker emitted by the planning
|
||||
* agent when the new task duplicates an existing one.
|
||||
*/
|
||||
export function parseExplicitDuplicateMarker(content: string): ExplicitDuplicateMarker | null {
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const withoutFence = stripCodeFenceLayer(trimmed).trim();
|
||||
const nonBlankLines = withoutFence
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0);
|
||||
|
||||
if (nonBlankLines.length !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidate = stripSingleWrapper(nonBlankLines[0] ?? "");
|
||||
const match = candidate.match(/^DUPLICATE:\s*(FN-\d+)\s*$/i);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
canonicalId: match[1].toUpperCase(),
|
||||
};
|
||||
}
|
||||
@@ -156,6 +156,10 @@ export {
|
||||
type NearDuplicateMatch,
|
||||
} from "./near-duplicate.js";
|
||||
export { getTaskDuplicateLineage } from "./duplicate-lineage.js";
|
||||
export {
|
||||
parseExplicitDuplicateMarker,
|
||||
type ExplicitDuplicateMarker,
|
||||
} from "./explicit-duplicate-marker.js";
|
||||
export {
|
||||
__getDeterministicGuardMutexSize,
|
||||
deterministicGuardLocks,
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
// @vitest-environment node
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import express from "express";
|
||||
import * as core from "@fusion/core";
|
||||
import type { Column, Task, TaskStore } from "@fusion/core";
|
||||
import { registerTaskWorkflowRoutes } from "../routes/register-task-workflow-routes.js";
|
||||
import { request as performRequest } from "../test-request.js";
|
||||
import { ApiError, sendErrorResponse } from "../api-error.js";
|
||||
|
||||
function mkTask(overrides: Partial<Task> & { id: string; description: string; column: Column }): Task {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id: overrides.id,
|
||||
description: overrides.description,
|
||||
column: overrides.column,
|
||||
dependencies: [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
size: "M",
|
||||
subtasks: [],
|
||||
log: [],
|
||||
tags: [],
|
||||
blockedBy: [],
|
||||
source: { sourceType: "api" },
|
||||
...overrides,
|
||||
} as Task;
|
||||
}
|
||||
|
||||
function buildApp(seed: Task[] = []) {
|
||||
const tasks = [...seed];
|
||||
const runtimeLogger = { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() };
|
||||
const store: Partial<TaskStore> = {
|
||||
searchTasks: vi.fn().mockResolvedValue(tasks),
|
||||
listTasks: vi.fn().mockResolvedValue(tasks),
|
||||
getTask: vi.fn().mockImplementation(async (id: string) => tasks.find((task) => task.id === id) ?? null),
|
||||
findRecentTasksByContentFingerprint: vi.fn().mockImplementation(async (fingerprint: string) =>
|
||||
tasks.filter((task) => task.source?.sourceMetadata?.contentFingerprint === fingerprint),
|
||||
),
|
||||
getSettingsFast: vi.fn().mockResolvedValue({ autoSummarizeTitles: false }),
|
||||
createTask: vi.fn().mockImplementation(async (input: { title?: string; description: string; source?: Task["source"] }) => {
|
||||
const created = mkTask({ id: `FN-${tasks.length + 100}`, title: input.title, description: input.description, column: "todo", source: input.source ?? { sourceType: "api" } });
|
||||
tasks.push(created);
|
||||
return created;
|
||||
}),
|
||||
recordActivity: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const router = express.Router();
|
||||
registerTaskWorkflowRoutes({
|
||||
router,
|
||||
store: store as TaskStore,
|
||||
options: {},
|
||||
runtimeLogger: runtimeLogger as never,
|
||||
planningLogger: runtimeLogger as never,
|
||||
chatLogger: runtimeLogger as never,
|
||||
getProjectIdFromRequest: () => undefined,
|
||||
getScopedStore: async () => store as TaskStore,
|
||||
getProjectContext: async () => ({ store: store as TaskStore, engine: undefined, projectId: "p-1" }),
|
||||
prioritizeProjectsForCurrentDirectory: (projects) => projects,
|
||||
emitRemoteRouteDiagnostic: () => {},
|
||||
emitAuthSyncAuditLog: () => {},
|
||||
parseScopeParam: () => undefined,
|
||||
resolveAutomationStore: () => ({}) as never,
|
||||
resolveRoutineStore: () => ({}) as never,
|
||||
resolveRoutineRunner: () => ({}) as never,
|
||||
registerDispose: () => {},
|
||||
dispose: () => {},
|
||||
rethrowAsApiError: (error: unknown): never => {
|
||||
if (error instanceof ApiError) throw error;
|
||||
throw new ApiError(500, error instanceof Error ? error.message : "Internal server error");
|
||||
},
|
||||
}, {
|
||||
runtimeLogger: { error: vi.fn(), warn: runtimeLogger.warn },
|
||||
upload: { single: () => (_req: unknown, _res: unknown, next: () => void) => next() },
|
||||
taskDetailActivityLogLimit: 100,
|
||||
validateOptionalModelField: (value) => (typeof value === "string" ? value : undefined),
|
||||
normalizeModelSelectionPair: (provider, modelId) => ({ provider: provider ?? null, modelId: modelId ?? null }),
|
||||
runGitCommand: async () => "",
|
||||
trimTaskDetailActivityLog: (task) => task,
|
||||
triggerCommentWakeForAssignedAgent: async () => {},
|
||||
});
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", router);
|
||||
app.use((error: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||
if (error instanceof ApiError) {
|
||||
sendErrorResponse(res, error.statusCode, error.message, { details: error.details });
|
||||
return;
|
||||
}
|
||||
sendErrorResponse(res, 500, error instanceof Error ? error.message : "Internal server error");
|
||||
});
|
||||
|
||||
return { app, store, tasks, runtimeLogger };
|
||||
}
|
||||
|
||||
describe("routes /api/tasks explicit duplicate marker", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns 409 duplicate_candidates when description is an explicit marker", async () => {
|
||||
const canonical = mkTask({ id: "FN-42", title: "Canonical", description: "Existing canonical task", column: "todo" });
|
||||
const { app, tasks, store } = buildApp([canonical]);
|
||||
|
||||
const res = await performRequest(app, "POST", "/api/tasks", JSON.stringify({ description: "DUPLICATE: FN-42" }), { "content-type": "application/json" });
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
expect((res.body as { details: { matches: Array<{ id: string; reason: string }> } }).details.matches[0]).toMatchObject({
|
||||
id: canonical.id,
|
||||
reason: "explicit-marker",
|
||||
});
|
||||
expect(tasks).toHaveLength(1);
|
||||
expect(store.createTask).not.toHaveBeenCalled();
|
||||
expect(store.recordActivity).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: "task:auto-archived-duplicate",
|
||||
taskId: canonical.id,
|
||||
metadata: expect.objectContaining({ canonicalTaskId: canonical.id, source: "explicit-marker-intake" }),
|
||||
}));
|
||||
});
|
||||
|
||||
it("returns 409 when the explicit marker is supplied in the title with blank description padding", async () => {
|
||||
const canonical = mkTask({ id: "FN-42", title: "Canonical", description: "Existing canonical task", column: "todo" });
|
||||
const { app, tasks } = buildApp([canonical]);
|
||||
const res = await performRequest(app, "POST", "/api/tasks", JSON.stringify({ title: "DUPLICATE: FN-42", description: " " }), { "content-type": "application/json" });
|
||||
expect(res.status).toBe(409);
|
||||
expect((res.body as { details: { matches: Array<{ id: string; reason: string }> } }).details.matches[0]).toMatchObject({ id: canonical.id, reason: "explicit-marker" });
|
||||
expect(tasks).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("blocks backtick-wrapped markers", async () => {
|
||||
const canonical = mkTask({ id: "FN-42", title: "Canonical", description: "Existing canonical task", column: "todo" });
|
||||
const { app } = buildApp([canonical]);
|
||||
const res = await performRequest(app, "POST", "/api/tasks", JSON.stringify({ description: "`DUPLICATE: FN-42`" }), { "content-type": "application/json" });
|
||||
expect(res.status).toBe(409);
|
||||
expect((res.body as { details: { matches: Array<{ id: string; reason: string }> } }).details.matches[0]).toMatchObject({ id: canonical.id, reason: "explicit-marker" });
|
||||
});
|
||||
|
||||
it("acknowledgedDuplicates bypasses the explicit-marker guard", async () => {
|
||||
const canonical = mkTask({ id: "FN-42", title: "Canonical", description: "Existing canonical task", column: "todo" });
|
||||
const { app, tasks, store } = buildApp([canonical]);
|
||||
const res = await performRequest(app, "POST", "/api/tasks", JSON.stringify({ description: "DUPLICATE: FN-42", acknowledgedDuplicates: ["FN-42"] }), { "content-type": "application/json" });
|
||||
expect(res.status).toBe(201);
|
||||
expect(tasks).toHaveLength(2);
|
||||
expect(store.recordActivity).not.toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: "task:auto-archived-duplicate",
|
||||
metadata: expect.objectContaining({ source: "explicit-marker-intake" }),
|
||||
}));
|
||||
});
|
||||
|
||||
it("bypassDuplicateCheck bypasses the explicit-marker guard", async () => {
|
||||
const canonical = mkTask({ id: "FN-42", title: "Canonical", description: "Existing canonical task", column: "todo" });
|
||||
const { app, tasks, store } = buildApp([canonical]);
|
||||
const res = await performRequest(app, "POST", "/api/tasks", JSON.stringify({ description: "DUPLICATE: FN-42", bypassDuplicateCheck: true }), { "content-type": "application/json" });
|
||||
expect(res.status).toBe(201);
|
||||
expect(tasks).toHaveLength(2);
|
||||
expect(store.recordActivity).not.toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: "task:auto-archived-duplicate",
|
||||
metadata: expect.objectContaining({ source: "explicit-marker-intake" }),
|
||||
}));
|
||||
});
|
||||
|
||||
it("fails open when the marker target is missing", async () => {
|
||||
const { app, tasks } = buildApp();
|
||||
const res = await performRequest(app, "POST", "/api/tasks", JSON.stringify({ description: "DUPLICATE: FN-999" }), { "content-type": "application/json" });
|
||||
expect(res.status).toBe(201);
|
||||
expect(tasks).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("fails open when the marker target is soft-deleted", async () => {
|
||||
const canonical = mkTask({ id: "FN-42", title: "Canonical", description: "Existing canonical task", column: "archived", deletedAt: new Date().toISOString() });
|
||||
const { app, tasks } = buildApp([canonical]);
|
||||
const res = await performRequest(app, "POST", "/api/tasks", JSON.stringify({ description: "DUPLICATE: FN-42" }), { "content-type": "application/json" });
|
||||
expect(res.status).toBe(201);
|
||||
expect(tasks).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("does not block prose that merely mentions duplicate text", async () => {
|
||||
const canonical = mkTask({ id: "FN-42", title: "Canonical", description: "Existing canonical task", column: "todo" });
|
||||
const { app, tasks } = buildApp([canonical]);
|
||||
const res = await performRequest(app, "POST", "/api/tasks", JSON.stringify({ description: "We thought this was a DUPLICATE: FN-42 but it is not." }), { "content-type": "application/json" });
|
||||
expect(res.status).toBe(201);
|
||||
expect(tasks).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("keeps deterministic matching ahead of the explicit-marker guard", async () => {
|
||||
const title = "";
|
||||
const description = "DUPLICATE: FN-42";
|
||||
const fingerprint = core.computeContentFingerprint({ title, description }) as string;
|
||||
const { app } = buildApp([
|
||||
mkTask({
|
||||
id: "FN-500",
|
||||
title,
|
||||
description,
|
||||
column: "todo",
|
||||
source: { sourceType: "api", sourceMetadata: { contentFingerprint: fingerprint } },
|
||||
}),
|
||||
mkTask({ id: "FN-42", title: "Canonical", description: "Existing canonical task", column: "todo" }),
|
||||
]);
|
||||
|
||||
const res = await performRequest(app, "POST", "/api/tasks", JSON.stringify({ description }), { "content-type": "application/json" });
|
||||
expect(res.status).toBe(409);
|
||||
expect((res.body as { details: { matches: Array<{ id: string; deterministic?: boolean; reason?: string }> } }).details.matches[0]).toMatchObject({
|
||||
id: "FN-500",
|
||||
deterministic: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("fails open when parseExplicitDuplicateMarker throws", async () => {
|
||||
const canonical = mkTask({ id: "FN-42", title: "Canonical", description: "Existing canonical task", column: "todo" });
|
||||
vi.spyOn(core, "parseExplicitDuplicateMarker").mockImplementation(() => {
|
||||
throw new Error("boom");
|
||||
});
|
||||
const { app, tasks, runtimeLogger } = buildApp([canonical]);
|
||||
|
||||
const res = await performRequest(app, "POST", "/api/tasks", JSON.stringify({ description: "DUPLICATE: FN-42" }), { "content-type": "application/json" });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(tasks).toHaveLength(2);
|
||||
expect(runtimeLogger.warn).toHaveBeenCalledWith("Explicit duplicate-marker intake guard failed; proceeding", expect.objectContaining({ error: "boom" }));
|
||||
});
|
||||
});
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
extractIntentSignature,
|
||||
findNearDuplicates,
|
||||
isEphemeralAgent,
|
||||
parseExplicitDuplicateMarker,
|
||||
type NearDuplicateCandidate,
|
||||
} from "@fusion/core";
|
||||
import { GitHubClient } from "../github.js";
|
||||
@@ -657,6 +658,56 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
||||
}
|
||||
}
|
||||
|
||||
// FN-5220: layered intake ordering remains deterministic -> similarity -> near-duplicate intent -> explicit-marker.
|
||||
try {
|
||||
const combinedText = `${normalizedTitle ?? ""}\n${normalizedDescription}`;
|
||||
const explicitDuplicateMarker = parseExplicitDuplicateMarker(combinedText);
|
||||
const explicitMarkerBypassed =
|
||||
bypassDuplicateCheck === true ||
|
||||
(explicitDuplicateMarker ? acknowledgedDuplicateIds.includes(explicitDuplicateMarker.canonicalId) : false);
|
||||
if (explicitDuplicateMarker && !explicitMarkerBypassed) {
|
||||
const canonical = await scopedStore.getTask(explicitDuplicateMarker.canonicalId).catch(() => null);
|
||||
if (canonical && !canonical.deletedAt) {
|
||||
try {
|
||||
// The intake guard runs before createTask, so there is no new task row yet.
|
||||
// Record against the canonical target to leave a traceable audit breadcrumb.
|
||||
await scopedStore.recordActivity({
|
||||
type: "task:auto-archived-duplicate",
|
||||
taskId: canonical.id,
|
||||
taskTitle: canonical.title ?? "",
|
||||
details: `Rejected explicit duplicate-marker intake redirect to ${canonical.id}`,
|
||||
metadata: {
|
||||
canonicalTaskId: canonical.id,
|
||||
source: "explicit-marker-intake",
|
||||
},
|
||||
});
|
||||
} catch (activityError) {
|
||||
runtimeLogger.warn("Explicit duplicate-marker intake activity recording failed; proceeding with conflict response", {
|
||||
canonicalTaskId: canonical.id,
|
||||
error: activityError instanceof Error ? activityError.message : String(activityError),
|
||||
});
|
||||
}
|
||||
throw conflict("duplicate_candidates", {
|
||||
matches: [{
|
||||
id: canonical.id,
|
||||
title: canonical.title ?? "",
|
||||
description: canonical.description ?? "",
|
||||
column: canonical.column,
|
||||
score: 1,
|
||||
reason: "explicit-marker",
|
||||
}],
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) {
|
||||
throw error;
|
||||
}
|
||||
runtimeLogger.warn("Explicit duplicate-marker intake guard failed; proceeding", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
const normalizedTaskSource = normalizedSource as TaskSource;
|
||||
const createInput = {
|
||||
title: normalizedTitle,
|
||||
|
||||
@@ -22,7 +22,7 @@ const qualityAppTests = [
|
||||
const qualityApiTests = [
|
||||
// Critical HTTP/server behavior: auth, task/project/settings mutation,
|
||||
// git/GitHub, agents, nodes, chat/files, realtime, and isolation guards.
|
||||
"src/__tests__/{api-error,auth-middleware,auth-middleware-integration,chat-attachment-routes,chat-routes,file-service,github,github-webhooks,initialize,planning-flow-diagnostics-guardrail,pr-routes-auto-merge,pr-routes.contract,project-routes,project-store-resolver,register-git-github.pr-options-preflight-metadata,remote-access-routes,remote-auth,routes-agent-budget,routes-agent-keys,routes-agent-permissions,routes-agent-ratings,routes-agent-runs,routes-agent-soul-memory,routes-agents,routes-automation,routes-git,routes-github,routes-nodes,routes-nodes-sync-contract,routes-secrets-sync,routes-settings,routes-task-commit-associations,routes-tasks,routes-tasks-deterministic-dedup,routes-tasks-duplicate-check,server,server-static-assets,server-webhook,server.events,setup-routes,sse,sse-buffer,test-isolation-guard,update-check-route,websocket,recover-branch-binding-route}.test.ts",
|
||||
"src/__tests__/{api-error,auth-middleware,auth-middleware-integration,chat-attachment-routes,chat-routes,file-service,github,github-webhooks,initialize,planning-flow-diagnostics-guardrail,pr-routes-auto-merge,pr-routes.contract,project-routes,project-store-resolver,register-git-github.pr-options-preflight-metadata,remote-access-routes,remote-auth,routes-agent-budget,routes-agent-keys,routes-agent-permissions,routes-agent-ratings,routes-agent-runs,routes-agent-soul-memory,routes-agents,routes-automation,routes-git,routes-github,routes-nodes,routes-nodes-sync-contract,routes-secrets-sync,routes-settings,routes-task-commit-associations,routes-tasks,routes-tasks-deterministic-dedup,routes-tasks-duplicate-check,routes-tasks-explicit-duplicate-marker,server,server-static-assets,server-webhook,server.events,setup-routes,sse,sse-buffer,test-isolation-guard,update-check-route,websocket,recover-branch-binding-route}.test.ts",
|
||||
"src/routes/__tests__/{custom-provider-routes,custom-providers,register-docker-node-routes,stash-recovery-routes}.test.ts",
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { makeReliabilityFixture, type ReliabilityFixture } from "./_helpers.js";
|
||||
|
||||
const FULL_SPEC = `# Task: FN-7000 - Example\n\n## Mission\nThis spec mentions duplicate handling, but it is not a redirect marker.\n`;
|
||||
|
||||
function duplicateStub(canonicalId: string): string {
|
||||
return `DUPLICATE: ${canonicalId}\n`;
|
||||
}
|
||||
|
||||
async function createPromptTask(
|
||||
fx: ReliabilityFixture,
|
||||
input: { id: string; column: "triage" | "todo" | "in-review"; title?: string; prompt: string },
|
||||
) {
|
||||
const task = await fx.store.createTask({
|
||||
title: input.title ?? input.id,
|
||||
description: `${input.id} description`,
|
||||
});
|
||||
if (input.column !== "triage") {
|
||||
await fx.store.moveTask(task.id, input.column);
|
||||
}
|
||||
const taskDir = join(fx.rootDir, ".fusion", "tasks", task.id);
|
||||
await mkdir(taskDir, { recursive: true });
|
||||
await writeFile(join(taskDir, "PROMPT.md"), input.prompt, "utf-8");
|
||||
return task;
|
||||
}
|
||||
|
||||
describe("reliability interactions: explicit duplicate marker sweep", () => {
|
||||
const fixtures: ReliabilityFixture[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
while (fixtures.length) {
|
||||
await fixtures.pop()!.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves an FN-5217-style stuck marker task during maintenance", async () => {
|
||||
const fx = await makeReliabilityFixture();
|
||||
fixtures.push(fx);
|
||||
|
||||
const canonical = await fx.store.createTask({ title: "Canonical", description: "canonical", column: "todo" });
|
||||
const duplicate = await createPromptTask(fx, { id: "FN-5217", column: "triage", prompt: duplicateStub(canonical.id) });
|
||||
|
||||
await (fx.manager as any).runMaintenance();
|
||||
|
||||
await expect(fx.store.getTask(duplicate.id)).rejects.toThrow(`Task ${duplicate.id} not found`);
|
||||
expect((await fx.store.getTask(canonical.id)).column).toBe("todo");
|
||||
const activity = await fx.store.getActivityLog({ type: "task:auto-archived-duplicate", limit: 20 });
|
||||
expect(activity.find((entry) => entry.taskId === duplicate.id)).toEqual(
|
||||
expect.objectContaining({
|
||||
metadata: expect.objectContaining({ canonicalTaskId: canonical.id, source: "explicit-marker-sweep" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not disturb unrelated in-review tasks when autoMerge is false", async () => {
|
||||
const fx = await makeReliabilityFixture({ settings: { autoMerge: false } });
|
||||
fixtures.push(fx);
|
||||
|
||||
await fx.store.updateTask(fx.task.id, {
|
||||
status: "failed",
|
||||
branch: undefined,
|
||||
worktree: undefined,
|
||||
});
|
||||
const canonical = await fx.store.createTask({ title: "Canonical", description: "canonical", column: "todo" });
|
||||
await createPromptTask(fx, { id: "FN-5217", column: "triage", prompt: duplicateStub(canonical.id) });
|
||||
|
||||
await (fx.manager as any).runMaintenance();
|
||||
|
||||
const untouched = await fx.store.getTask(fx.task.id);
|
||||
expect(untouched.column).toBe("in-review");
|
||||
expect(untouched.status).toBe("failed");
|
||||
});
|
||||
|
||||
it("leaves marker tasks alone when the canonical target is missing", async () => {
|
||||
const fx = await makeReliabilityFixture();
|
||||
fixtures.push(fx);
|
||||
|
||||
const duplicate = await createPromptTask(fx, { id: "FN-5301", column: "triage", prompt: "DUPLICATE: FN-9999\n" });
|
||||
|
||||
await (fx.manager as any).resolveExplicitDuplicateMarkerTasks();
|
||||
|
||||
expect((await fx.store.getTask(duplicate.id)).column).toBe("triage");
|
||||
const activity = await fx.store.getActivityLog({ type: "task:auto-archived-duplicate", limit: 20 });
|
||||
expect(activity.find((entry) => entry.taskId === duplicate.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("leaves full specs untouched", async () => {
|
||||
const fx = await makeReliabilityFixture();
|
||||
fixtures.push(fx);
|
||||
|
||||
const duplicate = await createPromptTask(fx, { id: "FN-5302", column: "todo", prompt: FULL_SPEC });
|
||||
|
||||
await (fx.manager as any).resolveExplicitDuplicateMarkerTasks();
|
||||
|
||||
expect((await fx.store.getTask(duplicate.id)).column).toBe("todo");
|
||||
});
|
||||
|
||||
it("honors the disable flag", async () => {
|
||||
const fx = await makeReliabilityFixture({ settings: { resolveExplicitDuplicateMarkerEnabled: false } as never });
|
||||
fixtures.push(fx);
|
||||
|
||||
const canonical = await fx.store.createTask({ title: "Canonical", description: "canonical", column: "todo" });
|
||||
const duplicate = await createPromptTask(fx, { id: "FN-5303", column: "triage", prompt: duplicateStub(canonical.id) });
|
||||
|
||||
await (fx.manager as any).resolveExplicitDuplicateMarkerTasks();
|
||||
|
||||
expect((await fx.store.getTask(duplicate.id)).column).toBe("triage");
|
||||
});
|
||||
|
||||
it("caps work at 50 tasks per sweep", async () => {
|
||||
const fx = await makeReliabilityFixture();
|
||||
fixtures.push(fx);
|
||||
|
||||
const canonical = await fx.store.createTask({ title: "Canonical", description: "canonical", column: "todo" });
|
||||
const ids: string[] = [];
|
||||
for (let index = 0; index < 60; index += 1) {
|
||||
const task = await createPromptTask(fx, {
|
||||
id: `FN-${6000 + index}`,
|
||||
column: index % 2 === 0 ? "triage" : "todo",
|
||||
prompt: duplicateStub(canonical.id),
|
||||
});
|
||||
ids.push(task.id);
|
||||
}
|
||||
|
||||
expect(await (fx.manager as any).resolveExplicitDuplicateMarkerTasks()).toBe(50);
|
||||
const remainingAfterFirst = await fx.store.listTasks({ includeArchived: false });
|
||||
expect(remainingAfterFirst.filter((task) => ids.includes(task.id))).toHaveLength(10);
|
||||
|
||||
expect(await (fx.manager as any).resolveExplicitDuplicateMarkerTasks()).toBe(10);
|
||||
const remainingAfterSecond = await fx.store.listTasks({ includeArchived: false });
|
||||
expect(remainingAfterSecond.filter((task) => ids.includes(task.id))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("fails open when one delete throws and continues processing later tasks", async () => {
|
||||
const fx = await makeReliabilityFixture();
|
||||
fixtures.push(fx);
|
||||
|
||||
const canonical = await fx.store.createTask({ title: "Canonical", description: "canonical", column: "todo" });
|
||||
const first = await createPromptTask(fx, { id: "FN-5304", column: "triage", prompt: duplicateStub(canonical.id) });
|
||||
const second = await createPromptTask(fx, { id: "FN-5305", column: "triage", prompt: duplicateStub(canonical.id) });
|
||||
|
||||
const originalDeleteTask = fx.store.deleteTask.bind(fx.store);
|
||||
const deleteSpy = vi.spyOn(fx.store, "deleteTask").mockImplementation(async (taskId, options) => {
|
||||
if (taskId === first.id) {
|
||||
throw new Error("boom");
|
||||
}
|
||||
return await originalDeleteTask(taskId, options as never);
|
||||
});
|
||||
|
||||
expect(await (fx.manager as any).resolveExplicitDuplicateMarkerTasks()).toBe(1);
|
||||
expect(deleteSpy).toHaveBeenCalled();
|
||||
expect((await fx.store.getTask(first.id)).column).toBe("triage");
|
||||
await expect(fx.store.getTask(second.id)).rejects.toThrow(`Task ${second.id} not found`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { Settings, Task, TaskStore } from "@fusion/core";
|
||||
|
||||
import { TriageProcessor } from "../triage.js";
|
||||
|
||||
function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
|
||||
return {
|
||||
getTask: vi.fn(),
|
||||
getSettings: vi.fn().mockResolvedValue({ requirePlanApproval: false } as Settings),
|
||||
logEntry: vi.fn(),
|
||||
deleteTask: vi.fn(),
|
||||
recordActivity: vi.fn(),
|
||||
updateTask: vi.fn(),
|
||||
moveTask: vi.fn(),
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
...overrides,
|
||||
} as unknown as TaskStore;
|
||||
}
|
||||
|
||||
function createTask(overrides: Partial<Task> = {}): Task {
|
||||
return {
|
||||
id: "FN-002",
|
||||
title: "Incoming duplicate",
|
||||
description: "desc",
|
||||
column: "triage",
|
||||
status: "planning",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("triage explicit duplicate marker short-circuit", () => {
|
||||
const rootDir = process.cwd();
|
||||
const settings = { requirePlanApproval: true } as Settings;
|
||||
|
||||
async function runExplicitDuplicateMarker(
|
||||
store: TaskStore,
|
||||
task: Task,
|
||||
prompt: string,
|
||||
): Promise<boolean> {
|
||||
const processor = new TriageProcessor(store, rootDir);
|
||||
return await (processor as any).tryFinalizeExplicitDuplicateMarker(task, prompt, settings, {});
|
||||
}
|
||||
|
||||
it("deletes the duplicate task and records explicit-marker activity", async () => {
|
||||
const canonical = createTask({ id: "FN-001", title: "Canonical task", column: "todo" });
|
||||
const store = createMockStore({
|
||||
getTask: vi.fn().mockImplementation(async (id: string) => (id === canonical.id ? canonical : null)),
|
||||
});
|
||||
|
||||
await expect(runExplicitDuplicateMarker(store, createTask(), "DUPLICATE: FN-001\n")).resolves.toBe(true);
|
||||
|
||||
expect(store.deleteTask).toHaveBeenCalledWith("FN-002", expect.objectContaining({
|
||||
removeLineageReferences: true,
|
||||
auditContext: expect.objectContaining({
|
||||
agentId: "triage",
|
||||
runId: expect.stringMatching(/^triage-delete-FN-002-/),
|
||||
}),
|
||||
}));
|
||||
expect(store.recordActivity).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: "task:auto-archived-duplicate",
|
||||
taskId: "FN-002",
|
||||
metadata: expect.objectContaining({ canonicalTaskId: "FN-001", source: "explicit-marker" }),
|
||||
}));
|
||||
});
|
||||
|
||||
it("does not short-circuit when the canonical target is missing", async () => {
|
||||
const store = createMockStore({
|
||||
getTask: vi.fn().mockResolvedValue(null),
|
||||
});
|
||||
|
||||
await expect(runExplicitDuplicateMarker(store, createTask(), "DUPLICATE: FN-999\n")).resolves.toBe(false);
|
||||
|
||||
expect(store.deleteTask).not.toHaveBeenCalled();
|
||||
expect(store.recordActivity).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not short-circuit on circular self-reference", async () => {
|
||||
const task = createTask();
|
||||
const store = createMockStore({
|
||||
getTask: vi.fn().mockResolvedValue(task),
|
||||
});
|
||||
|
||||
await expect(runExplicitDuplicateMarker(store, task, "DUPLICATE: FN-002\n")).resolves.toBe(false);
|
||||
|
||||
expect(store.deleteTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not short-circuit for a full spec that mentions duplicate", async () => {
|
||||
const store = createMockStore({
|
||||
getTask: vi.fn(),
|
||||
});
|
||||
const fullSpec = `# Task: FN-002 - Example\n\n## Mission\nWe suspected this might duplicate another task, but it is a full prompt body.\n`;
|
||||
|
||||
await expect(runExplicitDuplicateMarker(store, createTask(), fullSpec)).resolves.toBe(false);
|
||||
|
||||
expect(store.getTask).not.toHaveBeenCalled();
|
||||
expect(store.deleteTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails open when store lookup throws", async () => {
|
||||
const store = createMockStore({
|
||||
getTask: vi.fn().mockRejectedValue(new Error("boom")),
|
||||
});
|
||||
|
||||
await expect(runExplicitDuplicateMarker(store, createTask(), "DUPLICATE: FN-001\n")).resolves.toBe(false);
|
||||
|
||||
expect(store.deleteTask).not.toHaveBeenCalled();
|
||||
expect(store.recordActivity).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -26,9 +26,9 @@
|
||||
|
||||
import { exec, execSync } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { existsSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, 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, 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, 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";
|
||||
@@ -1298,6 +1298,7 @@ export class SelfHealingManager {
|
||||
{ name: "recover-partial-progress-no-task-done", fn: () => this.recoverPartialProgressNoTaskDoneFailures() },
|
||||
{ name: "recover-orphaned-executions", fn: () => this.recoverOrphanedExecutions() },
|
||||
{ name: "recover-approved-triage", fn: () => this.recoverApprovedTriageTasks() },
|
||||
{ name: "resolve-explicit-duplicate-markers", fn: () => this.resolveExplicitDuplicateMarkerTasks() },
|
||||
{ name: "recover-starved-refinement", fn: () => this.recoverStarvedRefinementTriageTasks() },
|
||||
{ name: "recover-orphaned-planning", fn: () => this.recoverOrphanedPlanningTasks() },
|
||||
{ name: "recover-ghost-review", fn: () => this.recoverGhostReviewTasks() },
|
||||
@@ -6559,6 +6560,76 @@ export class SelfHealingManager {
|
||||
}
|
||||
}
|
||||
|
||||
async resolveExplicitDuplicateMarkerTasks(): Promise<number> {
|
||||
try {
|
||||
const settings = await this.store.getSettings();
|
||||
const enabled = (settings as Settings & { resolveExplicitDuplicateMarkerEnabled?: boolean }).resolveExplicitDuplicateMarkerEnabled !== false;
|
||||
if (!enabled) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const tasks = await this.store.listTasks({ slim: true, includeArchived: false, limit: 500 });
|
||||
const candidates = tasks.filter((task) => task.column === "triage" || task.column === "todo");
|
||||
|
||||
let resolved = 0;
|
||||
let processedMarkers = 0;
|
||||
for (const task of candidates) {
|
||||
try {
|
||||
const promptPath = join(this.options.rootDir, ".fusion", "tasks", task.id, "PROMPT.md");
|
||||
if (!existsSync(promptPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const written = readFileSync(promptPath, "utf-8");
|
||||
const marker = parseExplicitDuplicateMarker(written);
|
||||
if (!marker) {
|
||||
continue;
|
||||
}
|
||||
if (processedMarkers >= 50) {
|
||||
break;
|
||||
}
|
||||
processedMarkers += 1;
|
||||
|
||||
const canonicalTask = await this.store.getTask(marker.canonicalId).catch(() => null);
|
||||
if (
|
||||
!canonicalTask ||
|
||||
canonicalTask.deletedAt ||
|
||||
canonicalTask.id.toLowerCase() === task.id.toLowerCase()
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.store.deleteTask(task.id, {
|
||||
removeLineageReferences: true,
|
||||
auditContext: {
|
||||
agentId: "self-healing",
|
||||
runId: generateSyntheticRunId("self-heal-explicit-duplicate", task.id),
|
||||
},
|
||||
});
|
||||
await this.store.recordActivity({
|
||||
type: "task:auto-archived-duplicate",
|
||||
taskId: task.id,
|
||||
taskTitle: task.title ?? "",
|
||||
details: `Duplicate of ${canonicalTask.id} — closed`,
|
||||
metadata: {
|
||||
canonicalTaskId: canonicalTask.id,
|
||||
source: "explicit-marker-sweep",
|
||||
},
|
||||
});
|
||||
log.log(`[self-healing] resolved explicit duplicate marker ${task.id} → ${canonicalTask.id}`);
|
||||
resolved += 1;
|
||||
} catch (error) {
|
||||
log.warn(`Failed explicit duplicate-marker sweep for ${task.id}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
return resolved;
|
||||
} catch (error) {
|
||||
log.error(`Explicit duplicate-marker sweep failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover refinement tasks that have sat in triage long enough to indicate
|
||||
* starvation while the rest of the board keeps progressing.
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
TaskDeletedError,
|
||||
buildTriageMemoryInstructions,
|
||||
getTaskDuplicateLineage,
|
||||
parseExplicitDuplicateMarker,
|
||||
resolveAgentPrompt,
|
||||
resolvePersistAgentThinkingLog,
|
||||
compareTaskPriority,
|
||||
@@ -1508,6 +1509,25 @@ export class TriageProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
const written = await readFile(
|
||||
join(this.rootDir, promptPath),
|
||||
"utf-8",
|
||||
).catch((err: unknown) => {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
planLog.warn(`${task.id}: failed to read generated PROMPT.md before finalization (${promptPath}): ${msg}`);
|
||||
return "";
|
||||
});
|
||||
|
||||
// FN-5220: planning agents that emit a `DUPLICATE: FN-NNNN` redirect
|
||||
// do not call `fn_review_spec()`; short-circuit the APPROVE gate.
|
||||
if (await this.tryFinalizeExplicitDuplicateMarker(task, written, settings, {
|
||||
isReplan,
|
||||
feedback,
|
||||
})) {
|
||||
this.options.onSpecifyComplete?.(task);
|
||||
return;
|
||||
}
|
||||
|
||||
// Post-session APPROVE gate: only advance to todo when the spec
|
||||
// reviewer explicitly approved. Any other verdict (REVISE,
|
||||
// RETHINK, UNAVAILABLE) or a missing review (null) stays in triage
|
||||
@@ -1576,15 +1596,6 @@ export class TriageProcessor {
|
||||
return;
|
||||
}
|
||||
|
||||
const written = await readFile(
|
||||
join(this.rootDir, promptPath),
|
||||
"utf-8",
|
||||
).catch((err: unknown) => {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
planLog.warn(`${task.id}: failed to read generated PROMPT.md before finalization (${promptPath}): ${msg}`);
|
||||
return "";
|
||||
});
|
||||
|
||||
await this.finalizeApprovedTask(task, written, settings, {
|
||||
isReplan,
|
||||
feedback,
|
||||
@@ -2243,6 +2254,41 @@ export class TriageProcessor {
|
||||
};
|
||||
}
|
||||
|
||||
private async tryFinalizeExplicitDuplicateMarker(
|
||||
task: Task,
|
||||
written: string,
|
||||
settings: Settings,
|
||||
options: {
|
||||
isReplan?: boolean;
|
||||
feedback?: string;
|
||||
} = {},
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const explicitDuplicateMarker = parseExplicitDuplicateMarker(written);
|
||||
if (!explicitDuplicateMarker) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const canonicalId = explicitDuplicateMarker.canonicalId;
|
||||
const canonicalTask = await this.store.getTask(canonicalId).catch(() => null);
|
||||
if (
|
||||
!canonicalTask ||
|
||||
canonicalTask.deletedAt ||
|
||||
canonicalTask.id.toLowerCase() === task.id.toLowerCase()
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
planLog.log(`${task.id} explicit duplicate marker detected — redirecting to ${canonicalId}`);
|
||||
await this.finalizeApprovedTask(task, written, settings, options);
|
||||
return true;
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
planLog.warn(`${task.id}: explicit duplicate marker short-circuit failed; proceeding with normal approval gate (${msg})`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async finalizeApprovedTask(
|
||||
task: Task,
|
||||
written: string,
|
||||
@@ -2262,6 +2308,21 @@ export class TriageProcessor {
|
||||
task.id,
|
||||
`Duplicate of ${dupId} — closed`,
|
||||
);
|
||||
try {
|
||||
await this.store.recordActivity({
|
||||
type: "task:auto-archived-duplicate",
|
||||
taskId: task.id,
|
||||
taskTitle: task.title ?? "",
|
||||
details: `Duplicate of ${dupId} — closed`,
|
||||
metadata: {
|
||||
canonicalTaskId: dupId,
|
||||
source: "explicit-marker",
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
planLog.warn(`${task.id}: failed to record explicit duplicate-marker activity (${msg})`);
|
||||
}
|
||||
// Pass removeLineageReferences so a duplicate-close cannot be blocked by lineage children (FN-5129 / FN-5131).
|
||||
await this.store.deleteTask(task.id, {
|
||||
removeLineageReferences: true,
|
||||
|
||||
Reference in New Issue
Block a user