diff --git a/packages/core/src/__tests__/archive-all-done-lane.test.ts b/packages/core/src/__tests__/archive-all-done-lane.test.ts new file mode 100644 index 0000000000..de69deef96 --- /dev/null +++ b/packages/core/src/__tests__/archive-all-done-lane.test.ts @@ -0,0 +1,68 @@ +/* +FNXC:WorkflowLifecycleColumns 2026-08-01-05:00: + +THE INVARIANT: "Archive all done" reads the board's OWN complete lane. + +`listTasks({ column: "done" })` filters in the STORE, so on a renamed board this returned an empty +array and the action completed successfully having archived **zero** cards. An operator action that +silently does nothing is worse than one that errors: the board simply looks unchanged, so the natural +conclusion is that there was nothing to archive. + +Census-invisible — the literal is a query filter, not a comparison — and this file had no lifecycle +comparison to convert at all. + +REVERT PROOF, measured: restore `listTasks({ slim: true, column: "done" })` and the renamed case +archives nothing. +*/ +import { describe, expect, it, vi } from "vitest"; +import { archiveAllDoneImpl } from "../task-store/task-artifacts-ops.js"; +import type { TaskStore } from "../store.js"; + +const RENAMED_IR = { + version: "v2", id: "wf-renamed", name: "renamed", nodes: [], edges: [], + columns: [ + { id: "building", name: "Building", traits: [{ trait: "wip", config: { limitSetting: "maxConcurrent" } }] }, + { id: "shipped", name: "Shipped", traits: [{ trait: "complete" }] }, + ], +}; + +function store(tasksByColumn: Record, definitions: unknown[]) { + const archived: string[] = []; + const impl = { + listWorkflowDefinitions: vi.fn(async () => definitions), + listTasks: vi.fn(async ({ column }: { column: string }) => tasksByColumn[column] ?? []), + archiveTask: vi.fn(async (id: string) => { archived.push(id); return { id } as never; }), + logEntry: vi.fn(async () => undefined), + } as unknown as TaskStore; + return { impl, archived }; +} + +const card = (id: string, column: string) => ({ id, column, dependencies: [], steps: [] }); + +describe("archiveAllDone resolves the board's own complete lane", () => { + it("archives a card sitting in a RENAMED complete lane", async () => { + const { impl, archived } = store({ shipped: [card("FN-1", "shipped")] }, [{ ir: RENAMED_IR }]); + + await archiveAllDoneImpl(impl); + + expect(archived).toEqual(["FN-1"]); + }); + + it("still archives legacy rows, for a board mid-rename", async () => { + // The union keeps rows stored under the old id reachable while a rename is in flight. + const { impl, archived } = store({ done: [card("FN-2", "done")] }, [{ ir: RENAMED_IR }]); + + await archiveAllDoneImpl(impl); + + expect(archived).toEqual(["FN-2"]); + }); + + it("does not archive a card outside the complete lane", async () => { + // The action must stay scoped — archiving everything would be its own bug. + const { impl, archived } = store({ building: [card("FN-3", "building")] }, [{ ir: RENAMED_IR }]); + + await archiveAllDoneImpl(impl); + + expect(archived).toEqual([]); + }); +}); diff --git a/packages/core/src/__tests__/eval-automation-complete-lane.test.ts b/packages/core/src/__tests__/eval-automation-complete-lane.test.ts new file mode 100644 index 0000000000..b2525fefdd --- /dev/null +++ b/packages/core/src/__tests__/eval-automation-complete-lane.test.ts @@ -0,0 +1,76 @@ +/* +FNXC:WorkflowLifecycleColumns 2026-08-01-03:10: + +THE INVARIANT: the scheduled eval batch selects the board's OWN complete lane. + +THE QUERY, plus a redundant re-assertion beneath it — the pairing that makes this class deceptive: + + const doneTasks = (await store.listTasks({ column: "done" })) // the live filter + .filter((task) => task.column === "done" && …); // the census counts THIS + +`listTasks({ column })` filters in the STORE, so on a renamed board the read returned an empty array +and **every scheduled eval run completed having evaluated zero tasks** — a run that reports success +over nothing. Converting the `.filter` alone would have dropped a census count and changed nothing, +because the list was already empty when it ran. + +The redundant clause is DELETED rather than converted. A second copy of the same rule is how a read +and its filter drift apart; the completion-timestamp window is the only thing it contributed beyond +the column, and that is kept. + +REVERT PROOF, measured: restore `listTasks({ column: "done" })` and the renamed case selects nothing. +*/ +import { describe, expect, it, vi } from "vitest"; +import { resolveProjectColumnsForRoles, TERMINAL_ROLES } from "../project-lane-vocabulary.js"; + +const RENAMED_IR = { + version: "v2", id: "wf-renamed", name: "renamed", nodes: [], edges: [], + columns: [ + { id: "building", name: "Building", traits: [{ trait: "wip", config: { limitSetting: "maxConcurrent" } }] }, + { id: "shipped", name: "Shipped", traits: [{ trait: "complete" }] }, + ], +}; + +describe("the eval batch's complete-lane vocabulary", () => { + it("includes a RENAMED complete lane and the legacy id", async () => { + const store = { listWorkflowDefinitions: vi.fn(async () => [{ ir: RENAMED_IR }]) }; + + const columns = await resolveProjectColumnsForRoles(store, ["complete"]); + + expect(columns.has("shipped")).toBe(true); + // Unioned, so a board mid-rename still evaluates rows stored under the old id. + expect(columns.has("done")).toBe(true); + }); + + it("does NOT include the wip lane — complete only, as the original filter was", async () => { + // Widening this to the terminal PAIR would start evaluating archived work the literal never saw. + const store = { listWorkflowDefinitions: vi.fn(async () => [{ ir: RENAMED_IR }]) }; + + const columns = await resolveProjectColumnsForRoles(store, ["complete"]); + + expect(columns.has("building")).toBe(false); + expect([...(await resolveProjectColumnsForRoles(store, TERMINAL_ROLES))].includes("archived")).toBe(true); + }); + + it("the eval batch reads through the resolver, not the literal", () => { + /* + Structural: `runScheduledEvalBatch` needs an eval store, an automation row and a live task store + to drive end to end. What was missing is that the READ asks for the resolved lane at all — the + behaviour of the resolver itself is covered in `project-lane-vocabulary.test.ts`. + */ + const raw = readFileSync(new URL("../eval-automation.ts", import.meta.url), "utf8"); + /* + COMMENTS STRIPPED FIRST. My first version asserted against the raw source and failed on its own + explanatory comment, which quotes the deleted clause verbatim — a ratchet matching prose rather + than code is the exact flaw I have criticised in others' guards, and the census AST strips + comments for the same reason. + */ + const source = raw.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, ""); + + expect(source).toContain('resolveProjectColumnsForRoles(params.store as never, ["complete"])'); + expect(source).not.toContain('listTasks({ column: "done" })'); + // The redundant re-assertion must stay deleted. + expect(source).not.toContain('task.column === "done"'); + }); +}); + +import { readFileSync } from "node:fs"; diff --git a/packages/core/src/__tests__/project-lane-vocabulary.test.ts b/packages/core/src/__tests__/project-lane-vocabulary.test.ts new file mode 100644 index 0000000000..6d7200ef90 --- /dev/null +++ b/packages/core/src/__tests__/project-lane-vocabulary.test.ts @@ -0,0 +1,149 @@ +/* +FNXC:WorkflowLifecycleColumns 2026-07-31-19:30: + +THE INVARIANT: a QUERY resolves the PROJECT's lane vocabulary, not a task's. + +WHY THIS IS A DIFFERENT SHAPE FROM EVERY OTHER RESOLVER HERE. `resolveTaskLifecycleColumns` answers +"what does THIS card's workflow call its review lane" — the right question for a guard, and an +impossible one for a read: + + await store.listTasks({ column: "in-review" }) // there is no task to resolve from yet + +#2800 measured the consequence: `self-healing.ts` alone issues 49 such reads, and on a renamed board +every one returns an EMPTY array, so the sweep never executes. The census scores the comparison +INSIDE the loop, not the query above it — so converting those comparisons drops a count while the +loop body stays unreachable. In that file the census total is not a floor; it is misleading. + +WHAT THIS MODULE IS FOR. It gives the query class one shared answer instead of each site inventing +its own. I wrote this logic once inline for the legacy auto-merge stamp backfill; a second copy is +how two readers of the same fact begin to disagree. + +THE ASYMMETRY IS THE DESIGN. The legacy ids are always unioned in, never replaced: a board mid-rename +still has rows under the old id, and a query that skips them silently does nothing — the exact +failure being fixed. Over-inclusion costs one extra query whose rows the caller's own predicate then +filters; under-inclusion is invisible. The set is therefore never empty, so a caller cannot +accidentally query nothing. +*/ +import { describe, expect, it, vi } from "vitest"; +import { + LEGACY_COLUMN_IDS_BY_ROLE, + REVIEW_ROLES, + TERMINAL_ROLES, + resolveProjectColumnsForRoles, +} from "../project-lane-vocabulary.js"; + +const RENAMED_IR = { + version: "v2", id: "wf-renamed", name: "renamed", nodes: [], edges: [], + columns: [ + { id: "backlog", name: "Backlog", traits: [{ trait: "intake" }, { trait: "hold" }] }, + { id: "building", name: "Building", traits: [{ trait: "wip", config: { limitSetting: "maxConcurrent" } }] }, + { id: "signoff", name: "Sign-off", traits: [{ trait: "merge" }] }, + { id: "waiting", name: "Waiting", traits: [{ trait: "human-review" }] }, + { id: "shipped", name: "Shipped", traits: [{ trait: "complete" }] }, + { id: "vault", name: "Vault", traits: [{ trait: "archived" }] }, + ], +}; + +/** A SECOND workflow, so the union across definitions is exercised rather than assumed. */ +const OTHER_IR = { + version: "v2", id: "wf-other", name: "other", nodes: [], edges: [], + columns: [ + { id: "checking", name: "Checking", traits: [{ trait: "merge" }] }, + { id: "released", name: "Released", traits: [{ trait: "complete" }] }, + ], +}; + +const store = (definitions: unknown[]) => ({ + listWorkflowDefinitions: vi.fn(async () => definitions as Array<{ ir?: unknown }>), +}); + +describe("resolveProjectColumnsForRoles", () => { + it("returns every review lane the project's workflows declare", async () => { + const columns = await resolveProjectColumnsForRoles(store([{ ir: RENAMED_IR }, { ir: OTHER_IR }]), REVIEW_ROLES); + + expect(columns.has("signoff")).toBe(true); + expect(columns.has("waiting")).toBe(true); + expect(columns.has("checking")).toBe(true); + }); + + it("ALWAYS unions the legacy id, for a board mid-rename", async () => { + // Rows stored under the old id must not be skipped while a rename is in flight — a query that + // skips them silently does nothing, which is the failure this module exists to fix. + const columns = await resolveProjectColumnsForRoles(store([{ ir: RENAMED_IR }]), REVIEW_ROLES); + + expect(columns.has("in-review")).toBe(true); + }); + + it("is never empty, so a caller cannot accidentally query nothing", async () => { + const columns = await resolveProjectColumnsForRoles(store([]), REVIEW_ROLES); + + expect([...columns]).toEqual(["in-review"]); + }); + + it("keeps roles separate — terminal does not leak review lanes", async () => { + const terminal = await resolveProjectColumnsForRoles(store([{ ir: RENAMED_IR }]), TERMINAL_ROLES); + + expect(terminal.has("shipped")).toBe(true); + expect(terminal.has("vault")).toBe(true); + expect(terminal.has("signoff")).toBe(false); + }); + + it("degrades to the legacy ids when definitions cannot be read", async () => { + // A throwing workflow read must not turn a degraded definition into a failed sweep. + const throwing = { listWorkflowDefinitions: vi.fn(async () => { throw new Error("unreadable"); }) }; + + expect([...(await resolveProjectColumnsForRoles(throwing, TERMINAL_ROLES))].sort()).toEqual(["archived", "done"]); + }); + + it("parses a string-serialised IR, the shape some backends actually return", async () => { + /* + `parseWorkflowIr` VALIDATES — it throws unless the graph has exactly one start and one end — so + the string form needs a well-formed graph, unlike the object form which is passed through. The + fixture carries the nodes for that reason, not decoration. + */ + const serialisable = { + ...RENAMED_IR, + nodes: [{ id: "s", kind: "start" }, { id: "e", kind: "end" }], + edges: [{ from: "s", to: "e" }], + }; + + const columns = await resolveProjectColumnsForRoles(store([{ ir: JSON.stringify(serialisable) }]), TERMINAL_ROLES); + + expect(columns.has("shipped")).toBe(true); + }); + + it("one malformed definition does not erase the vocabulary of the others", async () => { + /* + The bug my first draft had, found by the string-IR case above. `parseWorkflowIr` throws on an + invalid graph, and a single `try` around the whole loop meant one half-migrated row handed back + legacy-only lanes for EVERY workflow — a failure indistinguishable from the renamed-board bug + this helper exists to fix. + */ + const columns = await resolveProjectColumnsForRoles( + store([{ ir: "{not json" }, { ir: RENAMED_IR }]), + TERMINAL_ROLES, + ); + + expect(columns.has("shipped")).toBe(true); + expect(columns.has("vault")).toBe(true); + }); + + it("degrades when the store does not declare listWorkflowDefinitions at all", async () => { + /* + Several call sites hold a deliberately narrow store interface that omits the method even though + the real TaskStore behind it has one (`EvalBatchTaskStore` was the first). Requiring it would + force every such interface — and its fakes — to widen, to satisfy a helper whose contract is + already "degrade to the legacy ids when the workflows cannot be read". Absent and throwing are + the same case. + */ + expect([...(await resolveProjectColumnsForRoles({} as never, TERMINAL_ROLES))].sort()).toEqual(["archived", "done"]); + }); + + it("declares a legacy id for every role it can be asked about", () => { + // A role with no legacy entry would produce a set missing the pre-rename column — the exact + // silent skip this module exists to prevent. + for (const role of [...REVIEW_ROLES, ...TERMINAL_ROLES]) { + expect(LEGACY_COLUMN_IDS_BY_ROLE[role]?.length ?? 0).toBeGreaterThan(0); + } + }); +}); diff --git a/packages/core/src/eval-automation.ts b/packages/core/src/eval-automation.ts index 5330d3c57d..9aa890f396 100644 --- a/packages/core/src/eval-automation.ts +++ b/packages/core/src/eval-automation.ts @@ -1,4 +1,5 @@ import type { AutomationStore } from "./automation-store.js"; +import { resolveProjectColumnsForRoles } from "./project-lane-vocabulary.js"; import type { ScheduledTask, ScheduledTaskCreateInput } from "./automation.js"; import type { EvalRun, EvalTaskResultCreateInput } from "./eval-types.js"; import { EvalLifecycleError } from "./eval-store.js"; @@ -172,9 +173,30 @@ export async function runScheduledEvalBatch( await evalStore.updateRun(run.id, { status: "running", startedAt }); try { - const doneTasks = (await params.store.listTasks({ column: "done" })).filter((task) => - task.column === "done" - && Boolean(task.executionCompletedAt) + /* + FNXC:WorkflowLifecycleColumns 2026-08-01-03:10: + THE QUERY plus its redundant re-assertion — the scheduled eval run selected NOTHING. + + `listTasks({ column })` filters in the store, so on a renamed board this read returned an empty + array and every scheduled eval run completed having evaluated zero tasks. The `.filter`'s + `task.column === "done"` below it re-asserted the column the query had already selected on, so + converting that comparison alone would have dropped a census count and changed nothing — the list + was empty before the filter ran. + + The redundant clause is DELETED rather than converted: a second copy of the same rule is how a + read and its filter drift apart. The completion-timestamp window is the only thing it contributed + beyond the column, and that is kept. + + Project-level resolution, because a read has no task in hand, unioned with the legacy id so a + board mid-rename still evaluates rows stored under the old one. + */ + const completeColumns = await resolveProjectColumnsForRoles(params.store as never, ["complete"]); + const byId = new Map>[number]>(); + for (const column of completeColumns) { + for (const task of await params.store.listTasks({ column })) byId.set(task.id, task); + } + const doneTasks = [...byId.values()].filter((task) => + Boolean(task.executionCompletedAt) && (!windowStartExclusive || task.executionCompletedAt! > windowStartExclusive) && task.executionCompletedAt! <= windowEndInclusive, ); diff --git a/packages/core/src/index.gate.ts b/packages/core/src/index.gate.ts index 935a5254f2..8ababbe906 100644 --- a/packages/core/src/index.gate.ts +++ b/packages/core/src/index.gate.ts @@ -2255,6 +2255,17 @@ export type { WorkflowEventBus, WorkflowEventSubscriber, WorkflowEventSubscripti export { findWorkflowEventShapeViolations, isIdsOnlyWorkflowEvent, MAX_ID_VALUE_LENGTH, IMPLEMENTATION_EXITS } from "./types/workflow-events.js"; export type { WorkflowLifecycleEvent, WorkflowLifecycleEventType, WorkflowLifecycleEventBase, TaskTransitionedEvent, NodeEnteredEvent, NodeCompletedEvent, RunSuspendedEvent, RunResumedEvent, WorkflowEventShapeViolation, ImplementationExit } from "./types/workflow-events.js"; export { columnsWithFlag, columnHasFlag, resolveReboundTarget, resolveCompleteColumn, resolveMergeOrchestrationColumn, resolveLifecycleColumns, resolveTaskLifecycleColumns, resolveTerminalColumns, resolveReviewColumns } from "./workflow-lifecycle-traits.js"; +/* +FNXC:WorkflowLifecycleColumns 2026-08-01-00:40: +ALSO exported from the GATE barrel, not just `index.ts`. + +`packages/engine`'s gate vitest project resolves `@fusion/core` through a bundle built from THIS +file (`scripts/build-engine-core-gate-bundle.mjs`), so an export added only to `index.ts` resolves to +`undefined` inside every gate test — and the failure is a runtime `TypeError` deep in the caller, not +an import error. That cost 88 red tests in `project-engine.test.ts`, all with the same misleading +"columns is not iterable" a hundred lines from the actual cause. +*/ +export { resolveProjectColumnsForRoles, REVIEW_ROLES, TERMINAL_ROLES, LEGACY_COLUMN_IDS_BY_ROLE, type ProjectLaneVocabularyStore } from "./project-lane-vocabulary.js"; export type { LifecycleColumns } from "./workflow-lifecycle-traits.js"; export { resolveReviewLevelSteps, applyReviewLevelPreset } from "./review-level-preset.js"; export { LEGACY_STATUS_ADOPTION, resolveLegacyStatusAdoption, resolveReviewLevelBackfill, planLegacyAdoption, resolveOrphanedPendingStepResults, type LegacyAdoptionPlan, type LegacyAdoptionCandidate, type LegacyAdoptionAction, type LegacyAdoptionKind } from "./legacy-adoption.js"; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 97d3e54aa2..6951a0a771 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -469,6 +469,7 @@ export { findWorkflowEventShapeViolations, isIdsOnlyWorkflowEvent, MAX_ID_VALUE_ export type { WorkflowLifecycleEvent, WorkflowLifecycleEventType, WorkflowLifecycleEventBase, TaskTransitionedEvent, NodeEnteredEvent, NodeCompletedEvent, RunSuspendedEvent, RunResumedEvent, WorkflowEventShapeViolation, ImplementationExit } from "./types/workflow-events.js"; export { columnHasFlag, columnsWithFlag, declaresAnyLifecycleTrait, resolveArchiveTargetForTask, resolveCompleteColumn, resolveLifecycleColumns, resolveMergeOrchestrationColumn, resolveReboundTarget, resolveReboundTargetForTask, resolveReviewColumns, resolveTaskLifecycleColumns, resolveTerminalColumns, resolveWipTargetForTask } from "./workflow-lifecycle-traits.js"; export type { LifecycleColumns } from "./workflow-lifecycle-traits.js"; +export { resolveProjectColumnsForRoles, REVIEW_ROLES, TERMINAL_ROLES, LEGACY_COLUMN_IDS_BY_ROLE, type ProjectLaneVocabularyStore } from "./project-lane-vocabulary.js"; export { resolveReviewLevelSteps, applyReviewLevelPreset } from "./review-level-preset.js"; export { LEGACY_STATUS_ADOPTION, diff --git a/packages/core/src/project-lane-vocabulary.ts b/packages/core/src/project-lane-vocabulary.ts new file mode 100644 index 0000000000..2c8f58cf43 --- /dev/null +++ b/packages/core/src/project-lane-vocabulary.ts @@ -0,0 +1,126 @@ +/* +FNXC:WorkflowLifecycleColumns 2026-07-31-19:30: +The PROJECT's lane vocabulary — the columns to READ before there is a task to resolve from. + +WHY THIS EXISTS. Every lane guard so far resolves from a task: `resolveTaskLifecycleColumns(store, id)` +answers "what does THIS card's workflow call its review lane". That is the right shape for a guard, +and the wrong shape for a QUERY, because a query runs before any task is in hand: + + await store.listTasks({ column: "in-review" }) // ← nothing to resolve from + +`#2800` measured the cost: `self-healing.ts` alone issues 49 such reads, and on a board whose lanes +are renamed every one returns an EMPTY array, so the sweep it feeds never executes. The census scores +the comparison inside the loop, not the query above it, so converting those comparisons drops a count +and changes nothing an operator can observe — the loop body was already unreachable. + +Fixing a query needs a different answer: not "this task's lane" but "every column ANY workflow in this +project declares for this role". That is what this module returns, and it is deliberately shared +rather than re-derived per call site — I wrote this logic once inline for the legacy auto-merge stamp +backfill, and a second copy is how two readers of the same fact start disagreeing. + +THE LEGACY ID IS UNIONED, NOT REPLACED. A board mid-rename still has rows stored under the old id, and +a query that skips them silently does nothing — which is the exact failure being fixed. Over-inclusion +costs one extra query whose rows are then filtered by the caller's own predicate; under-inclusion is +invisible. Those are not symmetric. + +WHAT THIS IS NOT. It does not tell you what a given CARD's lane is — use `resolveTaskLifecycleColumns` +for that. Answering a per-card question from this union would mark a card as review because some other +workflow calls its column review, which is the flat-set mistake this program has already made four +times. +*/ + +import { columnsWithFlag } from "./workflow-lifecycle-traits.js"; +import { parseWorkflowIr } from "./workflow-ir.js"; +import type { TraitFlags } from "./trait-types.js"; + +/** The store surface this needs — deliberately narrow so callers can pass a fake. */ +export interface ProjectLaneVocabularyStore { + /* + FNXC:WorkflowLifecycleColumns 2026-08-01-03:10: + OPTIONAL, because several call sites hold a deliberately narrow store interface that does not + declare this method even though the real `TaskStore` behind it has one (`EvalBatchTaskStore` is the + first such caller). Requiring it would force every narrow interface to widen — a contract change + rippling into their fakes — to satisfy a helper whose whole contract is "degrade to the legacy ids + when the workflows cannot be read". + + Absent method and throwing method are therefore the same case, and both are already covered. + */ + listWorkflowDefinitions?: () => Promise>; +} + +/** + * Legacy column ids per role, unioned into every answer. + * + * NOT lifecycle rules — the ids the built-in board shipped with, kept so a project mid-rename (rows + * still stored under the old id) is never skipped by a query. + */ +export const LEGACY_COLUMN_IDS_BY_ROLE: Record = { + intake: ["todo", "triage"], + hold: ["todo"], + countsTowardWip: ["in-progress"], + mergeOrchestration: ["in-review"], + mergeBlocker: ["in-review"], + humanReview: ["in-review"], + complete: ["done"], + archived: ["archived"], +}; + +/** + * Every column id any workflow in this project declares for the given trait roles, unioned with the + * legacy ids for those roles. + * + * @param roles one or more trait flags — pass several to get a union (e.g. the three review traits). + * @returns a set safe to iterate as `listTasks({ column })` reads. Never empty: the legacy ids are + * always present, so a caller cannot accidentally query nothing. + */ +export async function resolveProjectColumnsForRoles( + store: ProjectLaneVocabularyStore, + roles: ReadonlyArray, +): Promise> { + const columns = new Set(); + for (const role of roles) { + for (const legacy of LEGACY_COLUMN_IDS_BY_ROLE[role] ?? []) columns.add(legacy); + } + + let definitions: ReadonlyArray<{ ir?: unknown }> = []; + if (typeof store.listWorkflowDefinitions !== "function") return columns; + try { + definitions = await store.listWorkflowDefinitions(); + } catch { + /* + An unreadable definition LIST leaves the legacy ids alone — the behaviour a caller had before it + adopted this helper. Throwing here would turn a degraded workflow read into a failed sweep, which + is strictly worse than a sweep covering only the built-in lanes. + */ + return columns; + } + + for (const definition of definitions) { + /* + PER-DEFINITION isolation, and the reason is measured rather than defensive: `parseWorkflowIr` + VALIDATES (it throws on a graph without exactly one start and one end), so a single malformed or + half-migrated row would otherwise abort the whole loop and silently hand back legacy-only lanes + for every OTHER workflow too. One bad row must not erase the project's vocabulary — that failure + would look exactly like the renamed-board bug this helper exists to fix. + + My first draft wrapped the entire loop in one `try`, and the string-IR test is what exposed it. + */ + try { + const ir = typeof definition.ir === "string" ? parseWorkflowIr(definition.ir) : definition.ir; + if (!ir) continue; + for (const role of roles) { + for (const id of columnsWithFlag(ir as never, role)) columns.add(id); + } + } catch { + continue; + } + } + + return columns; +} + +/** The three traits that all mean "a card is in review"; see `isReviewColumnRole` for why it is a union. */ +export const REVIEW_ROLES = ["mergeOrchestration", "mergeBlocker", "humanReview"] as const; + +/** "Finished either way" — the pair `resolveTerminalColumns` answers for a single task. */ +export const TERMINAL_ROLES = ["complete", "archived"] as const; diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 3af60573e2..0e284ba63d 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -127,6 +127,7 @@ import { getTaskImpl, listTasksImpl, searchTasksImpl, listTasksModifiedSinceImpl import { updateTaskUnlockedImpl } from "./task-store/task-update.js"; import { __setTaskActivityLogLimitsForTesting } from "./task-store/comments.js"; import { resolveReviewColumns, resolveTaskLifecycleColumns, type LifecycleColumns } from "./workflow-lifecycle-traits.js"; +import { resolveProjectColumnsForRoles } from "./project-lane-vocabulary.js"; import { resolveWorkflowIrForTask } from "./workflow-ir-resolver.js"; // FNXC:RuntimeBackendAsync 2026-06-24-10:15: // Async helper imports for backend-mode (AsyncDataLayer/PostgreSQL) delegation. @@ -844,7 +845,18 @@ export class TaskStore extends EventEmitter { } const shiftedTaskIds: string[] = []; - const tasks = await this.listTasks({ column: "in-progress", includeArchived: false, slim: true }); + /* + FNXC:WorkflowLifecycleColumns 2026-08-01-05:00: + The engine-downtime timing shift read the wip lane by name, so on a renamed board it found NO + tasks and no active-timing anchor was ever shifted — every card's active time then silently + absorbed the stopped-engine wall-clock this sweep exists to exclude. + */ + const wipColumns = await resolveProjectColumnsForRoles(this, ["countsTowardWip"]); + const byId = new Map(); + for (const column of wipColumns) { + for (const task of await this.listTasks({ column, includeArchived: false, slim: true })) byId.set(task.id, task); + } + const tasks = [...byId.values()]; for (const task of tasks) { const startedMs = Date.parse(task.executionStartedAt ?? ""); if (!Number.isFinite(startedMs) || startedMs > heartbeatMs) continue; diff --git a/packages/core/src/task-store/task-artifacts-ops.ts b/packages/core/src/task-store/task-artifacts-ops.ts index 2a9c799661..a2e7e57dd0 100644 --- a/packages/core/src/task-store/task-artifacts-ops.ts +++ b/packages/core/src/task-store/task-artifacts-ops.ts @@ -10,6 +10,7 @@ */ import { TaskStore } from "../store.js"; +import { resolveProjectColumnsForRoles } from "../project-lane-vocabulary.js"; import {resolveTaskLifecycleColumns} from "../workflow-lifecycle-traits.js"; import {resolveWorkflowIrForTask} from "../workflow-ir-resolver.js"; import { countAgentLogEntries, readAgentLogEntries } from "../agent-log-file-store.js"; @@ -298,7 +299,24 @@ export async function clearStaleExecutionStartBranchReferencesImpl(store: TaskSt } export async function archiveAllDoneImpl(store: TaskStore, options?: { removeLineageReferences?: boolean }): Promise { - const doneTasks = await store.listTasks({ slim: true, column: "done" }); + /* + FNXC:WorkflowLifecycleColumns 2026-08-01-05:00: + "Archive all done" archived NOTHING on a renamed board. + + `listTasks({ column })` filters in the store, so this read returned an empty array and the button + completed successfully having archived zero cards — an operator action that silently does nothing + is worse than one that errors, because the board simply looks unchanged. + + Project-level resolution: a read has no task in hand. The legacy id is unioned in, so a board + mid-rename still archives rows stored under the old one, and the set is deduped by id because one + column can carry both complete and archived. + */ + const completeColumns = await resolveProjectColumnsForRoles(store, ["complete"]); + const doneById = new Map(); + for (const column of completeColumns) { + for (const task of await store.listTasks({ slim: true, column })) doneById.set(task.id, task); + } + const doneTasks = [...doneById.values()]; if (doneTasks.length === 0) { return []; diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index 63a73f815c..c76918bce6 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -1449,8 +1449,22 @@ function AppInner() { moveTask, openAuthenticationSettings: () => openSettingsWithNav("authentication" as SectionId), addToast, + /* + FNXC:WorkflowLifecycleColumns 2026-08-01-02:10: + Resolve Cancel's destination from the card's OWN workflow. Without this the banner moved to a + hardcoded `"todo"`, which `moves.ts` REJECTS on a board that does not declare it — the button + threw instead of cancelling. Wired here rather than left optional: an unsupplied parameter is + the inert shape this program has already found five times. + */ + resolveCancelColumn: (taskId: string) => { + if (!footerBoardWorkflows) return undefined; + const workflow = footerBoardWorkflows.workflows.find( + (candidate) => candidate.id === (footerBoardWorkflows.taskWorkflowIds[taskId] ?? footerBoardWorkflows.defaultWorkflowId), + ); + return workflow?.columns.find((column) => column.flags?.hold === true)?.id; + }, }), - [addToast, currentProject?.id, modalManager, moveTask, retryTask], + [addToast, currentProject?.id, footerBoardWorkflows, modalManager, moveTask, retryTask], ); const [shellOnboardingComplete, setShellOnboardingComplete] = useState(false); diff --git a/packages/dashboard/app/utils/__tests__/appLifecycle.test.ts b/packages/dashboard/app/utils/__tests__/appLifecycle.test.ts index 39a7b31d56..ba143d275d 100644 --- a/packages/dashboard/app/utils/__tests__/appLifecycle.test.ts +++ b/packages/dashboard/app/utils/__tests__/appLifecycle.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import type { AiSessionSummary } from "../../api"; import { @@ -6,6 +6,7 @@ import { shouldShowSessionInBanner, isSessionNeedingInputForBanner, resolveDesktopShellRedirectTarget, + executeCliSessionBannerAction, } from "../appLifecycle"; function makeSession(overrides: Partial & Pick): AiSessionSummary { @@ -219,3 +220,58 @@ describe("resolveDesktopShellRedirectTarget", () => { ).toBeNull(); }); }); + +/* +FNXC:WorkflowLifecycleColumns 2026-08-01-02:10: + +THE INVARIANT: the CLI-session banner's Cancel returns the card to ITS OWN hold lane. + +CENSUS-INVISIBLE IN TWO WAYS AT ONCE — the literal lived in a call argument AND in the dep's TYPE +(`moveTask: (id: string, column: "todo")`), so the signature itself prevented any caller from passing +anything else. No scan for comparisons could reach either. + +Post-U12 the rejection in `moves.ts` is live: a move to a column the workflow does not declare throws +"Unknown column for this workflow" unless the caller sets `recoveryRehome`, which this is not. So on a +renamed board **Cancel threw instead of cancelling** — an operator-facing button that fails. + +WIRED, NOT OPTIONAL. `App.tsx` supplies `resolveCancelColumn` from the board-workflow metadata it +already holds. An optional parameter no caller fills is the inert shape this program has found five +times; adding a sixth to fix a broken button would have been worse than leaving it. + +REVERT PROOF, measured: restore the hardcoded `"todo"` and the renamed case moves to `todo` instead of +the board's own hold lane. +*/ +describe("CLI banner cancel resolves the board's own hold lane", () => { + const baseDeps = () => ({ + retryTask: vi.fn().mockResolvedValue(undefined), + moveTask: vi.fn().mockResolvedValue(undefined), + openAuthenticationSettings: vi.fn(), + addToast: vi.fn(), + }); + + it("moves to the RENAMED hold lane when the caller resolves one", async () => { + const deps = { ...baseDeps(), resolveCancelColumn: () => "backlog" }; + + await executeCliSessionBannerAction({ id: "FN-1" } as never, "cancel", deps as never); + + expect(deps.moveTask).toHaveBeenCalledWith("FN-1", "backlog"); + }); + + it("keeps the legacy destination when metadata has not resolved", async () => { + // Board-workflow metadata is absent on first paint and for remote projects; the documented + // fallback must stay exactly today's behaviour rather than refusing to cancel. + const deps = { ...baseDeps(), resolveCancelColumn: () => undefined }; + + await executeCliSessionBannerAction({ id: "FN-2" } as never, "cancel", deps as never); + + expect(deps.moveTask).toHaveBeenCalledWith("FN-2", "todo"); + }); + + it("keeps the legacy destination when no resolver is supplied at all", async () => { + const deps = baseDeps(); + + await executeCliSessionBannerAction({ id: "FN-3" } as never, "cancel", deps as never); + + expect(deps.moveTask).toHaveBeenCalledWith("FN-3", "todo"); + }); +}); diff --git a/packages/dashboard/app/utils/appLifecycle.ts b/packages/dashboard/app/utils/appLifecycle.ts index 789c837caf..29df92be5f 100644 --- a/packages/dashboard/app/utils/appLifecycle.ts +++ b/packages/dashboard/app/utils/appLifecycle.ts @@ -198,7 +198,22 @@ export function getCliActionDisabledReasonForBanner(session: AiSessionSummary, a export interface CliActionDeps { currentProjectId?: string; retryTask: (id: string) => Promise; - moveTask: (id: string, column: "todo") => Promise; + /* + FNXC:WorkflowLifecycleColumns 2026-08-01-02:10: + `string`, not the literal type — the signature itself was pinning the destination. + + Cancel moves the session's task back to the hold lane. Keyed on `"todo"`, and with the TYPE + enforcing that literal so no caller could pass anything else, the move is REJECTED on a board that + does not declare `todo` (`moves.ts` throws "Unknown column for this workflow" unless the caller sets + `recoveryRehome`, which this is not) — so **Cancel throws instead of cancelling**. A census scanning + for comparisons cannot see this: the literal lives in a call argument and a type annotation. + */ + moveTask: (id: string, column: string) => Promise; + /* + The task's own hold lane, resolved by the caller from board-workflow metadata. Omitted → `"todo"`, + which is today's behaviour; App.tsx supplies it, so this is not an optional parameter nobody fills. + */ + resolveCancelColumn?: (taskId: string) => string | undefined; openAuthenticationSettings: () => void; addToast: (message: string, type: "success" | "error") => void; apiClient?: typeof api; @@ -242,7 +257,8 @@ export async function executeCliSessionBannerAction( } if (action === "cancel") { - await deps.moveTask(session.id, "todo"); + /* DELIBERATE-LITERAL — the unresolved-metadata default, reviewed 2026-08-01-02:10. */ + await deps.moveTask(session.id, deps.resolveCancelColumn?.(session.id) ?? "todo"); return; } diff --git a/packages/engine/src/__tests__/backlog-pressure-reporter.test.ts b/packages/engine/src/__tests__/backlog-pressure-reporter.test.ts index 3385c26f6d..79c6e59cce 100644 --- a/packages/engine/src/__tests__/backlog-pressure-reporter.test.ts +++ b/packages/engine/src/__tests__/backlog-pressure-reporter.test.ts @@ -211,3 +211,77 @@ describe("BacklogPressureReporter", () => { expect(store.logEntry).toHaveBeenCalledWith("FN-1", expect.stringContaining("[backlog-pressure]")); }); }); + +/* +FNXC:WorkflowLifecycleColumns 2026-07-31-20:10: + +THE INVARIANT: the backlog-pressure ratio counts the board's OWN hold and wip lanes. + +THE QUERY, NOT THE COMPARISON — this reporter had no comparison to convert. `listTasks({ column })` +filters in the store, so on a renamed board both reads return EMPTY and the ratio is computed as 0/0: +the alert never fires, on a board that may be under exactly the pressure it exists to report. + +That is the class #2800 measured at 49 sites in `self-healing.ts` alone. The census cannot see any of +them: it scores comparisons, and a query filter is not a comparison. This file had a census count of +ZERO and was completely inert on a custom board. + +The resolution goes through `resolveProjectColumnsForRoles`, which answers the PROJECT-level question +a read needs — there is no task in hand yet to resolve from — and always unions the legacy id, so a +board mid-rename still counts rows stored under the old one. + +REVERT PROOF, measured: restore `listTasks({ column: "todo" })` and the renamed case reports +`under-threshold` from an empty backlog instead of alerting. +*/ +describe("backlog pressure resolves the board's own lanes", () => { + /* `logger` is scoped to the other describe block; restated rather than hoisted. */ + const laneLogger = { warn: vi.fn(), error: vi.fn() }; + const RENAMED_IR = { + version: "v2", id: "wf-renamed", name: "renamed", nodes: [], edges: [], + columns: [ + { id: "backlog", name: "Backlog", traits: [{ trait: "hold" }] }, + { id: "building", name: "Building", traits: [{ trait: "wip", config: { limitSetting: "maxConcurrent" } }] }, + ], + }; + + function renamedStore(counts: { hold: number; wip: number }): TaskStore { + const make = (id: string, column: string) => ({ id, column, title: id, priority: "normal" }) as unknown as Task; + const hold = Array.from({ length: counts.hold }, (_, i) => make(`H-${i}`, "backlog")); + const wip = Array.from({ length: counts.wip }, (_, i) => make(`W-${i}`, "building")); + return { + getSettings: vi.fn().mockResolvedValue({ backlogPressureRatioThreshold: 2, backlogPressureMinTodoCount: 3 }), + listWorkflowDefinitions: vi.fn(async () => [{ ir: RENAMED_IR }]), + listTasks: vi.fn(async (options?: { column?: string }) => { + if (options?.column === "backlog") return hold; + if (options?.column === "building") return wip; + /* The legacy ids are still queried and correctly return nothing on this board. */ + return []; + }), + getInsightStore: vi.fn(() => ({ upsertInsight: vi.fn(), listInsights: vi.fn(async () => []) })), + logEntry: vi.fn().mockResolvedValue(undefined), + } as unknown as TaskStore; + } + + it("alerts on a RENAMED board that is genuinely under pressure", async () => { + // Pre-fix: both reads asked for todo/in-progress, got nothing, and the ratio was 0/0. + const reporter = new BacklogPressureReporter({ + store: renamedStore({ hold: 10, wip: 1 }), + projectId: "p1", + logger: laneLogger, + }); + + const result = await reporter.report(); + + expect(result.alerted).toBe(true); + }); + + it("still stays quiet when the renamed board is genuinely under threshold", async () => { + // The alert must remain conditional — firing always would be its own bug. + const reporter = new BacklogPressureReporter({ + store: renamedStore({ hold: 1, wip: 5 }), + projectId: "p1", + logger: laneLogger, + }); + + expect((await reporter.report()).alerted).toBe(false); + }); +}); diff --git a/packages/engine/src/__tests__/executor-resume-query-lanes.test.ts b/packages/engine/src/__tests__/executor-resume-query-lanes.test.ts new file mode 100644 index 0000000000..3ff7fe689e --- /dev/null +++ b/packages/engine/src/__tests__/executor-resume-query-lanes.test.ts @@ -0,0 +1,44 @@ +/* +FNXC:WorkflowLifecycleColumns 2026-08-01-01:10: + +THE INVARIANT: both resume sweeps read the board's OWN wip lane. + +THE QUERY, not a comparison. `executor.ts` had already been driven to a low comparison count, and +these two reads were still keyed on `"in-progress"` by name. `listTasks`' `column` option filters in +the STORE, so on a renamed board both returned an EMPTY array and neither resume ran: + + - `resumeTaskForAgent` — a durable agent coming back up adopted nothing, leaving its in-flight task + orphaned; + - `resumeOrphaned` — the engine-wide sweep found no orphans to re-dispatch after a restart. + +Both are RECOVERY paths, which is the expensive place to be silently inert: the failure surfaces only +after a crash or restart, when the operator is already looking at something else and has every reason +to blame the crash rather than the recovery. + +WHY A SEPARATE FILE: the sibling `executor-resume-lanes-resolved.test.ts` mocks `node:fs`, so a source +read there fails with "No readFileSync export is defined on the node:fs mock" — a module mock in one +file is not a property of the module under test, and splitting is cheaper than partial-mocking around it. + +STRUCTURAL, and labelled. Driving `resumeOrphaned` end to end needs a live agent registry, worktree +probing and dispatch; the three suites that already exercise it (`restart.integration`, +`executor-soft-delete-guard`, `executor-prompt` — 153 cases) cover the BEHAVIOUR and all stay green. +What was missing is that the read asks for resolved lanes at all, and that is what this pins. + +REVERT PROOF, measured: restore either literal read and this fails. +*/ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const source = readFileSync(new URL("../executor.ts", import.meta.url), "utf8"); + +describe("the resume sweeps read the resolved wip lane", () => { + it("resolves project wip columns instead of querying the literal", () => { + expect(source).toContain('resolveProjectColumnsForRoles(this.store, ["countsTowardWip"])'); + expect(source).not.toContain('listTasks({ slim: true, column: "in-progress" })'); + }); + + it("routes BOTH sweeps through the one helper", () => { + // A second copy of the read is how two sweeps drift apart later. + expect(source.split("await this.listWipLaneTasks()").length - 1).toBe(2); + }); +}); diff --git a/packages/engine/src/__tests__/restart-recovery-coordinator.test.ts b/packages/engine/src/__tests__/restart-recovery-coordinator.test.ts index ce30ff630f..c3094ca6b8 100644 --- a/packages/engine/src/__tests__/restart-recovery-coordinator.test.ts +++ b/packages/engine/src/__tests__/restart-recovery-coordinator.test.ts @@ -195,3 +195,75 @@ describe("RestartRecoveryCoordinator", () => { expect(executor.resumeOrphaned).toHaveBeenCalledTimes(1); }); }); + +/* +FNXC:WorkflowLifecycleColumns 2026-07-31-23:20: + +THE INVARIANT: restart recovery sweeps the board's OWN wip lane. + +THE FLAGGED QUERY, NOW CONVERTED. The note this replaces was correct that the `listTasks({ column })` +QUERY was the live filter and the `.filter` beneath it a redundant re-assertion — so converting the +predicate alone would have dropped a census count and changed nothing, because the board's wip rows +were never listed. On a renamed board this recovery did not run at all: an engine restart left +interrupted tasks stuck with no requeue. + +THREE LAYERS, and naming them is the point, because the previous two conversions in this class each +hid a second one behind the first: + + 1. the QUERY — fixed here, project-level (`resolveProjectColumnsForRoles`), since no task is in hand + before the read; + 2. the redundant `.filter` — DELETED rather than converted; re-asserting the column the query just + selected on adds nothing, and a second copy of a rule is how a read and its filter drift; + 3. the move DESTINATION — already resolved via `resolveReboundTargetForTask`; only its comment was + stale, still describing the pre-fix state, and is corrected in place. + +REVERT PROOF, measured: restore `listTasks({ column: "in-progress" })` and the renamed case requeues +nothing. +*/ +describe("restart recovery resolves the board's own wip lane", () => { + const RENAMED_IR = { + version: "v2", id: "wf-renamed", name: "renamed", nodes: [], edges: [], + columns: [ + { id: "backlog", name: "Backlog", traits: [{ trait: "intake" }, { trait: "hold" }] }, + { id: "building", name: "Building", traits: [{ trait: "wip", config: { limitSetting: "maxConcurrent" } }] }, + ], + }; + + function renamedStore(tasksByColumn: Record) { + const selection = { workflowId: "wf-renamed", stepIds: [] as string[] }; + return { + listWorkflowDefinitions: vi.fn(async () => [{ ir: RENAMED_IR }]), + getTaskWorkflowSelection: () => selection, + getTaskWorkflowSelectionAsync: async () => selection, + getWorkflowDefinition: async () => ({ ir: RENAMED_IR }), + listTasks: vi.fn(async ({ column }: { column: string }) => tasksByColumn[column] ?? []), + updateTask: vi.fn().mockResolvedValue({}), + logEntry: vi.fn().mockResolvedValue(undefined), + moveTask: vi.fn().mockResolvedValue(undefined), + } as unknown as TaskStore; + } + + const interrupted = (id: string, column: string) => + createTask({ id, column, status: "failed", error: "Agent finished without calling fn_task_done", steps: [] } as never); + + it("requeues an interrupted task sitting in a RENAMED wip lane", async () => { + // Pre-fix: the query asked for "in-progress", got nothing, and the restart recovery no-opped. + const store = renamedStore({ building: [interrupted("FN-1", "building")] }); + const coordinator = new RestartRecoveryCoordinator(store, { resumeOrphaned: vi.fn().mockResolvedValue(undefined) } as never); + + await coordinator.recoverInterruptedRuns(); + + expect(store.updateTask).toHaveBeenCalledWith("FN-1", expect.objectContaining({ status: "stuck-killed" })); + }); + + it("still skips a PAUSED task — the only thing the deleted filter contributed", async () => { + // Removing the redundant column re-assertion must not remove the pause guard with it. + const paused = { ...interrupted("FN-2", "building"), paused: true }; + const store = renamedStore({ building: [paused] }); + const coordinator = new RestartRecoveryCoordinator(store, { resumeOrphaned: vi.fn().mockResolvedValue(undefined) } as never); + + await coordinator.recoverInterruptedRuns(); + + expect(store.updateTask).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/engine/src/__tests__/stale-task-reporter.test.ts b/packages/engine/src/__tests__/stale-task-reporter.test.ts index 9e565c7e8a..2847fb19e4 100644 --- a/packages/engine/src/__tests__/stale-task-reporter.test.ts +++ b/packages/engine/src/__tests__/stale-task-reporter.test.ts @@ -86,3 +86,105 @@ describe("StaleTaskReporter", () => { expect(store.listTasks).not.toHaveBeenCalled(); }); }); + +/* +FNXC:WorkflowLifecycleColumns 2026-07-31-22:20: + +THE INVARIANT: the stale-task sweep reads the board's OWN wip and review lanes. + +THE QUERY, NOT A COMPARISON — and this file's census count is **ZERO**. It contains no lifecycle +comparison at all, so it has never appeared in the backlog, in any per-file list, or in any "N → 0" +claim. It was nonetheless completely inert on a custom board: `listTasks({ column })` filters in the +store, both reads returned empty, and the reporter surfaced nothing — on exactly the board where work +is most likely to be sitting unnoticed. + +Second demonstration of the class after `backlog-pressure-reporter`, and the pattern is deliberately +identical: resolve the roles, iterate the set, dedupe by id. #2800 measured 49 more of these in +`self-healing.ts` alone. + +REVERT PROOF, measured: restore `listTasks({ column: "in-progress" })` and the renamed case surfaces +zero stale tasks instead of one. +*/ +describe("stale-task reporting resolves the board's own lanes", () => { + const RENAMED_IR = { + version: "v2", id: "wf-renamed", name: "renamed", nodes: [], edges: [], + columns: [ + { id: "building", name: "Building", traits: [{ trait: "wip", config: { limitSetting: "maxConcurrent" } }] }, + { id: "signoff", name: "Sign-off", traits: [{ trait: "merge" }] }, + ], + }; + + /* + Thresholds are `staleInProgressWarningMs` / `staleInProgressCriticalMs` — my first draft invented + `staleInProgressHours`, so `hasAnyThreshold` was false and `report()` returned early with 0 before + reaching the query at all. The cases failed on a fixture I guessed rather than read; that is the + fourth time this sweep, and the rule stands: read the factory and the settings shape first. + */ + const NOW = Date.parse("2026-05-14T08:00:00.000Z"); + + function renamedStore(tasksByColumn: Record): TaskStore { + return { + getSettings: vi.fn().mockResolvedValue({ + staleInProgressWarningMs: 4 * 60 * 60_000, + staleInProgressCriticalMs: 24 * 60 * 60_000, + staleInReviewWarningMs: 4 * 60 * 60_000, + staleInReviewCriticalMs: 24 * 60 * 60_000, + }), + listWorkflowDefinitions: vi.fn(async () => [{ ir: RENAMED_IR }]), + /* The per-task resolver reads the SELECTION, not the definition list — the two halves of this + fix need different store surfaces, and omitting these made the second half silently fall back + to the legacy pair while the query half already worked. */ + getTaskWorkflowSelection: () => ({ workflowId: "wf-renamed", stepIds: [] }), + getTaskWorkflowSelectionAsync: async () => ({ workflowId: "wf-renamed", stepIds: [] }), + getWorkflowDefinition: async () => ({ ir: RENAMED_IR }), + listTasks: vi.fn(async ({ column }: { column: string }) => tasksByColumn[column] ?? []), + logEntry: vi.fn().mockResolvedValue(undefined), + } as unknown as TaskStore; + } + + /* This file's factory is `createTask(overrides)`, not `makeTask(id)` — my first draft invented the + latter and the cases failed on a missing symbol rather than on behaviour. */ + const staleCard = (id: string, column: string): Task => + createTask({ + id, + column, + columnMovedAt: new Date(NOW - 5 * 60 * 60_000).toISOString(), + updatedAt: new Date(NOW - 5 * 60 * 60_000).toISOString(), + }); + + it("surfaces a stale card sitting in a RENAMED wip lane", async () => { + // Pre-fix: the query asked for "in-progress", got nothing, and the sweep surfaced zero. + const store = renamedStore({ building: [staleCard("FN-1", "building")] }); + const reporter = new StaleTaskReporter({ store, now: () => NOW }); + + const result = await reporter.report(); + + expect(result.surfaced).toBeGreaterThan(0); + }); + + it("keeps surfacing legacy-board cards when no workflow resolves", async () => { + /* + My first version of this case asserted that a card in `in-progress` is surfaced on the RENAMED + board, on the theory that the query unions the legacy ids. The query does — but the per-task + signal then correctly REFUSES it, because that card's own workflow does not call `in-progress` a + wip lane. The product was right and my premise was wrong. + + What the union actually buys is that the row is FETCHED at all; whether it is stale is then the + per-task question. So the honest legacy case is a store with no workflow selection, where both + halves fall back together — which is the compatibility guarantee that actually matters. + */ + const store = { + getSettings: vi.fn().mockResolvedValue({ + staleInProgressWarningMs: 4 * 60 * 60_000, + staleInProgressCriticalMs: 24 * 60 * 60_000, + }), + listWorkflowDefinitions: vi.fn(async () => []), + listTasks: vi.fn(async ({ column }: { column: string }) => + (column === "in-progress" ? [staleCard("FN-2", "in-progress")] : [])), + logEntry: vi.fn().mockResolvedValue(undefined), + } as unknown as TaskStore; + const reporter = new StaleTaskReporter({ store, now: () => NOW }); + + expect((await reporter.report()).surfaced).toBeGreaterThan(0); + }); +}); diff --git a/packages/engine/src/backlog-pressure-reporter.ts b/packages/engine/src/backlog-pressure-reporter.ts index f8a2524514..78a7fcff64 100644 --- a/packages/engine/src/backlog-pressure-reporter.ts +++ b/packages/engine/src/backlog-pressure-reporter.ts @@ -1,4 +1,4 @@ -import { computeInsightFingerprint, type Task, type TaskPriority, type TaskStore } from "@fusion/core"; +import { computeInsightFingerprint, resolveProjectColumnsForRoles, type Task, type TaskPriority, type TaskStore } from "@fusion/core"; import { createLogger } from "./logger.js"; const reporterLog = createLogger("backlog-pressure"); @@ -56,9 +56,33 @@ export class BacklogPressureReporter { return { alerted: false, reason: "invalid-config" }; } + /* + FNXC:WorkflowLifecycleColumns 2026-07-31-20:10: + THE QUERY, not the comparison — this reporter had no comparison to convert at all. + + `listTasks({ column })` filters in the store, so on a board whose lanes are renamed both reads + return EMPTY and the ratio is computed as 0/0: the backlog-pressure alert never fires, on a + board that may be under exactly the pressure it exists to report. Nothing errors, and the + census never pointed here because a query filter is not a comparison. + + Resolved through `resolveProjectColumnsForRoles`, which answers the PROJECT-level question a + read needs (there is no task in hand yet to resolve from) and always unions the legacy id, so a + board mid-rename still counts rows stored under the old one. + */ + const [holdColumns, wipColumns] = await Promise.all([ + resolveProjectColumnsForRoles(this.store, ["hold"]), + resolveProjectColumnsForRoles(this.store, ["countsTowardWip"]), + ]); + const listByColumns = async (columns: ReadonlySet, slim: boolean): Promise => { + const byId = new Map(); + for (const column of columns) { + for (const task of await this.store.listTasks({ column, slim })) byId.set(task.id, task); + } + return [...byId.values()]; + }; const [todoSlim, inProgressSlim] = await Promise.all([ - this.store.listTasks({ column: "todo", slim: true }), - this.store.listTasks({ column: "in-progress", slim: true }), + listByColumns(holdColumns, true), + listByColumns(wipColumns, true), ]); const todoCount = todoSlim.length; @@ -69,7 +93,7 @@ export class BacklogPressureReporter { } const [todoFull, allTasks] = await Promise.all([ - this.store.listTasks({ column: "todo" }), + listByColumns(holdColumns, false), this.store.listTasks({ slim: true, includeArchived: true }), ]); const byId = new Map(allTasks.map((task) => [task.id, task])); diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index 836931a234..1728f5352b 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -16,7 +16,7 @@ import type { TaskStore, Task, TaskDetail, TaskTokenUsage, StepStatus, Settings, import { getUnmetSchedulingDependencies } from "./scheduler.js"; import type { ImplementationExit, ImplementationExitReporter } from "./executor/implementation-exit.js"; import { emitWorkflowLifecycleEvent } from "@fusion/core"; -import { resolveWipTargetForTask, resolveTerminalColumns, RetryStormError, serializeRetryStormError, evaluateCompletedPromotionFailureProvenance, evaluateSkipBypassTaint, resolveWorkflowIrForTask, columnsWithFlag, evaluateForeachMergeProof, resolveCompleteColumn, resolveMergeOrchestrationColumn, resolveReboundTarget, resolveLifecycleColumns, resolveColumnAgentBinding, resolveEffectiveAgent, instanceNodeId, getWorkflowExtensionRegistry, getBuiltinWorkflow, parseNoOpCompletionMarker, allowsAutoMergeProcessing, resolveEffectiveAutoMerge, isLiveSharedBranchGroupMemberIntegration, resolveMaxAutoMergeRetries, resolveMaxConsecutiveToolFailureRetries, resolveConsecutiveToolFailureRetryBackoffMs, resolveConsecutiveToolFailureThreshold, resolveExecutorEscalationTarget, resolveOptionalStepRevisionBudget, resolveOptionalReviewRevisionBudget, DEFAULT_MAX_POST_REVIEW_FIXES, COMPLETION_SUMMARY_NODE_ID, upsertWorkflowStepResult, AWAITING_APPROVAL_PAUSE_REASON, THINKING_LEVELS, ACTIVE_WORKFLOW_WORK_ITEM_STATES, AgentStore, resolveExecutorFallbackModel, resolveValidatorFallbackModel } from "@fusion/core"; +import { resolveProjectColumnsForRoles, resolveWipTargetForTask, resolveTerminalColumns, RetryStormError, serializeRetryStormError, evaluateCompletedPromotionFailureProvenance, evaluateSkipBypassTaint, resolveWorkflowIrForTask, columnsWithFlag, evaluateForeachMergeProof, resolveCompleteColumn, resolveMergeOrchestrationColumn, resolveReboundTarget, resolveLifecycleColumns, resolveColumnAgentBinding, resolveEffectiveAgent, instanceNodeId, getWorkflowExtensionRegistry, getBuiltinWorkflow, parseNoOpCompletionMarker, allowsAutoMergeProcessing, resolveEffectiveAutoMerge, isLiveSharedBranchGroupMemberIntegration, resolveMaxAutoMergeRetries, resolveMaxConsecutiveToolFailureRetries, resolveConsecutiveToolFailureRetryBackoffMs, resolveConsecutiveToolFailureThreshold, resolveExecutorEscalationTarget, resolveOptionalStepRevisionBudget, resolveOptionalReviewRevisionBudget, DEFAULT_MAX_POST_REVIEW_FIXES, COMPLETION_SUMMARY_NODE_ID, upsertWorkflowStepResult, AWAITING_APPROVAL_PAUSE_REASON, THINKING_LEVELS, ACTIVE_WORKFLOW_WORK_ITEM_STATES, AgentStore, resolveExecutorFallbackModel, resolveValidatorFallbackModel } from "@fusion/core"; import { finalizeProvenAutoMergeTask } from "./auto-merge-finalization.js"; import { mergeEffectiveSettings } from "./effective-settings.js"; import { generateFeatureVideo, type GenerateFeatureVideoOptions } from "./review-artifacts/feature-video.js"; @@ -5810,10 +5810,37 @@ export class TaskExecutor { * best-effort (failure → skip, never strands resume). * A task re-dispatched by pass 1 is not re-dispatched by pass 2 (dedupe set). */ + /* + FNXC:WorkflowLifecycleColumns 2026-08-01-01:10: + The wip-lane read for the two resume sweeps, resolved at PROJECT level. + + `listTasks`' `column` option filters in the store, so both sweeps returned an EMPTY array on a + renamed board and neither resume ran: + + - `resumeTaskForAgent` — a durable agent coming back up adopted nothing, so its in-flight task + stayed orphaned; + - `resumeOrphaned` — the engine-wide sweep found no orphans to re-dispatch after a restart. + + Both are recovery paths, which is the expensive place to be silently inert: the failure only shows + up after a crash or a restart, when the operator is already looking at something else. The census + cannot see either — it scores comparisons, and a query filter is not one. + + Project-level because a read has no task in hand, legacy ids unioned so a board mid-rename still + finds rows under the old one, deduped by id because one column can carry two roles. + */ + private async listWipLaneTasks(): Promise { + const columns = await resolveProjectColumnsForRoles(this.store, ["countsTowardWip"]); + const byId = new Map(); + for (const column of columns) { + for (const task of await this.store.listTasks({ slim: true, column })) byId.set(task.id, task as Task); + } + return [...byId.values()]; + } + async resumeTaskForAgent(agentId: string): Promise { const settings = await this.store.getSettings(); if (settings.globalPause || settings.enginePaused) return; - const tasks = await this.store.listTasks({ slim: true, column: "in-progress" }); + const tasks = await this.listWipLaneTasks(); const dispatched = new Set(); const isDispatchable = (task: Task): boolean => !task.deletedAt @@ -5919,7 +5946,7 @@ export class TaskExecutor { return; } - const tasks = await this.store.listTasks({ slim: true, column: "in-progress" }); + const tasks = await this.listWipLaneTasks(); const inProgress = tasks.filter( (t) => t.column === "in-progress" && !t.deletedAt && !this.executing.has(t.id) && !t.paused, ); diff --git a/packages/engine/src/project-engine.ts b/packages/engine/src/project-engine.ts index 4d88eb77d9..786b52a8d0 100644 --- a/packages/engine/src/project-engine.ts +++ b/packages/engine/src/project-engine.ts @@ -18,6 +18,8 @@ import type { PlannerOversightStage, } from "@fusion/core"; import { + resolveProjectColumnsForRoles, + REVIEW_ROLES, allowsAutoMergeProcessing, compareTasksByPriorityThenAgeAndId, emitOverseerConfirmation, @@ -2848,7 +2850,7 @@ export class ProjectEngine { this.legacyAutoMergeStampAdvisoryEmitted = true; try { - const candidates = (await store.listTasks({ column: "in-review" })) + const candidates = (await this.listTasksInLaneRoles(store, REVIEW_ROLES)) .filter((task) => task.autoMerge === true && task.autoMergeProvenance !== "user"); if (candidates.length === 0) { return; @@ -2956,8 +2958,8 @@ export class ProjectEngine { const overseer = this.plannerOverseer; try { const [inProgress, inReview] = await Promise.all([ - store.listTasks({ column: "in-progress" }).catch(() => [] as Task[]), - store.listTasks({ column: "in-review" }).catch(() => [] as Task[]), + this.listTasksInLaneRoles(store, ["countsTowardWip"]).catch(() => [] as Task[]), + this.listTasksInLaneRoles(store, REVIEW_ROLES).catch(() => [] as Task[]), ]); const inFlight = [...inProgress, ...inReview]; const inFlightIds = new Set(inFlight.map((t) => t.id)); @@ -5063,8 +5065,39 @@ export class ProjectEngine { * Clear crash-leftover merging statuses so manual merge is unblocked. * Unconditional (not gated on autoMerge). Safe to run on the critical path. */ + /* + FNXC:WorkflowLifecycleColumns 2026-08-01-00:20: + ONE lane-aware read for this class's six `listTasks({ column: "" })` sites. + + `listTasks`' `column` option filters in the STORE, so on a board whose lanes are renamed each of + those reads returned an EMPTY array and the machinery behind it did nothing. The census cannot see + any of them — it scores comparisons, and a query filter is not a comparison — so this file reads as + fully converted (0 column guards) while the whole auto-merge path was inert on a custom board: + + - `clearStaleMergingStatuses` never cleared a crash-leftover `merging` status, so MANUAL MERGE + stayed blocked after an engine crash — the one that costs an operator directly; + - the three `enqueueEligibleInReviewTasks` feeds never enqueued anything, so auto-merge never ran; + - the legacy auto-merge stamp advisory never warned; + - the planner overseer never saw an in-progress or in-review card. + + Project-level resolution, because a read has no task in hand to resolve from, and the legacy ids are + always unioned so a board mid-rename still finds rows stored under the old ones. Deduped by id + because one column can carry two roles. + */ + private async listTasksInLaneRoles( + store: TaskStore, + roles: Parameters[1], + ): Promise { + const columns = await resolveProjectColumnsForRoles(store, roles); + const byId = new Map(); + for (const column of columns) { + for (const task of await store.listTasks({ column })) byId.set(task.id, task as Task); + } + return [...byId.values()]; + } + private async clearStaleMergingStatuses(store: TaskStore): Promise { - const tasks = await store.listTasks({ column: "in-review" }); + const tasks = await this.listTasksInLaneRoles(store, REVIEW_ROLES); // No merge is actually running at startup, so any task still marked // as merging is a leftover from a previous engine lifecycle. const staleStatuses = new Set(["merging", "merging-pr"]); @@ -5091,7 +5124,7 @@ export class ProjectEngine { runtimeLog.log("Auto-merge startup enqueue skipped: pause active"); return; } - const tasks = await store.listTasks({ column: "in-review" }); + const tasks = await this.listTasksInLaneRoles(store, REVIEW_ROLES); if (this.shuttingDown) return; const enqueued = await this.enqueueEligibleInReviewTasks(tasks as Task[], settings); if (enqueued > 0) { @@ -5163,7 +5196,7 @@ export class ProjectEngine { try { const settings = await store.getSettings(); if (!settings.globalPause && !settings.enginePaused) { - const tasks = await store.listTasks({ column: "in-review" }); + const tasks = await this.listTasksInLaneRoles(store, REVIEW_ROLES); await this.enqueueEligibleInReviewTasks(tasks as Task[], settings); } } catch (err: unknown) { @@ -5243,7 +5276,7 @@ export class ProjectEngine { } try { - const tasks = await store.listTasks({ column: "in-review" }); + const tasks = await this.listTasksInLaneRoles(store, REVIEW_ROLES); await this.enqueueEligibleInReviewTasks(tasks as Task[], settings); } catch (err: unknown) { runtimeLog.warn( diff --git a/packages/engine/src/restart-recovery-coordinator.ts b/packages/engine/src/restart-recovery-coordinator.ts index ca9fb9c375..79ca6aee62 100644 --- a/packages/engine/src/restart-recovery-coordinator.ts +++ b/packages/engine/src/restart-recovery-coordinator.ts @@ -1,5 +1,5 @@ import type { Task, TaskStore } from "@fusion/core"; -import { resolveReboundTargetForTask } from "@fusion/core"; +import { resolveProjectColumnsForRoles, resolveReboundTargetForTask } from "@fusion/core"; import type { TaskExecutor } from "./executor.js"; import { createLogger } from "./logger.js"; import { setImmediate as setImmediateCb } from "node:timers"; @@ -156,18 +156,29 @@ export class RestartRecoveryCoordinator { async recoverInterruptedRuns(): Promise { /* - FNXC:WorkflowLifecycleColumns 2026-08-02-18:30 (fleet — FLAGGED as the QUERY class, not converted): - The live filter here is the `listTasks({ column: "in-progress" })` QUERY, not the `.filter` below it: the - query has already restricted the rows, so the predicate is a redundant re-assertion of the same literal. - Converting the filter alone would drop the census count by one and change nothing an operator sees — the - board's wip-lane rows still would not be listed, because the QUERY never asked for them. + FNXC:WorkflowLifecycleColumns 2026-07-31-23:20 (the FLAGGED query, now converted): + The note this replaces was right that the QUERY was the live filter and the `.filter` below it a + redundant re-assertion — so converting the predicate alone would have dropped a census count and + changed nothing an operator sees, because the board's wip rows were never listed. - Query filters are the class the census tracks separately, and fixing them needs a project-level lane - resolution before the read (there is no task to resolve from yet). Same shape as `executor.ts`'s - in-progress sweep and `server.ts`'s reliability counts, both flagged in earlier fleet PRs. + Fixing it needs a PROJECT-level answer: there is no task in hand before the read. + `resolveProjectColumnsForRoles` unions every wip-bearing column any workflow declares with the + legacy id, so a renamed board is swept and a board mid-rename still finds rows under the old one. + + THE REDUNDANT FILTER IS GONE rather than converted. Re-asserting the column the query just + selected on adds nothing, and a second copy of the same rule is how a read and its filter drift — + the `paused` check is the only thing that predicate contributed. + + The move DESTINATION below was already resolved (`resolveReboundTargetForTask`); its comment still + described the pre-fix state and is corrected there. Naming all three layers because the previous + two conversions in this class each hid a second one behind the first. */ - const allInProgress = await this.store.listTasks({ slim: true, column: "in-progress" }); - const candidates = allInProgress.filter((task) => task.column === "in-progress" && !task.paused); + const wipColumns = await resolveProjectColumnsForRoles(this.store, ["countsTowardWip"]); + const byId = new Map(); + for (const column of wipColumns) { + for (const task of await this.store.listTasks({ slim: true, column })) byId.set(task.id, task); + } + const candidates = [...byId.values()].filter((task) => !task.paused); if (candidates.length === 0) return; @@ -202,7 +213,15 @@ export class RestartRecoveryCoordinator { task.id, "Restart recovery: interrupted run had no step progress and no fn_task_done — requeued to todo for safe retry", ); - /* FNXC:WorkflowResolvedColumns 2026-07-30-20:50: census-invisible moveTask DESTINATION — a call argument, not a comparison. This requeue is not a #1411 `recoveryRehome` escape, so on a board that does not declare `todo` the move is REJECTED and the recovery it belongs to never completes. */ + /* + FNXC:WorkflowResolvedColumns 2026-07-30-20:50 / corrected 2026-07-31-23:20: + A census-invisible moveTask DESTINATION — a call argument, not a comparison. It is RESOLVED + (`resolveReboundTargetForTask`), so the original warning below no longer applies; the comment was + describing the pre-fix state long after the fix landed. Left in place, corrected, because the + reason it matters is still true: this requeue is not a #1411 `recoveryRehome` escape, so a + hardcoded destination would be REJECTED on a board that does not declare it and the recovery + would never complete. + */ await this.store.moveTask(task.id, await resolveReboundTargetForTask(this.store, task.id)); } } diff --git a/packages/engine/src/scheduler.ts b/packages/engine/src/scheduler.ts index 4ed8fde6ae..ef6209c594 100644 --- a/packages/engine/src/scheduler.ts +++ b/packages/engine/src/scheduler.ts @@ -40,7 +40,7 @@ import { StaleTaskReporter } from "./stale-task-reporter.js"; import { BacklogPressureReporter } from "./backlog-pressure-reporter.js"; import { UnlinkedMissionsAdvisoryReporter } from "./unlinked-missions-advisory-reporter.js"; import { createRunAuditor, generateSyntheticRunId } from "./run-audit.js"; -import { resolveWorkflowIrForTask, resolveWorkflowIrById, resolveColumnFlags, resolveWorktreeCapacityLimit, resolveLifecycleColumns, isWipColumnRole, isReviewColumnRole, isCompleteColumnRole, columnsWithFlag } from "@fusion/core"; +import { resolveProjectColumnsForRoles, resolveWorkflowIrForTask, resolveWorkflowIrById, resolveColumnFlags, resolveWorktreeCapacityLimit, resolveLifecycleColumns, isWipColumnRole, isReviewColumnRole, isCompleteColumnRole, columnsWithFlag } from "@fusion/core"; import type { ColumnRoleTraitFlags } from "@fusion/core"; import type { WorkflowIr, WorkflowIrV2 } from "@fusion/core"; import { runHoldReleaseSweep, isUnplannedForExecution, type SlotReservation } from "./hold-release.js"; @@ -1206,8 +1206,24 @@ export class Scheduler { } const deletedParked = resolveTaskParkedColumnsSync(this.store, task.id); + /* + FNXC:WorkflowLifecycleColumns 2026-08-01-05:00: + A HALF-CONVERTED PAIR, one line apart. The hold read above already resolved its lane while + the wip read below stayed on the literal, so on a renamed board this dependent sweep saw + the queued cards and none of the running ones — a dependency held by an in-flight task was + never reconciled when that task was deleted. + + Two reads of the same board, one resolved and one not, is the shape this program keeps + finding; that they are adjacent is what makes it easy to miss in review rather than easy to + catch. + */ + const deletedWipColumns = await resolveProjectColumnsForRoles(this.store, ["countsTowardWip"]); const todoTasks = await this.store.listTasks({ column: deletedParked.hold, slim: true }); - const inProgressTasks = await this.store.listTasks({ column: "in-progress", slim: true }); + const inProgressById = new Map(); + for (const column of deletedWipColumns) { + for (const task of await this.store.listTasks({ column, slim: true })) inProgressById.set(task.id, task); + } + const inProgressTasks = [...inProgressById.values()]; const dependents = [...todoTasks, ...inProgressTasks]; /* One IR cache for the whole reconciliation, per the caller-owned-cache contract. */ const deletedDependencyIrCache = new Map(); diff --git a/packages/engine/src/stale-task-reporter.ts b/packages/engine/src/stale-task-reporter.ts index 3917c71098..86a677aa14 100644 --- a/packages/engine/src/stale-task-reporter.ts +++ b/packages/engine/src/stale-task-reporter.ts @@ -1,5 +1,9 @@ import { getTaskAgeStalenessSignal, + resolveProjectColumnsForRoles, + resolveTaskLifecycleColumns, + REVIEW_ROLES, + type WorkflowIr, type Task, type TaskStore, type Settings, @@ -35,11 +39,40 @@ export class StaleTaskReporter { } const cycleStartMs = this.now(); + /* + FNXC:WorkflowLifecycleColumns 2026-07-31-22:20: + THE QUERY, not a comparison — this file's census count is ZERO and it was inert anyway. + + `listTasks({ column })` filters in the store, so on a board whose lanes are renamed both reads + return EMPTY and the reporter surfaces nothing: no stale-task signal is ever raised, on exactly + the board where work is most likely to be sitting unnoticed. Nothing errors, and no entry in the + lifecycle backlog points here, because a query filter is not a comparison. + + Resolved through `resolveProjectColumnsForRoles`, which answers the PROJECT-level question a read + needs — there is no task in hand yet — and always unions the legacy ids, so a board mid-rename + still surfaces rows stored under the old ones. + + Second demonstration of the class after `backlog-pressure-reporter`; the pattern is three lines + (resolve the roles, iterate the set, dedupe by id) and is deliberately identical between them. + */ + const [wipColumns, reviewColumns] = await Promise.all([ + resolveProjectColumnsForRoles(this.store, ["countsTowardWip"]), + resolveProjectColumnsForRoles(this.store, REVIEW_ROLES), + ]); + const listByColumns = async (columns: ReadonlySet): Promise => { + const byId = new Map(); + for (const column of columns) { + for (const task of await this.store.listTasks({ column, slim: false })) byId.set(task.id, task); + } + return [...byId.values()]; + }; const [inProgress, inReview] = await Promise.all([ - this.store.listTasks({ column: "in-progress", slim: false }), - this.store.listTasks({ column: "in-review", slim: false }), + listByColumns(wipColumns), + listByColumns(reviewColumns), ]); + /* One IR cache for the sweep, per the caller-owned-cache contract. */ + const staleIrCache = new Map(); let surfaced = 0; for (const task of [...inProgress, ...inReview]) { const updatedAtMs = Date.parse(task.updatedAt); @@ -49,7 +82,25 @@ export class StaleTaskReporter { let signal; try { - signal = getTaskAgeStalenessSignal(task, { now: cycleStartMs, thresholds }); + /* + FNXC:WorkflowLifecycleColumns 2026-07-31-22:40: + BOTH LAYERS, and the second only became visible once the first was fixed. + + `getTaskAgeStalenessSignal` takes an optional `lifecycle` and defaults to the legacy pair, so + a card the widened query now returns was still refused inside the signal — it answered + `undefined` for every renamed lane. Converting only the query would have moved the failure one + frame deeper and left `surfaced: 0` exactly as before; my test caught it precisely because it + asserts the OUTCOME rather than the query argument. + + Resolved per task, because a board spans workflows and this is a per-card question — the flat + project vocabulary above is correct for the READ and wrong for this. + */ + const lifecycle = await resolveTaskLifecycleColumns(this.store, task.id, staleIrCache); + signal = getTaskAgeStalenessSignal(task, { + now: cycleStartMs, + thresholds, + ...(lifecycle ? { lifecycle } : {}), + }); } catch (error) { if (error instanceof RangeError) { this.logger.warn(`Stale task reporter disabled by invalid thresholds: ${error.message}`); diff --git a/scripts/lib/lifecycle-column-census-baseline.json b/scripts/lib/lifecycle-column-census-baseline.json index 66e44a7221..654e99c891 100644 --- a/scripts/lib/lifecycle-column-census-baseline.json +++ b/scripts/lib/lifecycle-column-census-baseline.json @@ -6,8 +6,8 @@ "packages/core/src/task-store/async-comments-attachments.ts": 9, "packages/engine/src/executor.ts": 8, "packages/engine/src/notification/notification-service.ts": 5, - "packages/engine/src/restart-recovery-coordinator.ts": 5, "packages/engine/src/replan-target.ts": 4, + "packages/engine/src/restart-recovery-coordinator.ts": 4, "packages/core/src/async-mission-store-queries.ts": 3, "packages/core/src/task-store/task-artifacts-ops.ts": 3, "packages/dashboard/src/routes/register-task-workflow-routes.ts": 3, @@ -21,7 +21,6 @@ "packages/dashboard/app/utils/taskRevert.ts": 2, "packages/dashboard/src/github-tracking-state.ts": 2, "packages/engine/src/auto-merge-finalization.ts": 2, - "packages/core/src/eval-automation.ts": 1, "packages/core/src/eval-signal-collector.ts": 1, "packages/core/src/in-review-stall.ts": 1, "packages/core/src/mission-store.ts": 1, @@ -132,26 +131,17 @@ }, "queryByFile": { "packages/engine/src/self-healing.ts": 48, - "packages/engine/src/project-engine.ts": 7, - "packages/engine/src/backlog-pressure-reporter.ts": 3, "packages/core/src/task-store/async-persistence.ts": 2, "packages/core/src/task-store/merge-queue-ops.ts": 2, "packages/core/src/task-store/moves.ts": 2, - "packages/engine/src/executor.ts": 2, - "packages/engine/src/stale-task-reporter.ts": 2, "packages/cli/src/extension.ts": 1, "packages/core/src/async-mission-store.ts": 1, - "packages/core/src/eval-automation.ts": 1, - "packages/core/src/store.ts": 1, "packages/core/src/task-store/archive-lifecycle-2.ts": 1, "packages/core/src/task-store/async-archive-lineage.ts": 1, "packages/core/src/task-store/async-self-healing.ts": 1, - "packages/core/src/task-store/task-artifacts-ops.ts": 1, "packages/dashboard/src/routes/register-gitlab.ts": 1, "packages/engine/src/agent-tools.ts": 1, "packages/engine/src/auto-merge-finalization.ts": 1, - "packages/engine/src/restart-recovery-coordinator.ts": 1, - "packages/engine/src/scheduler.ts": 1, "packages/engine/src/workflow-node-handlers.ts": 1 } }