fix: the query class — three sweeps that never ran on a renamed board (#2818)
Three sweeps that **never ran at all** on a renamed board, plus the shared answer the rest of the class needs. Consolidated from three handoff branches so the helper appears once. #2811 merged, so this is my only open PR. `#2800` measured this class and shipped evidence deliberately without conversions: `listTasks({ column: "<literal>" })` filters in the store, so on a renamed board the read returns an **empty array** and the sweep it feeds does nothing. The census scores the comparison *inside* the loop, never the query above it. ## What was broken | file | census count | what actually happened on a renamed board | |---|---|---| | `backlog-pressure-reporter.ts` | **0** | both reads empty, ratio computed as 0/0 — **the alert never fired**, on a board that may be under exactly the pressure it reports | | `stale-task-reporter.ts` | **0** | both reads empty — **no stale-task signal ever raised**, where work is most likely sitting unnoticed | | `restart-recovery-coordinator.ts` | flagged | sweep never ran — **an engine restart left interrupted tasks stuck with no requeue** | Two of the three have a census count of **zero**. They contain no lifecycle comparison at all, so they have never appeared in the backlog, in a per-file list, or in any "N → 0" claim — and were completely inert. **A file at zero is not evidence of anything.** ## The shared answer, and what it is not Every existing resolver answers a **per-task** question. A query has no task in hand, so it needs the project-level one: every column any workflow declares for a role, unioned with the legacy ids so a board mid-rename still finds rows under the old ones. The set is never empty, so a caller cannot accidentally query nothing. The header states what it is **not**: answering a per-card question from the union would mark a card as review because some *other* workflow calls its column review — the flat-set mistake this program has made four times. ## The finding that generalises: the query is rarely the whole defect `stale-task-reporter` **still reported zero after the query was fixed** — `getTaskAgeStalenessSignal` defaults to the legacy pair, so a card the query now returned was refused inside the signal. Converting only the query would have looked like a fix and changed nothing. That is a caveat on #2800's approach, offered as refinement rather than correction: **asserting the query ARGUMENT is right when pinning a known defect** (the outcome is 0 either way) **and insufficient when proving a fix**, because the outcome is the only thing that distinguishes a real conversion from a deeper one. All three conversions here assert outcomes. `restart-recovery` had three layers — query, a redundant re-assertion (deleted; a test pins the `paused` guard it did contribute), and a move destination that was **already** resolved but whose warning comment was stale. A stale warning is its own hazard: it told the next reader a defect existed where none did. ## Verification - helper **8 passed** · three reporter/coordinator suites **29 passed** - `pnpm test:gate` **161 / 13 / 487 / 71** · lint clean · `--strict` exits 0 · four `tsc` targets clean - each conversion revert-proven independently; the failing case is named in each test header ## Two mistakes worth recording **The helper's own test caught a bug in it.** My first draft wrapped the definition loop in one `try`, and `parseWorkflowIr` **validates** rather than parses — one malformed row would have returned legacy-only lanes for *every* workflow, indistinguishable from the bug it exists to fix. Now isolated per definition. **I clobbered the core barrel** by taking `index.ts` wholesale from a handoff branch, dropping two exports `main` had added since; three packages stopped compiling. Taking a file from another branch takes its whole contents, including what is now stale — for a barrel that is nearly always wrong. Re-applied as a single edit on top of `main`. ## Not included `self-healing.ts`'s 49 — actively owned and mid-conversion; an outside refactor there produces conflicting halves of one sweep. `project-engine.ts` (7) and `executor.ts` (2) need their own read of what each sweep does with the rows, which these three are the argument for. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
68
packages/core/src/__tests__/archive-all-done-lane.test.ts
Normal file
68
packages/core/src/__tests__/archive-all-done-lane.test.ts
Normal file
@@ -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<string, unknown[]>, 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([]);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
149
packages/core/src/__tests__/project-lane-vocabulary.test.ts
Normal file
149
packages/core/src/__tests__/project-lane-vocabulary.test.ts
Normal file
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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<string, Awaited<ReturnType<typeof params.store.listTasks>>[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,
|
||||
);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
126
packages/core/src/project-lane-vocabulary.ts
Normal file
126
packages/core/src/project-lane-vocabulary.ts
Normal file
@@ -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<ReadonlyArray<{ ir?: unknown }>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, readonly string[]> = {
|
||||
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<keyof TraitFlags & string>,
|
||||
): Promise<ReadonlySet<string>> {
|
||||
const columns = new Set<string>();
|
||||
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;
|
||||
@@ -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<TaskStoreEvents> {
|
||||
}
|
||||
|
||||
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<string, Task>();
|
||||
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;
|
||||
|
||||
@@ -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<Task[]> {
|
||||
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<string, Task>();
|
||||
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 [];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<AiSessionSummary> & Pick<AiSessionSummary, "id">): 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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -198,7 +198,22 @@ export function getCliActionDisabledReasonForBanner(session: AiSessionSummary, a
|
||||
export interface CliActionDeps {
|
||||
currentProjectId?: string;
|
||||
retryTask: (id: string) => Promise<unknown>;
|
||||
moveTask: (id: string, column: "todo") => Promise<unknown>;
|
||||
/*
|
||||
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<unknown>;
|
||||
/*
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown[]>) {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, Task[]>): 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string>, slim: boolean): Promise<Task[]> => {
|
||||
const byId = new Map<string, Task>();
|
||||
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]));
|
||||
|
||||
@@ -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<Task[]> {
|
||||
const columns = await resolveProjectColumnsForRoles(this.store, ["countsTowardWip"]);
|
||||
const byId = new Map<string, Task>();
|
||||
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<void> {
|
||||
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<string>();
|
||||
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,
|
||||
);
|
||||
|
||||
@@ -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: "<literal>" })` 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<typeof resolveProjectColumnsForRoles>[1],
|
||||
): Promise<Task[]> {
|
||||
const columns = await resolveProjectColumnsForRoles(store, roles);
|
||||
const byId = new Map<string, Task>();
|
||||
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<Task[]> {
|
||||
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(
|
||||
|
||||
@@ -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<void> {
|
||||
/*
|
||||
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<string, Task>();
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, Task>();
|
||||
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<string, WorkflowIr>();
|
||||
|
||||
@@ -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<string>): Promise<Task[]> => {
|
||||
const byId = new Map<string, Task>();
|
||||
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<string, WorkflowIr>();
|
||||
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}`);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user