fix(dashboard): preserve recommendation recovery across archive lanes (#3360)

## Summary
- align dependency-link tests with prompt-only Definition refreshes
- classify archived recommendation tasks through shared workflow-trait
lanes, including legacy and v1 compatibility
- restore deterministic recommendation tasks directly to their workflow
intake lane without invoking cold-storage unarchive

## Test plan
- `FUSION_DASHBOARD_DEEP=1 corepack pnpm --filter @fusion/dashboard exec
vitest run src/routes/__tests__/task-recommendation-routes.test.ts
src/__tests__/task-lifecycle-lanes.test.ts
app/components/__tests__/TaskDetailModal.responsive-and-dependencies.test.tsx
--silent=passed-only --reporter=dot`
- `corepack pnpm --filter @fusion/dashboard typecheck`
- `corepack pnpm --filter @fusion/dashboard build`
- `corepack pnpm exec eslint
packages/dashboard/src/routes/register-task-workflow-routes.ts
packages/dashboard/src/routes/__tests__/task-recommendation-routes.test.ts
packages/dashboard/src/task-lifecycle-lanes.ts
packages/dashboard/src/__tests__/task-lifecycle-lanes.test.ts
packages/dashboard/app/components/__tests__/TaskDetailModal.responsive-and-dependencies.test.tsx`
- `corepack pnpm check:lifecycle-columns`
- `corepack pnpm check:changesets --strict`
This commit is contained in:
Phil Larson
2026-08-09 16:22:32 -07:00
committed by GitHub
parent 9ce698699a
commit a39a5116c2
6 changed files with 387 additions and 52 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Keep recommendation-created tasks recoverable across custom and legacy archive lanes.
category: fix
dev: Treats undeclared legacy archive IDs as tombstones and re-homes active rows without cold-storage restore.

View File

@@ -2256,7 +2256,18 @@ describe("TaskDetailModal", () => {
const mockDetail = makeTask({ id: "FN-001", description: "Dep 1" });
const onOpenDetail = vi.fn();
mockFetch.mockResolvedValue(mockDetail);
/*
FNXC:TaskDetailDependencies 2026-08-08-12:32:
A TaskDetail prop refreshes Definition through fetchTaskPrompt. The full-detail client is
reserved for the clicked dependency, so fail this fixture on any other request.
*/
mockFetch.mockReset();
mockFetch.mockImplementation(async (id: string, requestedProjectId?: string) => {
if (id !== "FN-001" || requestedProjectId !== projectId) {
throw new Error(`Unexpected dependency detail request: ${id}`);
}
return mockDetail;
});
const { baseElement: container } = render(
<TaskDetailModal
initialTab="definition"
@@ -2348,7 +2359,18 @@ describe("TaskDetailModal", () => {
const onOpenDetail = vi.fn();
const addToast = vi.fn();
mockFetch.mockRejectedValueOnce(new Error("Task not found"));
/*
FNXC:TaskDetailDependencies 2026-08-08-12:32:
Definition refresh uses fetchTaskPrompt. Reject the expected dependency request and fail this
fixture distinctly if another full-detail request appears.
*/
mockFetch.mockReset();
mockFetch.mockImplementation(async (id: string, requestedProjectId?: string) => {
if (id !== "FN-001" || requestedProjectId !== projectId) {
throw new Error(`Unexpected dependency detail request: ${id}`);
}
throw new Error("Task not found");
});
const { baseElement: container } = render(
<TaskDetailModal
initialTab="definition"
@@ -2382,7 +2404,15 @@ describe("TaskDetailModal", () => {
const blockingDetail = makeTask({ id: "FN-100", description: "Blocking dependent" });
const onOpenDetail = vi.fn();
mockFetch.mockImplementation(async (id) => id === "FN-001" ? upstreamDetail : blockingDetail);
mockFetch.mockReset();
mockFetch.mockImplementation(async (id: string, requestedProjectId?: string) => {
if (requestedProjectId !== projectId) {
throw new Error(`Unexpected dependency project: ${requestedProjectId ?? "<none>"}`);
}
if (id === "FN-001") return upstreamDetail;
if (id === "FN-100") return blockingDetail;
throw new Error(`Unexpected dependency detail request: ${id}`);
});
const { baseElement: container } = render(
<TaskDetailModal
initialTab="definition"

View File

@@ -50,10 +50,10 @@ const V1_UPGRADED_IR = {
};
describe("landedColumnsForTask", () => {
it("returns the renamed complete AND archived lanes, and not the legacy ids", async () => {
it("returns the renamed complete and archived lanes plus the undeclared legacy archive tombstone", async () => {
const landed = await landedColumnsForTask(storeWith(RENAMED_IR), "FN-1");
expect([...landed].sort()).toEqual(["attic", "shipped"]);
expect([...landed].sort()).toEqual(["archived", "attic", "shipped"]);
/*
The archived half is asserted explicitly: the two roles resolve independently and have failed
independently before, so a fixture that only proved `complete` would miss half the guard.
@@ -168,6 +168,13 @@ describe("the lane resolvers themselves, not just their callers", () => {
["preWipColumnsForTask", preWipColumnsForTask, "backlog", "todo"],
];
it("archivedColumnsForTask keeps an undeclared legacy archive tombstone beside traited lanes", async () => {
expect([...(await archivedColumnsForTask(storeFor(RENAMED_IR), "FN-1"))].sort()).toEqual([
"archived",
"attic",
]);
});
for (const [name, resolve, renamed, legacy] of cases) {
it(`${name} returns the traited lane and EXCLUDES the untraited legacy name`, async () => {
const lanes = await resolve(storeFor(TRAITED_WITH_LEGACY_NAMES), "FN-1");

View File

@@ -135,6 +135,78 @@ function parent(overrides: Partial<Task> = {}): Task {
});
}
/*
FNXC:TaskRecommendations 2026-08-08-12:49:
Custom-workflow fixtures distinguish the traited `boxed` archived lane from an explicitly declared,
untraited `archived` live lane while retaining undeclared legacy tombstone coverage.
*/
function installCustomRecommendationWorkflow(
store: Partial<TaskStore>,
taskIds: readonly string[],
options: { declareLegacyArchivedAsLive?: boolean } = {},
): void {
Object.assign(store, {
getTaskWorkflowSelection: vi.fn((id: string) => taskIds.includes(id) ? { workflowId: "recommendation-workflow", stepIds: [] } : undefined),
getTaskWorkflowSelectionAsync: vi.fn(async (id: string) => taskIds.includes(id) ? { workflowId: "recommendation-workflow", stepIds: [] } : undefined),
getWorkflowDefinition: vi.fn(async () => ({
id: "recommendation-workflow",
name: "Recommendation workflow",
kind: "workflow",
ir: {
version: "v2",
id: "recommendation-workflow",
name: "Recommendation workflow",
nodes: [{ id: "start", kind: "start", column: "backlog" }],
edges: [],
columns: [
{ id: "backlog", name: "Backlog", traits: [{ trait: "intake" }] },
{ id: "queued", name: "Queued", traits: [{ trait: "hold" }] },
{ id: "building", name: "Building", traits: [{ trait: "wip" }] },
{ id: "shipped", name: "Shipped", traits: [{ trait: "complete" }] },
...(options.declareLegacyArchivedAsLive
? [{ id: "archived", name: "Live archived", traits: [] }]
: []),
{ id: "boxed", name: "Boxed", traits: [{ trait: "archived" }] },
],
},
})),
});
}
/*
FNXC:TaskRecommendations 2026-08-09-06:06:
Real v1 workflow fixtures must pass through the production read-path upgrade so synthesized default
columns keep legacy `archived` tombstone semantics instead of looking like an explicitly live lane.
*/
function installLegacyV1RecommendationWorkflow(
store: Partial<TaskStore>,
taskIds: readonly string[],
): void {
Object.assign(store, {
getTaskWorkflowSelection: vi.fn((id: string) => taskIds.includes(id) ? { workflowId: "legacy-recommendation-workflow", stepIds: [] } : undefined),
getTaskWorkflowSelectionAsync: vi.fn(async (id: string) => taskIds.includes(id) ? { workflowId: "legacy-recommendation-workflow", stepIds: [] } : undefined),
getWorkflowDefinition: vi.fn(async () => ({
id: "legacy-recommendation-workflow",
name: "Legacy recommendation workflow",
kind: "workflow",
ir: JSON.stringify({
version: "v1",
name: "legacy-recommendation-workflow",
nodes: [
{ id: "start", kind: "start" },
{ id: "execute", kind: "prompt", config: { seam: "execute", prompt: "Do the work" } },
{ id: "end", kind: "end" },
],
edges: [
{ from: "start", to: "execute", condition: "success" },
{ from: "execute", to: "end", condition: "success" },
{ from: "execute", to: "end", condition: "failure" },
],
}),
})),
});
}
describe("recommendation task creation route", () => {
beforeEach(() => locks?.clear());
afterEach(() => { locks?.clear(); vi.restoreAllMocks(); });
@@ -318,7 +390,8 @@ describe("recommendation task creation route", () => {
expect(recovered.status).toBe(200);
expect(store.createTask).toHaveBeenCalledTimes(1);
expect(store.unarchiveTask).toHaveBeenCalledWith("FN-102");
expect(store.unarchiveTask).not.toHaveBeenCalled();
expect(store.moveTask).toHaveBeenLastCalledWith("FN-102", "todo", { recoveryRehome: true });
expect(tasks[0]?.recommendations?.[0]?.createdTaskId).toBe("FN-102");
});
@@ -328,29 +401,7 @@ describe("recommendation task creation route", () => {
column: "todo", createdAt: "2026-01-01T00:00:00.000Z",
});
const { app, store, tasks } = buildApp([parent(), canonical]);
Object.assign(store, {
getTaskWorkflowSelection: vi.fn((id: string) => id === "FN-102" ? { workflowId: "recommendation-workflow", stepIds: [] } : undefined),
getTaskWorkflowSelectionAsync: vi.fn(async (id: string) => id === "FN-102" ? { workflowId: "recommendation-workflow", stepIds: [] } : undefined),
getWorkflowDefinition: vi.fn(async () => ({
id: "recommendation-workflow",
name: "Recommendation workflow",
kind: "workflow",
ir: {
version: "v2",
id: "recommendation-workflow",
name: "Recommendation workflow",
nodes: [{ id: "start", kind: "start", column: "backlog" }],
edges: [],
columns: [
{ id: "backlog", name: "Backlog", traits: [{ trait: "intake" }] },
{ id: "queued", name: "Queued", traits: [{ trait: "hold" }] },
{ id: "building", name: "Building", traits: [{ trait: "wip" }] },
{ id: "shipped", name: "Shipped", traits: [{ trait: "complete" }] },
{ id: "boxed", name: "Boxed", traits: [{ trait: "archived" }] },
],
},
})),
});
installCustomRecommendationWorkflow(store, ["FN-102"]);
(store.findRecentTasksByContentFingerprint as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce([])
.mockResolvedValueOnce([canonical]);
@@ -363,7 +414,8 @@ describe("recommendation task creation route", () => {
const recovered = await performRequest(app, "POST", "/api/tasks/FN-1/recommendations/rec-1/create", undefined);
expect(recovered.status).toBe(200);
expect(store.moveTask).toHaveBeenLastCalledWith("FN-102", "backlog");
expect(store.unarchiveTask).not.toHaveBeenCalled();
expect(store.moveTask).toHaveBeenLastCalledWith("FN-102", "backlog", { recoveryRehome: true });
expect(tasks.find((item) => item.id === "FN-102")?.column).toBe("backlog");
});
@@ -454,6 +506,232 @@ describe("recommendation task creation route", () => {
expect(archived.store.createTask).not.toHaveBeenCalled();
});
it.each(["boxed", "archived"] as const)(
"rejects a linked child in custom workflow archived state %s",
async (archivedColumn) => {
const archivedChild = task({ id: "FN-9", description: "Archived child", column: archivedColumn as Column });
const custom = buildApp([
parent({ recommendations: [{ ...parent().recommendations![0], createdTaskId: "FN-9" }] }),
archivedChild,
]);
installCustomRecommendationWorkflow(custom.store, ["FN-9"]);
const response = await performRequest(
custom.app,
"POST",
"/api/tasks/FN-1/recommendations/rec-1/create",
undefined,
);
expect(response.status).toBe(409);
expect(custom.store.createTask).not.toHaveBeenCalled();
},
);
it("keeps a linked child live in an explicitly declared untraited archived column", async () => {
const linkedChild = task({ id: "FN-9", description: "Live child", column: "archived" });
const custom = buildApp([
parent({ recommendations: [{ ...parent().recommendations![0], createdTaskId: "FN-9" }] }),
linkedChild,
]);
installCustomRecommendationWorkflow(custom.store, ["FN-9"], { declareLegacyArchivedAsLive: true });
const response = await performRequest(
custom.app,
"POST",
"/api/tasks/FN-1/recommendations/rec-1/create",
undefined,
);
expect(response.status).toBe(200);
expect(response.body).toMatchObject({ task: { id: "FN-9", column: "archived" } });
expect(custom.store.createTask).not.toHaveBeenCalled();
});
it("treats a linked child in synthesized v1 archived state as unavailable", async () => {
const archivedChild = task({ id: "FN-9", description: "Legacy archived child", column: "archived" });
const legacy = buildApp([
parent({ recommendations: [{ ...parent().recommendations![0], createdTaskId: "FN-9" }] }),
archivedChild,
]);
installLegacyV1RecommendationWorkflow(legacy.store, ["FN-9"]);
const response = await performRequest(
legacy.app,
"POST",
"/api/tasks/FN-1/recommendations/rec-1/create",
undefined,
);
expect(response.status).toBe(409);
expect(legacy.store.createTask).not.toHaveBeenCalled();
});
it("rejects an unavailable proposal claim in a custom workflow archived lane", async () => {
const unavailableClaim = task({
id: "FN-9",
description: "Archived recommendation claim",
column: "boxed" as Column,
proposalClaimId: "recommendation:FN-1:rec-1",
});
const custom = buildApp([parent(), unavailableClaim]);
installCustomRecommendationWorkflow(custom.store, ["FN-9"]);
const response = await performRequest(
custom.app,
"POST",
"/api/tasks/FN-1/recommendations/rec-1/create",
undefined,
);
expect(response.status).toBe(409);
expect(custom.store.createTask).not.toHaveBeenCalled();
expect(custom.store.linkTaskRecommendation).not.toHaveBeenCalled();
expect(custom.tasks[0]?.recommendations?.[0]?.createdTaskId).toBeUndefined();
});
it("repairs a proposal claim from an explicitly declared untraited archived column", async () => {
const liveClaim = task({
id: "FN-9",
description: "Live recommendation claim",
column: "archived",
proposalClaimId: "recommendation:FN-1:rec-1",
});
const custom = buildApp([parent(), liveClaim]);
installCustomRecommendationWorkflow(custom.store, ["FN-9"], { declareLegacyArchivedAsLive: true });
const response = await performRequest(
custom.app,
"POST",
"/api/tasks/FN-1/recommendations/rec-1/create",
undefined,
);
expect(response.status).toBe(200);
expect(custom.store.createTask).not.toHaveBeenCalled();
expect(custom.store.linkTaskRecommendation).toHaveBeenCalledWith(
"FN-1",
"rec-1",
"FN-9",
expect.any(Set),
);
expect(custom.tasks[0]?.recommendations?.[0]?.createdTaskId).toBe("FN-9");
});
it("recovers a deterministic proposal claim from the legacy archived compatibility lane", async () => {
const canonical = task({
id: "FN-9",
title: "Canonical export task",
description: "Canonical export work",
column: "done",
});
const recoverableClaim = task({
id: "FN-10",
description: "Archived recommendation claim",
column: "archived",
proposalClaimId: "recommendation:FN-1:rec-1",
sourceMetadata: { deterministicDuplicateOf: "FN-9" },
});
const custom = buildApp([parent(), canonical, recoverableClaim]);
installCustomRecommendationWorkflow(custom.store, ["FN-10"]);
(custom.store.searchTasks as ReturnType<typeof vi.fn>).mockResolvedValue([]);
const response = await performRequest(
custom.app,
"POST",
"/api/tasks/FN-1/recommendations/rec-1/create",
undefined,
);
expect(response.status).toBe(200);
expect(custom.store.createTask).not.toHaveBeenCalled();
expect(custom.store.unarchiveTask).not.toHaveBeenCalled();
expect(custom.store.moveTask).toHaveBeenCalledWith("FN-10", "backlog", { recoveryRehome: true });
expect(custom.store.linkTaskRecommendation).toHaveBeenCalledWith(
"FN-1",
"rec-1",
"FN-10",
expect.any(Set),
);
expect(custom.tasks[0]?.recommendations?.[0]?.createdTaskId).toBe("FN-10");
});
it("recovers a deterministic proposal claim from a custom archived-trait lane", async () => {
const canonical = task({
id: "FN-9",
title: "Canonical export task",
description: "Canonical export work",
column: "done",
});
const recoverableClaim = task({
id: "FN-10",
description: "Archived recommendation claim",
column: "boxed" as Column,
proposalClaimId: "recommendation:FN-1:rec-1",
sourceMetadata: { deterministicDuplicateOf: "FN-9" },
});
const custom = buildApp([parent(), canonical, recoverableClaim]);
installCustomRecommendationWorkflow(custom.store, ["FN-10"]);
(custom.store.searchTasks as ReturnType<typeof vi.fn>).mockResolvedValue([]);
const response = await performRequest(
custom.app,
"POST",
"/api/tasks/FN-1/recommendations/rec-1/create",
undefined,
);
expect(response.status).toBe(200);
expect(custom.store.createTask).not.toHaveBeenCalled();
expect(custom.store.unarchiveTask).not.toHaveBeenCalled();
expect(custom.store.moveTask).toHaveBeenCalledWith("FN-10", "backlog", { recoveryRehome: true });
expect(custom.store.linkTaskRecommendation).toHaveBeenCalledWith(
"FN-1",
"rec-1",
"FN-10",
expect.any(Set),
);
expect(custom.tasks[0]?.recommendations?.[0]?.createdTaskId).toBe("FN-10");
});
it("recovers a deterministic proposal claim from synthesized v1 archived state", async () => {
const canonical = task({
id: "FN-9",
title: "Canonical export task",
description: "Canonical export work",
column: "done",
});
const recoverableClaim = task({
id: "FN-10",
description: "Legacy archived recommendation claim",
column: "archived",
proposalClaimId: "recommendation:FN-1:rec-1",
sourceMetadata: { deterministicDuplicateOf: "FN-9" },
});
const legacy = buildApp([parent(), canonical, recoverableClaim]);
installLegacyV1RecommendationWorkflow(legacy.store, ["FN-10"]);
(legacy.store.searchTasks as ReturnType<typeof vi.fn>).mockResolvedValue([]);
const response = await performRequest(
legacy.app,
"POST",
"/api/tasks/FN-1/recommendations/rec-1/create",
undefined,
);
expect(response.status).toBe(200);
expect(legacy.store.createTask).not.toHaveBeenCalled();
expect(legacy.store.unarchiveTask).not.toHaveBeenCalled();
expect(legacy.store.moveTask).toHaveBeenCalledWith("FN-10", "triage", { recoveryRehome: true });
expect(legacy.store.linkTaskRecommendation).toHaveBeenCalledWith(
"FN-1",
"rec-1",
"FN-10",
expect.any(Set),
);
expect(legacy.tasks[0]?.recommendations?.[0]?.createdTaskId).toBe("FN-10");
});
it.each([
{ bypassDuplicateCheck: true },
{ acknowledgedDuplicates: ["FN-9"] },

View File

@@ -86,6 +86,7 @@ import {
} from "@fusion/core";
import { GitHubClient } from "../github.js";
import { resolveArtifactMediaPath } from "../artifact-media.js";
import { archivedColumnsForTask } from "../task-lifecycle-lanes.js";
import { githubRateLimiter } from "../github-poll.js";
import { createTrackingIssueForTask } from "../github-tracking-hook.js";
import { parseGitHubBadgeUrl } from "./register-git-github.js";
@@ -357,7 +358,6 @@ async function resolveTerminalColumnsForTask(store: TaskStore, taskId: string):
}
}
function isArtifactType(value: string): value is ArtifactType {
return ARTIFACT_TYPES.has(value as ArtifactType);
}
@@ -2136,18 +2136,23 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
canonical lets the original claimed child return to its workflow-resolved intake lane.
*/
if (trusted?.recoverArchivedProposalTask) {
const restoredTask = await scopedStore.unarchiveTask(trusted.recoverArchivedProposalTask.id);
/*
FNXC:TaskRecommendations 2026-08-08-08:44:
A deterministic-duplicate archive is a lane move, not an operator archive, so it has no
pre-archive history for generic restore to replay. Re-home its recovered child to that
child's workflow intake explicitly; otherwise custom workflows can restore it to a legacy
fallback or complete lane instead of the normal guarded-intake destination.
FNXC:TaskRecommendations 2026-08-09-06:06:
Never call the cold-storage unarchive path here. Deterministic reconciliation keeps the row in
active storage and may move it to a custom archived-trait lane; re-home that live row directly
through the explicit recovery bypass because archive-to-intake is intentionally non-adjacent.
*/
const intakeColumn = await resolveIntakeColumnForTask(scopedStore, restoredTask.id);
const recoveredTask = restoredTask.column === intakeColumn
? restoredTask
: await scopedStore.moveTask(restoredTask.id, intakeColumn);
const archivedTask = trusted.recoverArchivedProposalTask;
const intakeColumn = await resolveIntakeColumnForTask(scopedStore, archivedTask.id);
const recoveredTask = archivedTask.column === intakeColumn
? archivedTask
: await scopedStore.moveTask(archivedTask.id, intakeColumn, { recoveryRehome: true });
const trustedCreateResult = await trusted.onCreated?.(recoveredTask);
res.status(200).json(trusted.responseForCreated?.(recoveredTask, trustedCreateResult) ?? recoveredTask);
return;
@@ -2349,13 +2354,16 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
throw conflict("Recommendation link is malformed");
}
const linked = await scopedStore.getTask(recommendation.createdTaskId).catch(() => null);
const linkedArchiveColumns = linked
? await archivedColumnsForTask(scopedStore, linked.id)
: new Set<string>();
/*
FNXC:TaskRecommendations 2026-08-08-06:34:
A prior link is reusable only while its child remains in a live task lane. Archived and
soft-deleted children are historical records, not an actionable Created result; conflict
rather than silently resurrecting or linking a second child.
*/
if (!linked || linked.deletedAt || linked.column === "archived") {
if (!linked || linked.deletedAt || linkedArchiveColumns.has(linked.column)) {
throw conflict("Recommendation link points to an unavailable task");
}
return res.status(200).json({ task: linked, parent });
@@ -2370,16 +2378,9 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
*/
const existing = (await scopedStore.listTasks({ slim: false, includeArchived: true, includeDeleted: true }))
.find((task) => task.proposalClaimId === proposalClaimId);
const existingArchiveColumns = await (async () => {
if (!existing) return new Set<string>();
try {
const ir = await resolveWorkflowIrForTask(scopedStore, existing.id);
const columns = columnsWithFlag(ir, "archived");
return new Set(columns.length > 0 ? columns : ["archived"]);
} catch {
return new Set(["archived"]);
}
})();
const existingArchiveColumns = existing
? await archivedColumnsForTask(scopedStore, existing.id)
: new Set<string>();
/*
FNXC:TaskRecommendations 2026-08-08-08:44:
Deterministic reconciliation moves a child to its workflow's archived trait, which may be

View File

@@ -1,4 +1,4 @@
import { columnsWithFlag, declaresAnyLifecycleTrait, resolveWorkflowIrForTask, type WorkflowIr } from "@fusion/core";
import { columnsWithFlag, declaresAnyLifecycleTrait, resolveWorkflowIrForTask, workflowHasColumn, type WorkflowIr } from "@fusion/core";
/*
FNXC:WorkflowResolvedColumns 2026-07-30-08:45 (#2783 review — coderabbit):
@@ -46,6 +46,14 @@ legacy fallback as a workflow that cannot be read at all.
*/
const LEGACY_LANDED_COLUMNS: readonly string[] = ["done", "archived"];
function archivedColumnsForIr(ir: WorkflowIr): Set<string> {
if (!declaresAnyLifecycleTrait(ir)) return new Set(["archived"]);
const archived = columnsWithFlag(ir, "archived");
return workflowHasColumn(ir, "archived")
? new Set(archived)
: new Set([...archived, "archived"]);
}
export async function landedColumnsForTask(
store: LaneResolverStore,
taskId: string,
@@ -53,8 +61,8 @@ export async function landedColumnsForTask(
): Promise<Set<string>> {
try {
const ir = await resolveWorkflowIrForTask(store, taskId, irCache);
const landed = [...columnsWithFlag(ir, "complete"), ...columnsWithFlag(ir, "archived")];
return new Set(landed.length > 0 ? landed : LEGACY_LANDED_COLUMNS);
if (!declaresAnyLifecycleTrait(ir)) return new Set(LEGACY_LANDED_COLUMNS);
return new Set([...columnsWithFlag(ir, "complete"), ...archivedColumnsForIr(ir)]);
} catch {
return new Set(LEGACY_LANDED_COLUMNS);
}
@@ -108,6 +116,11 @@ Falling back onto it widens the guard onto a role the board explicitly did not a
`declaresAnyLifecycleTrait` separates the two, matching the shape #2821's review established for
`resolveNodeOverrideLanes`. A board that traits nothing keeps the legacy id; a board that traits
something is taken at its word, including when the answer is "no such lane".
FNXC:TaskRecommendations 2026-08-09-06:06:
An undeclared legacy `archived` id remains a compatibility tombstone even after a workflow adopts
lifecycle traits. Only an explicitly declared untraited `archived` column proves that id is live;
archived-trait lanes remain additive because persisted pre-migration tasks can still use the old id.
*/
export async function archivedColumnsForTask(
store: LaneResolverStore,
@@ -116,8 +129,7 @@ export async function archivedColumnsForTask(
): Promise<Set<string>> {
try {
const ir = await resolveWorkflowIrForTask(store, taskId, irCache);
if (!declaresAnyLifecycleTrait(ir)) return new Set(["archived"]);
return new Set(columnsWithFlag(ir, "archived"));
return archivedColumnsForIr(ir);
} catch {
return new Set(["archived"]);
}