Phase B slice B3.1 (U4): resolve the hold column in recoverStrandedCompletedTodoTasks — query and guard together (#2472)
Stacked on #2471 (Phase B slice B2). Base is `feature/workflow-vocabulary-b2` — do not merge before it. **First landable slice of U4 (self-healing.ts).** One sweep, one PR, per the phase's sub-split rule. ## The finding: the guard and the query must convert together `recoverStrandedCompletedTodoTasks` promotes a card whose steps are all done/skipped but which is still sitting in the hold column — finished work that never handed off to review. It decided *"is this card in the hold column?"* **twice**, and both were literal: | | was | |---|---| | the QUERY | `listTasks({ column: "todo", slim: true })` | | the GUARD | `task.column !== "todo"` | **Either half alone is a green diff with zero behavior change.** A correct guard behind a literal query never runs; a converted query behind a literal guard rejects every row it just fetched. This is the shape that made B1's stale-paused-todo fix cosmetic, and the phase brief predicted more of it here — correctly. I proved it rather than asserting it: - literal **QUERY** restored (converted guard kept) → **3 tests fail** - literal **GUARD** restored (converted query kept) → **2 tests fail** Neither half passes the suite alone. ## Falsification came first Per the brief I tried to prove the work unnecessary before doing it. It is necessary, and the evidence is empirical, not assumed: the 7 tests were written against unmodified code and 3 failed. Unlike B2's hold-release — which turned out already converted — **self-healing is uniformly unconverted at the query level**: 53 of its sweeps carry a hardcoded `column:` filter (survey in the worker report). ## Negative half, per the brief A completed card resting in a WIP or review column is **not** promoted. Dropping a column filter without a per-task hold check would promote finished cards out of every column — laundering work past review, a louder bug than the silent one being fixed. ## Test-harness hazard (will recur in every remaining U4 slice) The pre-existing self-healing store mock returns its fixture from `listTasks` **regardless of arguments**. A renamed-hold test on that harness passes while the query stays hardcoded, because the mock hands the sweep rows the real store never would. The new harness **honors** the column filter, and one test asserts the query is no longer scoped to the literal. This is documented in the new file's header for whoever writes the next slice. ## Cost The column filter is gone, so the cheap non-column rejections (paused / executing / incomplete steps / errored / no-commits / skip-bypass taint) run **first and synchronously**; only survivors pay an IR resolution, shared through an `irCache`. A board spanning three workflows resolves three IRs regardless of card count. `includeArchived: false` preserves what the column filter did implicitly. The hold column resolves **per task** — a board spans workflows, and a card in *another* workflow's hold column must not be promoted. ## One pre-existing assertion changed, deliberately `self-healing.test.ts` pinned `listTasks` being called with `{ column: "todo", slim: true }`. That query shape changed on purpose; the assertion now pins the new one. The behavioral assertions either side of it (one qualifying card, promoted exactly once) are untouched and still pass. ## Carried A3 questions — both answered **Q1 — does the sync/SQLite counter have the same pool-id mismatch?** **Not applicable: there is no sync counter.** `occupantsByColumnForWorkflowImpl` and `listWorkflowOccupantTaskIds` are async/PG-only and throw without an initialized `AsyncDataLayer`; the sync twin went with the PG cutover. There is no second counter that could mismatch. The surviving pool-id sentinel sites are `project-store-ops.ts:767/819` and `moves.ts` — both parked by operator decision, untouched here. **Q2 — are custom workflows with an explicit numeric limit affected?** **No, by design.** `resolveColumnCapacity` gives `config.limit` top precedence (`configLimit` → `limitSetting` → default-workflow read-through → `Infinity`), and `resolveWipBudgetColumns` documents that a column with an explicit numeric limit is **independent — its budget is itself alone**. Such a column never pools, so there is no pool id to mismatch. Read-only analysis; no code changed for either question. ## Remaining U4 scope (not in this PR) 214 literal occurrences across ~70 methods; **53 sweeps carry a query-level column filter**. Hold-gated sweeps still to convert: `clearStaleBlockedBy`, `reclaimSelfOwnedBranchConflicts`, `reconcileCompletedTask`, `recoverMergedReviewTasks`, `recoverStuckMergeDeadlocks`, plus non-query `todo` guards in `recoverPausedAbortFailures`, `reconcileDependencyBlockingLeases`, and others. `surfaceStalePausedTodos` was already converted (B1 follow-up) and is verified intact on this branch. ## Verification - 7 new tests green; **both mutations kill the suite** - self-healing suite: 415 passed, **1 failure pre-existing** (`archiveStaleDoneTasks` — confirmed identical by stashing my changes) - merge gate green (299 + 10 + 71) - `tsc --noEmit` clean, `pnpm lint` clean No changeset: `@fusion/engine` is private. 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Improved recovery of completed tasks stranded in workflow-specific hold columns, including renamed hold columns. * Preserved recovery for built-in workflows while correctly handling boards with mixed workflow configurations. * Prevented recovery for tasks in non-hold columns or with paused, incomplete, or errored states. * Added fallback handling when workflow details cannot be resolved. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
/*
|
||||
FNXC:WorkflowLifecycleColumns 2026-07-28-06:10 (Phase B / slice B3.1 — U4):
|
||||
|
||||
`recoverStrandedCompletedTodoTasks` promotes a card whose steps are all
|
||||
done/skipped but which is still sitting in the HOLD column — work that finished
|
||||
and never handed off to review. It decides "is this card in the hold column?"
|
||||
TWICE, and both were literal:
|
||||
|
||||
1. the QUERY — `listTasks({ column: "todo", slim: true })`
|
||||
2. the per-task GUARD — `task.column !== "todo"`
|
||||
|
||||
Both must convert together. Converting only the guard leaves a correct predicate
|
||||
the sweep never reaches, because the query already returned an empty list;
|
||||
converting only the query leaves the guard rejecting every row it just fetched.
|
||||
Either half alone is a green diff with zero behavior change — the exact shape
|
||||
that made B1's stale-paused-todo fix cosmetic.
|
||||
|
||||
TEST-HARNESS WARNING, load-bearing. The pre-existing `self-healing.test.ts` store
|
||||
mock returns its fixture from `listTasks` REGARDLESS of arguments. A renamed-hold
|
||||
test written on that harness passes while the query stays hardcoded, because the
|
||||
mock hands the sweep rows the real store never would. The mock below therefore
|
||||
HONORS `options.column`, and one test asserts the query is no longer scoped to
|
||||
the literal. Do not "simplify" this mock.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { Task, TaskStore, WorkflowIr } from "@fusion/core";
|
||||
|
||||
import { SelfHealingManager } from "../self-healing.js";
|
||||
|
||||
const WF = "custom:wf";
|
||||
|
||||
/** A card whose steps are all done — stranded, awaiting promotion to review. */
|
||||
function strandedTask(over: Partial<Task> = {}): Task {
|
||||
return {
|
||||
id: "FN-1",
|
||||
title: "t",
|
||||
description: "",
|
||||
column: "todo",
|
||||
paused: false,
|
||||
dependencies: [],
|
||||
steps: [{ name: "s1", status: "done" }],
|
||||
currentStep: 1,
|
||||
log: [],
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
columnMovedAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
...over,
|
||||
} as unknown as Task;
|
||||
}
|
||||
|
||||
function ir(holdId: string): WorkflowIr {
|
||||
return {
|
||||
version: "v2",
|
||||
id: WF,
|
||||
nodes: [],
|
||||
edges: [],
|
||||
columns: [
|
||||
{ id: holdId, label: "Hold", traits: [{ trait: "hold", config: { release: "capacity" } }] },
|
||||
{ id: "building", label: "Building", traits: [{ trait: "wip", config: { limitSetting: "maxConcurrent" } }] },
|
||||
{ id: "reviewing", label: "Reviewing", traits: [{ trait: "mergeOrchestration" }] },
|
||||
{ id: "shipped", label: "Shipped", traits: [{ trait: "complete" }] },
|
||||
],
|
||||
} as unknown as WorkflowIr;
|
||||
}
|
||||
|
||||
function harness(tasks: Task[], workflowIr: WorkflowIr | undefined) {
|
||||
const recovered: string[] = [];
|
||||
const selection = { workflowId: WF, stepIds: [] };
|
||||
const listTasks = vi.fn(async (opts?: { column?: string }) =>
|
||||
/* HONORS the column filter — see the file header. */
|
||||
opts?.column ? tasks.filter((t) => t.column === opts.column) : tasks,
|
||||
);
|
||||
const store = {
|
||||
listTasks,
|
||||
getTask: vi.fn(async (id: string) => tasks.find((t) => t.id === id) ?? null),
|
||||
getSettings: vi.fn(async () => ({})),
|
||||
logEntry: vi.fn(async () => undefined),
|
||||
recordRunAuditEvent: vi.fn(async () => undefined),
|
||||
getTaskWorkflowSelection: vi.fn(() => selection),
|
||||
getTaskWorkflowSelectionAsync: vi.fn(async () => selection),
|
||||
getWorkflowDefinition: vi.fn(async () => (workflowIr ? { ir: workflowIr } : null)),
|
||||
} as unknown as TaskStore;
|
||||
|
||||
const manager = new SelfHealingManager(store, {
|
||||
rootDir: "/tmp/test-project",
|
||||
recoverCompletedTask: async (task: Task) => {
|
||||
recovered.push(task.id);
|
||||
return true;
|
||||
},
|
||||
} as never);
|
||||
|
||||
return { manager, store, listTasks, recovered };
|
||||
}
|
||||
|
||||
describe("recoverStrandedCompletedTodoTasks under a renamed hold column", () => {
|
||||
it("recovers a stranded completed card resting in a RENAMED hold column", async () => {
|
||||
/* The whole point: this card finished its work and is stuck. Under the
|
||||
literal query+guard the sweep does not see it at all, so it sits in
|
||||
`drafting` forever with no error and no failing test. */
|
||||
const task = strandedTask({ id: "FN-R", column: "drafting" });
|
||||
const h = harness([task], ir("drafting"));
|
||||
|
||||
const count = await h.manager.recoverStrandedCompletedTodoTasks();
|
||||
|
||||
expect(count).toBe(1);
|
||||
expect(h.recovered).toEqual(["FN-R"]);
|
||||
});
|
||||
|
||||
it("does not scope its query to the literal todo column", async () => {
|
||||
/* Pins the QUERY half directly. A correct guard behind a `column: "todo"`
|
||||
query is a guard that never runs — the conversion would be cosmetic. */
|
||||
const task = strandedTask({ id: "FN-R", column: "drafting" });
|
||||
const h = harness([task], ir("drafting"));
|
||||
|
||||
await h.manager.recoverStrandedCompletedTodoTasks();
|
||||
|
||||
const columnArgs = h.listTasks.mock.calls.map(
|
||||
(call) => (call[0] as { column?: string } | undefined)?.column,
|
||||
);
|
||||
expect(columnArgs).not.toContain("todo");
|
||||
});
|
||||
|
||||
it("does NOT recover a completed card resting in a NON-hold column", async () => {
|
||||
/*
|
||||
The negative half the phase brief requires. Dropping the column filter
|
||||
without a per-task hold check would promote finished cards out of the WIP and
|
||||
review columns too — a louder bug than the silent one it replaces, and one
|
||||
that would launder work past review.
|
||||
*/
|
||||
const wip = strandedTask({ id: "FN-W", column: "building" });
|
||||
const review = strandedTask({ id: "FN-V", column: "reviewing" });
|
||||
const h = harness([wip, review], ir("drafting"));
|
||||
|
||||
const count = await h.manager.recoverStrandedCompletedTodoTasks();
|
||||
|
||||
expect(count).toBe(0);
|
||||
expect(h.recovered).toEqual([]);
|
||||
});
|
||||
|
||||
it("still recovers a builtin todo card (regression floor)", async () => {
|
||||
const task = strandedTask({ id: "FN-D", column: "todo" });
|
||||
const h = harness([task], ir("todo"));
|
||||
|
||||
expect(await h.manager.recoverStrandedCompletedTodoTasks()).toBe(1);
|
||||
expect(h.recovered).toEqual(["FN-D"]);
|
||||
});
|
||||
|
||||
it("falls back to the legacy todo column when the workflow cannot be resolved", async () => {
|
||||
/* Conservative: an unresolvable workflow must behave exactly as it did
|
||||
before this conversion rather than guessing. */
|
||||
const task = strandedTask({ id: "FN-U", column: "todo" });
|
||||
const h = harness([task], undefined);
|
||||
|
||||
expect(await h.manager.recoverStrandedCompletedTodoTasks()).toBe(1);
|
||||
});
|
||||
|
||||
it("handles a board mixing a renamed and a builtin workflow", async () => {
|
||||
/* Per-task resolution, not one board-wide vocabulary: each card's hold
|
||||
column comes from ITS OWN workflow, and a card in the OTHER workflow's
|
||||
hold column must not be promoted. */
|
||||
const renamed = strandedTask({ id: "FN-R", column: "drafting" });
|
||||
const legacy = strandedTask({ id: "FN-D", column: "todo" });
|
||||
// FN-R belongs to the renamed workflow; FN-D to the builtin one.
|
||||
const irByWorkflow: Record<string, WorkflowIr> = {
|
||||
"wf-renamed": ir("drafting"),
|
||||
"wf-legacy": ir("todo"),
|
||||
};
|
||||
const byTask: Record<string, string> = { "FN-R": "wf-renamed", "FN-D": "wf-legacy" };
|
||||
const tasks = [renamed, legacy];
|
||||
const recovered: string[] = [];
|
||||
const store = {
|
||||
listTasks: vi.fn(async (opts?: { column?: string }) =>
|
||||
opts?.column ? tasks.filter((t) => t.column === opts.column) : tasks,
|
||||
),
|
||||
getTask: vi.fn(async (id: string) => tasks.find((t) => t.id === id) ?? null),
|
||||
getSettings: vi.fn(async () => ({})),
|
||||
logEntry: vi.fn(async () => undefined),
|
||||
recordRunAuditEvent: vi.fn(async () => undefined),
|
||||
getTaskWorkflowSelection: vi.fn((id: string) => ({ workflowId: byTask[id], stepIds: [] })),
|
||||
getTaskWorkflowSelectionAsync: vi.fn(async (id: string) => ({ workflowId: byTask[id], stepIds: [] })),
|
||||
getWorkflowDefinition: vi.fn(async (id: string) => (irByWorkflow[id] ? { ir: irByWorkflow[id] } : null)),
|
||||
} as unknown as TaskStore;
|
||||
const manager = new SelfHealingManager(store, {
|
||||
rootDir: "/tmp/test-project",
|
||||
recoverCompletedTask: async (task: Task) => {
|
||||
recovered.push(task.id);
|
||||
return true;
|
||||
},
|
||||
} as never);
|
||||
|
||||
const count = await manager.recoverStrandedCompletedTodoTasks();
|
||||
|
||||
expect(count).toBe(2);
|
||||
expect(recovered.sort()).toEqual(["FN-D", "FN-R"]);
|
||||
});
|
||||
|
||||
it("leaves the existing non-column guards intact under a renamed workflow", async () => {
|
||||
/*
|
||||
The conversion must not widen the sweep. These three rejections
|
||||
(paused / incomplete steps / errored) are the ones most likely to be lost
|
||||
when a filter is rewritten, and each independently protects against
|
||||
promoting work that is not actually finished.
|
||||
*/
|
||||
const paused = strandedTask({ id: "FN-P", column: "drafting", paused: true });
|
||||
const incomplete = strandedTask({
|
||||
id: "FN-I",
|
||||
column: "drafting",
|
||||
steps: [{ name: "s1", status: "pending" }],
|
||||
} as Partial<Task>);
|
||||
const errored = strandedTask({ id: "FN-E", column: "drafting", error: "boom" } as Partial<Task>);
|
||||
const h = harness([paused, incomplete, errored], ir("drafting"));
|
||||
|
||||
expect(await h.manager.recoverStrandedCompletedTodoTasks()).toBe(0);
|
||||
expect(h.recovered).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -3035,7 +3035,17 @@ describe("SelfHealingManager", () => {
|
||||
const result = await managerWithRecovery.recoverStrandedCompletedTodoTasks();
|
||||
|
||||
expect(result).toBe(1);
|
||||
expect(store.listTasks).toHaveBeenCalledWith({ column: "todo", slim: true });
|
||||
/*
|
||||
FNXC:WorkflowLifecycleColumns 2026-07-28-06:40 (Phase B / slice B3.1 — U4):
|
||||
The query shape CHANGED on purpose. This sweep can no longer scope itself to
|
||||
`column: "todo"` — that literal made it blind to every workflow whose hold
|
||||
column is named something else, so a finished card sat in `drafting` forever.
|
||||
It now reads the board and filters by each task's RESOLVED hold column.
|
||||
|
||||
The behavioral assertions below are the ones that matter and are unchanged:
|
||||
one qualifying card, promoted exactly once. Only the query shape moved.
|
||||
*/
|
||||
expect(store.listTasks).toHaveBeenCalledWith({ slim: true, includeArchived: false });
|
||||
expect(recoverFn).toHaveBeenCalledTimes(1);
|
||||
expect(recoverFn).toHaveBeenCalledWith(expect.objectContaining({ id: "FN-101" }));
|
||||
|
||||
|
||||
@@ -3209,11 +3209,27 @@ export class SelfHealingManager {
|
||||
if (!recoverFn) return 0;
|
||||
|
||||
try {
|
||||
const tasks = await this.store.listTasks({ column: "todo", slim: true });
|
||||
/*
|
||||
FNXC:WorkflowLifecycleColumns 2026-07-28-06:25 (Phase B / slice B3.1 — U4):
|
||||
This sweep decided "is this card in the hold column?" TWICE — the QUERY
|
||||
(`{ column: "todo" }`) and the per-task GUARD (`task.column !== "todo"`) —
|
||||
and both were literal. They convert TOGETHER or not at all: a correct guard
|
||||
behind a literal query never runs, and a converted query behind a literal
|
||||
guard rejects every row it just fetched. Either half alone is a green diff
|
||||
with no behavior change.
|
||||
|
||||
Cost discipline: the column filter is gone, so the CHEAP non-column
|
||||
rejections (paused / executing / incomplete steps / errored / taint) run
|
||||
FIRST and synchronously, and only the handful of survivors pay an IR
|
||||
resolution — shared through `irCache`, so a board spanning three workflows
|
||||
resolves three times regardless of card count. `includeArchived: false`
|
||||
keeps archived rows out, which the old column filter did implicitly.
|
||||
*/
|
||||
const tasks = await this.store.listTasks({ slim: true, includeArchived: false });
|
||||
const executingIds = this.options.getExecutingTaskIds?.() ?? new Set<string>();
|
||||
|
||||
const stranded = tasks.filter((task) => {
|
||||
if (task.column !== "todo" || task.paused) return false;
|
||||
const completedNonColumnCandidates = tasks.filter((task) => {
|
||||
if (task.paused) return false;
|
||||
if (executingIds.has(task.id)) return false;
|
||||
if (task.steps.length === 0 || !task.steps.every((s) => s.status === "done" || s.status === "skipped")) return false;
|
||||
/*
|
||||
@@ -3235,9 +3251,32 @@ export class SelfHealingManager {
|
||||
return true;
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:WorkflowLifecycleColumns 2026-07-28-06:25 (Phase B / slice B3.1 — U4):
|
||||
The column half of the old predicate, now resolved per task against the
|
||||
card's OWN workflow. A board can span workflows with different hold
|
||||
columns, so this cannot be hoisted to one board-wide value. A workflow that
|
||||
declares no hold column keeps the legacy `todo`, matching the pre-conversion
|
||||
behavior rather than matching nothing.
|
||||
*/
|
||||
const irCache = new Map<string, Awaited<ReturnType<typeof resolveWorkflowIrForTask>>>();
|
||||
const stranded: Task[] = [];
|
||||
for (const task of completedNonColumnCandidates) {
|
||||
let holdColumn = "todo";
|
||||
try {
|
||||
const lifecycle = resolveLifecycleColumns(
|
||||
await resolveWorkflowIrForTask(this.store, task.id, irCache),
|
||||
);
|
||||
if (lifecycle?.hold) holdColumn = lifecycle.hold;
|
||||
} catch {
|
||||
holdColumn = "todo";
|
||||
}
|
||||
if (task.column === holdColumn) stranded.push(task);
|
||||
}
|
||||
|
||||
if (stranded.length === 0) return 0;
|
||||
|
||||
log.warn(`Found ${stranded.length} completed task(s) stranded in todo`);
|
||||
log.warn(`Found ${stranded.length} completed task(s) stranded in the hold column`);
|
||||
|
||||
let recovered = 0;
|
||||
for (const task of stranded) {
|
||||
|
||||
Reference in New Issue
Block a user