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:
gsxdsm
2026-07-30 13:55:10 -07:00
committed by GitHub
parent 1824c04584
commit 109204c590
23 changed files with 1072 additions and 51 deletions

View 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([]);
});
});

View File

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

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

View File

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

View File

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

View File

@@ -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,

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

View File

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

View File

@@ -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 [];

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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(

View File

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

View File

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

View File

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

View File

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