fix(engine): FN-8356's duplicate-marker cleanup was inert on a renamed board (5 call sites) (#2827)
Fourth unowned finding picked up after both batch PRs (#2783 core, #2785 engine) merged without addressing their reports. Completes the `isNearDuplicateCanonicalInactive` seam alongside #2823, which fixed the sixth (core) site. ## The defect All five engine call sites — `self-healing.ts` ×2, `triage.ts` ×3 — called the predicate without the canonical's resolved column flags, so it fell back to the legacy `done`/`archived` ids. A canonical resting in a renamed complete column (`shipped`) read as **still active**, so every *"the canonical is inactive, so clear the marker"* branch failed to fire. The user-visible result is precisely the stranding FN-8356 was written to remove: a card keeps its **"Needs your decision"** duplicate badge pointing at work that shipped days ago, and no decision can resolve it — the detail banner deliberately offers none for an inactive canonical. ## Measured With the fix reverted, exactly one case flips: ``` ✓ clears the FN-8353-shaped hidden decision for every inactive canonical state × renamed vocabulary: clears the decision for a canonical resting in a RENAMED complete column ✓ renamed vocabulary: leaves the decision alone while the canonical is still in the WIP lane ✓ leaves active canonical decisions, user pauses, unrelated reasons, non-marker sources untouched Tests 1 failed | 3 passed (4) ``` With it: `Tests 4 passed (4)`. ## Wiring is proven separately from behaviour A behaviour test on one call site says nothing about the other four — that is the failure this whole lane keeps re-finding, so I did not rely on it. With #2822's barrel-import fix applied locally, the seam gate's staleness check **fails both engine allow-list entries as "now supplied"** — it can no longer find an omitting call site in either file. That is the proof for all five. Consequence worth flagging: **when the second of #2822 / this PR lands, the two engine entries in #2822 must be deleted.** CI fails until they are, by design — the exemption cannot outlive its fix. ## Both negatives included A canonical still in the WIP lane must **not** have its decision cleared, under each vocabulary. Resolving real flags must not degrade into "every column is terminal", which would dismiss a duplicate decision the operator has not made yet. ## Design note The helper is module-private in each file rather than shared. `findColumn` is already duplicated exactly this way in `hold-release.ts`, `merge-trait.ts`, and `workflow-capacity.ts` — following the established shape beat adding a cross-module abstraction for a bug fix. ## Verification `pnpm test:gate` green · 27 triage suites / 387 tests green · engine `tsc` 0 · lint 0 · changeset included. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
7
.changeset/near-duplicate-engine-renamed-terminal.md
Normal file
7
.changeset/near-duplicate-engine-renamed-terminal.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Duplicate-decision cards no longer stay parked forever when the canonical finishes on a renamed board.
|
||||||
|
category: fix
|
||||||
|
dev: The five engine call sites of `isNearDuplicateCanonicalInactive` (self-healing x2, triage x3) now resolve the canonical's own column flags; previously they fell back to the legacy `done`/`archived` ids, so FN-8356's marker cleanup never fired on a custom board.
|
||||||
@@ -40,9 +40,34 @@ function stranded(id: string, canonicalId: string, overrides: Partial<Task> = {}
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function storeFor(tasks: Task[]): TaskStore & EventEmitter {
|
/*
|
||||||
|
FNXC:WorkflowResolvedColumns 2026-07-30-04:30:
|
||||||
|
An optional renamed workflow, so the same sweep can be driven under a board whose terminal lane is
|
||||||
|
`shipped` instead of `done`. Supplied, `resolveWorkflowIrForTask` resolves it and the canonical's
|
||||||
|
real flags reach `isNearDuplicateCanonicalInactive`; omitted, the store behaves exactly as before and
|
||||||
|
the existing cases are untouched.
|
||||||
|
*/
|
||||||
|
const RENAMED_IR = {
|
||||||
|
version: "v2",
|
||||||
|
id: "custom:renamed-terminal",
|
||||||
|
nodes: [],
|
||||||
|
edges: [],
|
||||||
|
columns: [
|
||||||
|
{ id: "drafting", label: "Drafting", traits: [{ trait: "hold", config: { release: "capacity" } }] },
|
||||||
|
{ id: "building", label: "Building", traits: [{ trait: "wip", config: { limitSetting: "maxConcurrent" } }] },
|
||||||
|
{ id: "shipped", label: "Shipped", traits: [{ trait: "complete" }] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
function storeFor(tasks: Task[], workflow?: unknown): TaskStore & EventEmitter {
|
||||||
const tasksById = new Map(tasks.map((entry) => [entry.id, entry]));
|
const tasksById = new Map(tasks.map((entry) => [entry.id, entry]));
|
||||||
return Object.assign(new EventEmitter(), {
|
return Object.assign(new EventEmitter(), {
|
||||||
|
...(workflow
|
||||||
|
? {
|
||||||
|
getTaskWorkflowSelectionAsync: vi.fn(async () => ({ workflowId: "custom:renamed-terminal", stepIds: [] })),
|
||||||
|
getWorkflowDefinition: vi.fn(async () => ({ ir: workflow })),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
getSettings: vi.fn(async () => ({ globalPause: false, enginePaused: false } as Settings)),
|
getSettings: vi.fn(async () => ({ globalPause: false, enginePaused: false } as Settings)),
|
||||||
listTasks: vi.fn(async () => [...tasksById.values()]),
|
listTasks: vi.fn(async () => [...tasksById.values()]),
|
||||||
getTask: vi.fn(async (id: string) => tasksById.get(id)),
|
getTask: vi.fn(async (id: string) => tasksById.get(id)),
|
||||||
@@ -91,6 +116,43 @@ describe("FN-8356: reconcile stale duplicate-decision pauses", () => {
|
|||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:WorkflowResolvedColumns 2026-07-30-04:30 (the sweep was inert on a renamed board):
|
||||||
|
|
||||||
|
`isNearDuplicateCanonicalInactive` was called without the canonical's resolved flags, so it fell
|
||||||
|
back to the legacy `done`/`archived` ids. A canonical resting in `shipped` read as still ACTIVE,
|
||||||
|
this sweep skipped it, and the stranded card kept its "Needs your decision" badge pointing at work
|
||||||
|
that had finished — the precise stranding FN-8356 exists to clear.
|
||||||
|
|
||||||
|
Differential: `shipped` collides with no legacy literal, so a surviving `"done"` cannot pass here
|
||||||
|
by luck, and the control above proves the default vocabulary still works.
|
||||||
|
*/
|
||||||
|
it("renamed vocabulary: clears the decision for a canonical resting in a RENAMED complete column", async () => {
|
||||||
|
const shipped = task("FN-SHIPPED", { column: "shipped" });
|
||||||
|
const strandedCard = stranded("FN-1", shipped.id, { column: "drafting" });
|
||||||
|
const store = storeFor([strandedCard, shipped], RENAMED_IR);
|
||||||
|
const manager = new SelfHealingManager(store, { rootDir: "/repo" });
|
||||||
|
|
||||||
|
expect(await manager.reconcileStaleDuplicateDecisionPause()).toBe(1);
|
||||||
|
const recovered = await store.getTask("FN-1");
|
||||||
|
expect(recovered?.paused).toBe(false);
|
||||||
|
expect(recovered?.pausedReason).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
The paired negative on the same vocabulary: resolving real flags must not degrade into "every
|
||||||
|
column is terminal", which would clear a decision whose canonical is still being worked on.
|
||||||
|
*/
|
||||||
|
it("renamed vocabulary: leaves the decision alone while the canonical is still in the WIP lane", async () => {
|
||||||
|
const building = task("FN-BUILDING", { column: "building" });
|
||||||
|
const strandedCard = stranded("FN-1", building.id, { column: "drafting" });
|
||||||
|
const store = storeFor([strandedCard, building], RENAMED_IR);
|
||||||
|
const manager = new SelfHealingManager(store, { rootDir: "/repo" });
|
||||||
|
|
||||||
|
expect(await manager.reconcileStaleDuplicateDecisionPause()).toBe(0);
|
||||||
|
expect(await store.getTask("FN-1")).toMatchObject({ paused: true, pausedReason: "duplicate-decision-required" });
|
||||||
|
});
|
||||||
|
|
||||||
it("leaves active canonical decisions, user pauses, unrelated reasons, and non-marker sources untouched", async () => {
|
it("leaves active canonical decisions, user pauses, unrelated reasons, and non-marker sources untouched", async () => {
|
||||||
const active = task("FN-ACTIVE", { column: "todo" });
|
const active = task("FN-ACTIVE", { column: "todo" });
|
||||||
const activeDecision = stranded("FN-1", active.id);
|
const activeDecision = stranded("FN-1", active.id);
|
||||||
|
|||||||
@@ -814,6 +814,34 @@ function hasTerminalInvalidDoneTransition(task: Pick<Task, "error">): boolean {
|
|||||||
/** Sentinel for a workflow selection whose READ failed, distinct from "no selection". */
|
/** Sentinel for a workflow selection whose READ failed, distinct from "no selection". */
|
||||||
const UNREADABLE_WORKFLOW_SELECTION = "\u0000unreadable-workflow-selection";
|
const UNREADABLE_WORKFLOW_SELECTION = "\u0000unreadable-workflow-selection";
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:WorkflowResolvedColumns 2026-07-30-04:10:
|
||||||
|
The canonical's OWN resolved column flags, for `isNearDuplicateCanonicalInactive`.
|
||||||
|
|
||||||
|
Omitted, that predicate falls back to the legacy `done`/`archived` ids, so on a renamed board a
|
||||||
|
canonical that has SHIPPED reads as still ACTIVE. Every "the canonical is inactive, so clear the
|
||||||
|
marker" branch below then fails to fire, and FN-8356's fix — inactive canonicals flow through marker
|
||||||
|
cleanup rather than parking the card — is inert. The card keeps its "Needs your decision" badge
|
||||||
|
pointing at work that finished days ago, and no decision can ever resolve it.
|
||||||
|
|
||||||
|
Module-private rather than shared: `findColumn` is already duplicated this way in hold-release.ts,
|
||||||
|
merge-trait.ts, and workflow-capacity.ts, so this follows the established shape instead of adding a
|
||||||
|
cross-module helper for it.
|
||||||
|
|
||||||
|
`undefined` on any failure is deliberate — it degrades to the legacy id rather than to absent traits
|
||||||
|
that match nothing.
|
||||||
|
*/
|
||||||
|
async function resolveNearDuplicateCanonicalFlags(
|
||||||
|
store: TaskStore,
|
||||||
|
canonical: { id: string; column?: string | null } | null | undefined,
|
||||||
|
): Promise<ReturnType<typeof resolveColumnFlags> | undefined> {
|
||||||
|
if (!canonical?.column) return undefined;
|
||||||
|
const ir = await resolveWorkflowIrForTask(store, canonical.id).catch(() => undefined);
|
||||||
|
if (!ir || ir.version !== "v2") return undefined;
|
||||||
|
const column = ir.columns.find((candidate) => candidate.id === canonical.column);
|
||||||
|
return column ? resolveColumnFlags(column) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export class SelfHealingManager extends SelfHealingGitEvidence {
|
export class SelfHealingManager extends SelfHealingGitEvidence {
|
||||||
// ── Auto-unpause state ──────────────────────────────────────────────
|
// ── Auto-unpause state ──────────────────────────────────────────────
|
||||||
private unpauseTimer: ReturnType<typeof setTimeout> | null = null;
|
private unpauseTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
@@ -6412,7 +6440,8 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
|||||||
try {
|
try {
|
||||||
const canonicalId = task.sourceMetadata!.nearDuplicateOf as string;
|
const canonicalId = task.sourceMetadata!.nearDuplicateOf as string;
|
||||||
const canonical = await this.store.getTask(canonicalId).catch(() => null);
|
const canonical = await this.store.getTask(canonicalId).catch(() => null);
|
||||||
if (!isNearDuplicateCanonicalInactive(canonical ?? undefined)) continue;
|
const canonicalFlags = await resolveNearDuplicateCanonicalFlags(this.store, canonical);
|
||||||
|
if (!isNearDuplicateCanonicalInactive(canonical ?? undefined, canonicalFlags)) continue;
|
||||||
|
|
||||||
await this.store.updateTask(task.id, {
|
await this.store.updateTask(task.id, {
|
||||||
paused: false,
|
paused: false,
|
||||||
@@ -12512,7 +12541,8 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
|||||||
const canClearInactiveMarker = task.userPaused !== true
|
const canClearInactiveMarker = task.userPaused !== true
|
||||||
&& (task.paused !== true || task.pausedReason === "duplicate-decision-required")
|
&& (task.paused !== true || task.pausedReason === "duplicate-decision-required")
|
||||||
&& (task.pausedReason == null || task.pausedReason === "duplicate-decision-required");
|
&& (task.pausedReason == null || task.pausedReason === "duplicate-decision-required");
|
||||||
if (!canonicalTask || isNearDuplicateCanonicalInactive(canonicalTask)) {
|
const canonicalFlags = await resolveNearDuplicateCanonicalFlags(this.store, canonicalTask);
|
||||||
|
if (!canonicalTask || isNearDuplicateCanonicalInactive(canonicalTask, canonicalFlags)) {
|
||||||
if (canClearInactiveMarker) {
|
if (canClearInactiveMarker) {
|
||||||
rmSync(promptPath, { force: true });
|
rmSync(promptPath, { force: true });
|
||||||
await this.store.updateTask(task.id, { paused: false, pausedReason: null, status: null });
|
await this.store.updateTask(task.id, { paused: false, pausedReason: null, status: null });
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ import {
|
|||||||
computePlanApprovalFingerprint,
|
computePlanApprovalFingerprint,
|
||||||
extractIntentSignature,
|
extractIntentSignature,
|
||||||
findNearDuplicates,
|
findNearDuplicates,
|
||||||
isNearDuplicateCanonicalInactive,
|
isNearDuplicateCanonicalInactive, resolveColumnFlags,
|
||||||
detectImageMimeFromBytes,
|
detectImageMimeFromBytes,
|
||||||
applyFrontendUxCriteria,
|
applyFrontendUxCriteria,
|
||||||
applyOriginalDescription,
|
applyOriginalDescription,
|
||||||
@@ -332,6 +332,34 @@ no longer has.
|
|||||||
*/
|
*/
|
||||||
const LEGACY_PLANNER_COLUMN_IDS: ReadonlySet<string> = new Set(["triage", "todo"]);
|
const LEGACY_PLANNER_COLUMN_IDS: ReadonlySet<string> = new Set(["triage", "todo"]);
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:WorkflowResolvedColumns 2026-07-30-04:10:
|
||||||
|
The canonical's OWN resolved column flags, for `isNearDuplicateCanonicalInactive`.
|
||||||
|
|
||||||
|
Omitted, that predicate falls back to the legacy `done`/`archived` ids, so on a renamed board a
|
||||||
|
canonical that has SHIPPED reads as still ACTIVE. Every "the canonical is inactive, so clear the
|
||||||
|
marker" branch below then fails to fire, and FN-8356's fix — inactive canonicals flow through marker
|
||||||
|
cleanup rather than parking the card — is inert. The card keeps its "Needs your decision" badge
|
||||||
|
pointing at work that finished days ago, and no decision can ever resolve it.
|
||||||
|
|
||||||
|
Module-private rather than shared: `findColumn` is already duplicated this way in hold-release.ts,
|
||||||
|
merge-trait.ts, and workflow-capacity.ts, so this follows the established shape instead of adding a
|
||||||
|
cross-module helper for it.
|
||||||
|
|
||||||
|
`undefined` on any failure is deliberate — it degrades to the legacy id rather than to absent traits
|
||||||
|
that match nothing.
|
||||||
|
*/
|
||||||
|
async function resolveNearDuplicateCanonicalFlags(
|
||||||
|
store: TaskStore,
|
||||||
|
canonical: { id: string; column?: string | null } | null | undefined,
|
||||||
|
): Promise<ReturnType<typeof resolveColumnFlags> | undefined> {
|
||||||
|
if (!canonical?.column) return undefined;
|
||||||
|
const ir = await resolveWorkflowIrForTask(store, canonical.id).catch(() => undefined);
|
||||||
|
if (!ir || ir.version !== "v2") return undefined;
|
||||||
|
const column = ir.columns.find((candidate) => candidate.id === canonical.column);
|
||||||
|
return column ? resolveColumnFlags(column) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export class TriageProcessor {
|
export class TriageProcessor {
|
||||||
private running = false;
|
private running = false;
|
||||||
private polling = false;
|
private polling = false;
|
||||||
@@ -3596,7 +3624,8 @@ export class TriageProcessor {
|
|||||||
marker cleanup instead of being rejected here. The detail banner cannot offer a decision for
|
marker cleanup instead of being rejected here. The detail banner cannot offer a decision for
|
||||||
an inactive canonical, so parking the card would strand its Needs your decision badge.
|
an inactive canonical, so parking the card would strand its Needs your decision badge.
|
||||||
*/
|
*/
|
||||||
if (isNearDuplicateCanonicalInactive(canonicalTask)) {
|
const canonicalFlags = await resolveNearDuplicateCanonicalFlags(this.store, canonicalTask);
|
||||||
|
if (isNearDuplicateCanonicalInactive(canonicalTask, canonicalFlags)) {
|
||||||
planLog.log(`${task.id} explicit duplicate marker targets inactive ${canonicalId}; clearing marker for replanning`);
|
planLog.log(`${task.id} explicit duplicate marker targets inactive ${canonicalId}; clearing marker for replanning`);
|
||||||
} else {
|
} else {
|
||||||
planLog.log(`${task.id} explicit duplicate marker detected — redirecting to ${canonicalId}`);
|
planLog.log(`${task.id} explicit duplicate marker detected — redirecting to ${canonicalId}`);
|
||||||
@@ -3752,7 +3781,8 @@ export class TriageProcessor {
|
|||||||
remove only the marker and return eligible work to planning instead of stranding its badge;
|
remove only the marker and return eligible work to planning instead of stranding its badge;
|
||||||
explicit, implicit, and unrelated pauses are preserved.
|
explicit, implicit, and unrelated pauses are preserved.
|
||||||
*/
|
*/
|
||||||
if (isNearDuplicateCanonicalInactive(canonicalTask ?? undefined)) {
|
const canonicalFlags = await resolveNearDuplicateCanonicalFlags(this.store, canonicalTask);
|
||||||
|
if (isNearDuplicateCanonicalInactive(canonicalTask ?? undefined, canonicalFlags)) {
|
||||||
if (canClearInactiveMarker) {
|
if (canClearInactiveMarker) {
|
||||||
if (!await this.runIfStillPlanningUnderTaskLock(task, async () => {
|
if (!await this.runIfStillPlanningUnderTaskLock(task, async () => {
|
||||||
await rm(join(this.rootDir, ".fusion", "tasks", task.id, "PROMPT.md"), { force: true });
|
await rm(join(this.rootDir, ".fusion", "tasks", task.id, "PROMPT.md"), { force: true });
|
||||||
@@ -4099,7 +4129,8 @@ export class TriageProcessor {
|
|||||||
* FNXC:NearDuplicateDetection 2026-06-14-12:00:
|
* FNXC:NearDuplicateDetection 2026-06-14-12:00:
|
||||||
* FN-6439 makes the triage backstop defense-in-depth: never persist a user-decision duplicate flag when the canonical is inactive, even if candidate filtering regresses or a stale snapshot slips through.
|
* FN-6439 makes the triage backstop defense-in-depth: never persist a user-decision duplicate flag when the canonical is inactive, even if candidate filtering regresses or a stale snapshot slips through.
|
||||||
*/
|
*/
|
||||||
if (isNearDuplicateCanonicalInactive(canonicalTask)) {
|
const canonicalFlags = await resolveNearDuplicateCanonicalFlags(this.store, canonicalTask);
|
||||||
|
if (isNearDuplicateCanonicalInactive(canonicalTask, canonicalFlags)) {
|
||||||
planLog.log(`${task.id}: near-duplicate candidate ${canonical.id} is inactive; skipping near-duplicate flag`);
|
planLog.log(`${task.id}: near-duplicate candidate ${canonical.id} is inactive; skipping near-duplicate flag`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user