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:
gsxdsm
2026-07-30 13:04:58 -07:00
committed by GitHub
parent bcc8bf17c1
commit 8c79da364c
4 changed files with 137 additions and 7 deletions

View 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.

View File

@@ -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);

View File

@@ -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 });

View File

@@ -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;
} }