diff --git a/.changeset/near-duplicate-engine-renamed-terminal.md b/.changeset/near-duplicate-engine-renamed-terminal.md new file mode 100644 index 0000000000..0a8852d841 --- /dev/null +++ b/.changeset/near-duplicate-engine-renamed-terminal.md @@ -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. diff --git a/packages/engine/src/__tests__/self-healing-stale-duplicate-decision.test.ts b/packages/engine/src/__tests__/self-healing-stale-duplicate-decision.test.ts index 0e0dd586dd..e18c728331 100644 --- a/packages/engine/src/__tests__/self-healing-stale-duplicate-decision.test.ts +++ b/packages/engine/src/__tests__/self-healing-stale-duplicate-decision.test.ts @@ -40,9 +40,34 @@ function stranded(id: string, canonicalId: string, overrides: Partial = {} }); } -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])); 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)), listTasks: vi.fn(async () => [...tasksById.values()]), 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 () => { const active = task("FN-ACTIVE", { column: "todo" }); const activeDecision = stranded("FN-1", active.id); diff --git a/packages/engine/src/self-healing.ts b/packages/engine/src/self-healing.ts index eb08f20d02..d9161115cb 100644 --- a/packages/engine/src/self-healing.ts +++ b/packages/engine/src/self-healing.ts @@ -814,6 +814,34 @@ function hasTerminalInvalidDoneTransition(task: Pick): boolean { /** Sentinel for a workflow selection whose READ failed, distinct from "no 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 | 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 { // ── Auto-unpause state ────────────────────────────────────────────── private unpauseTimer: ReturnType | null = null; @@ -6412,7 +6440,8 @@ export class SelfHealingManager extends SelfHealingGitEvidence { try { const canonicalId = task.sourceMetadata!.nearDuplicateOf as string; 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, { paused: false, @@ -12512,7 +12541,8 @@ export class SelfHealingManager extends SelfHealingGitEvidence { const canClearInactiveMarker = task.userPaused !== true && (task.paused !== true || 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) { rmSync(promptPath, { force: true }); await this.store.updateTask(task.id, { paused: false, pausedReason: null, status: null }); diff --git a/packages/engine/src/triage.ts b/packages/engine/src/triage.ts index 6843ece9ec..e07caa272c 100644 --- a/packages/engine/src/triage.ts +++ b/packages/engine/src/triage.ts @@ -41,7 +41,7 @@ import { computePlanApprovalFingerprint, extractIntentSignature, findNearDuplicates, - isNearDuplicateCanonicalInactive, + isNearDuplicateCanonicalInactive, resolveColumnFlags, detectImageMimeFromBytes, applyFrontendUxCriteria, applyOriginalDescription, @@ -332,6 +332,34 @@ no longer has. */ const LEGACY_PLANNER_COLUMN_IDS: ReadonlySet = 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 | 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 { private running = 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 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`); } else { 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; 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 (!await this.runIfStillPlanningUnderTaskLock(task, async () => { 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: * 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`); return; }