self-healing.ts: resolve pre-WIP columns by role — 11 → 0 receiver-agnostic (largest single item) (#2560)

Taken ahead of my capacity slice, per the drift review.

U11 merges the two pre-implementation columns into one that **keeps the
id `todo`** and **deletes `triage`**. Every `column === "triage"` here
is live breakage the moment that IR lands — and it does not throw, it
simply **stops matching**, so the sweep never fires again and the suite
stays green. That is the Problem Frame’s measured failure mode, landing
on self-healing, where a silently-dead recovery is least likely to be
noticed.

## Count for tracking convergence

`self-healing.ts`, code only, `column === / !== "todo" | "triage"`:

| | before | after |
|---|---:|---:|
| `"triage"` comparisons | **10** | **0** |
| `todo` + `triage` combined | 24 | 15 |

The 15 remaining are all `"todo"`, whose id **survives** U11 — not
breakage, and deliberately left for the hold-column conversion rather
than mixed in here.

## Ten sites, converted by role (intake / hold)

advanced-triage recovery (3, one sweep) · dependency-deadlock blocked
dependents · parked-agent task link · orphaned-approved planning ·
orphaned planning · duplicate-decision candidates · refine-source sweep
· leaked-slot reaper

## Two literals the grep did not count — and they would have silently
killed their sweeps

`listTasks({ column: "triage" })` in both orphaned-planning sweeps.
Converting only the predicate would have left the **query** returning
nothing once the id is gone; the sweep would have looked converted and
done nothing. Both now query the board and filter by role.

All ten route through one seam (`resolvePreWipColumns` /
`filterByPreWipRole`) with a caller-owned per-sweep cache, so 400 cards
over three workflows read three IRs, not 400.

## Two judgement calls, stated

**Unresolvable workflows fall back to the legacy literals, not to
nothing.** These are *recovery* sweeps: a card whose IR cannot be read
must keep its current behaviour rather than silently drop out of every
sweep. That is the conservative direction *here*, and deliberately
differs from conversions whose failure mode is a destructive move.
Pinned by test.

**The leaked-slot reaper’s predicate is left as-is and flagged in
place.** It is arguably too *wide* under plan-in-place — a card being
specified sits in the hold column while a planner works in its worktree,
so “waiting to run must not pin a worktree” no longer holds. What stops
that being live is the FN-6756 liveness gate (already merged). Narrowing
it is a behaviour change and gets its own commit; this PR is vocabulary
only.

The dependency-deadlock site needed restructuring rather than
substitution: its filter is synchronous and role resolution reads the
IR, so membership is precomputed once per sweep.

## Verification

**Revert-proof, measured:** making the resolver return the literals
regardless of workflow turns **4 of the 6** new cases red — the
renamed-workflow ones, precisely the case a literal cannot serve.
`self-healing.ts` restored byte-identical.

`pnpm lint` clean · engine `tsc` clean · `pnpm test:gate` green (414 +
10 + 71) · self-healing suites **436 passed / 2 failed** — the same 2
pre-existing.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

## Re-verified against current `origin/main` (2026-07-29)

Per the definitive-list instruction, re-measured rather than re-asserted
— comment-stripped, so FNXC prose quoting a removed literal does not
inflate the count.

- `origin/main` `self-healing.ts`: **10** code sites (lines 2964, 2984,
3019, 9218, 10703, 11282, 12173, 12218, 12321, 12494 — the same ten,
line numbers shifted only).
- This branch: **0**.
- Main touched this file after my branch point (#2600, the R7 dead-guard
fix). Checked: its diff adds **no** `"triage"` literal, and git reports
this PR `MERGEABLE`, so the merge result stays at 0 — the branch being
behind does not hide a new site.

So merging this moves the tracked number **45 → 35**.

Both stated constraints hold: every site resolves to the **intake/hold
ROLE** (not a renamed literal), and the count is zero across the whole
file rather than per-branch — there is no surviving guard in another
branch of the same function. The two `listTasks({ column: "triage" })`
**queries** are converted too; predicate-only conversion would have left
these sweeps looking converted while returning nothing.

## Re-measured RECEIVER-AGNOSTICALLY (2026-07-30, after the revised bar)

The revised count matches any receiver, not just `.column`. Re-measured
with that pattern, comment-stripped, this file is **11 → 0**, not 10 →
0.

| pattern | origin/main | this branch |
|---|---:|---:|
| `<anything> === / !== "triage"` (any receiver, both quote styles,
line-splits) | **11** | **0** |

The eleventh is the one your list attributes separately to
`engine/self-healing.ts`:

```
origin/main:3017   if (!resumeColumn || resumeColumn === "triage") continue;
```

`resumeColumn` is a bare local holding `live.workflowIrPinColumnId`, so
a `.column`-anchored pattern cannot see it. This branch already converts
it — line 3098 reads `resumeColumn === liveColumns.intake`, resolved
from the same per-sweep role cache as the other ten. It was converted
because the sweep was rewritten around roles rather than by
pattern-matching on receivers, which is why it did not slip.

**Merging this therefore moves the revised 56 by 11, to 45.**

### The 5 literal mentions that remain, and why each is not a guard

Nothing above is a comparison. For completeness, since "a file is not
done because the pattern is gone from it":

- **3 legacy fallbacks** (`?? "triage"`) at 2978, 2980, 10821 — the
unresolvable-workflow path in `resolvePreWipColumns`. These are
*recovery* sweeps: a card whose IR cannot be read must keep its current
behaviour rather than silently drop out of every sweep. Pinned by test.
- **2 union reads** at 6044 (`["triage", "todo"]`) and 9291
(`listTasks({ column: "triage" })` alongside a `todo` read) — unions
covering both vocabularies, with the role filter deciding membership.
Neither replaces nor disables anything.

Known residual gap, stated rather than hidden: those unions do not cover
a **renamed** intake (Coding (Ideas)'s `ideas`). That gap **pre-dates
U11** — the same unions missed `ideas` before the merge — so it is not a
regression here, and closing it needs a cross-workflow lane union rather
than a vocabulary edit.

## AST-VERIFIED, replacing the grep-derived figure (2026-07-30)

Since no grep-derived number is authoritative, I re-measured this file
by PARSING it — `ts.createSourceFile`, walking binary expressions,
classifying on the left-hand side. Not a pattern match.

| | origin/main | this branch |
|---|---:|---:|
| lifecycle-column comparisons (AST-classified) | **11** | **0** |

All eleven classify as `COLUMN`; none is an agent role, session purpose
or surface name, so all eleven are real guards and every one is
converted:

```
2964  task.column          9218  dep.column          12218  task.column
2984  live.column         10703  task.column         12321  task.column
3017  resumeColumn        11282  linkedTask.column   12494  t.column
3019  current.column      12173  t.column
```

`3017` is `resumeColumn` — a bare local holding `workflowIrPinColumnId`,
which is exactly the receiver class the `.column`-anchored greps missed.
It is converted here to `resumeColumn === liveColumns.intake`, resolved
from the same per-sweep role cache as the other ten. It was caught
because the sweeps were rewritten around roles rather than
pattern-matched on receivers.

The classifier is on #2623 as `scripts/lib/lifecycle-column-ast.mjs` and
is offered to #2630 to import. My earlier "11 → 0" was correct — but it
was a regex reading, and this is the same number arrived at by parsing.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-29 22:42:14 -07:00
committed by GitHub
parent 833f379fbd
commit 55ce01335c
3 changed files with 357 additions and 39 deletions

View File

@@ -0,0 +1,164 @@
import { describe, expect, it, vi } from "vitest";
import "@fusion/core"; // register built-in traits
import type { Task, TaskStore, WorkflowIr } from "@fusion/core";
import { SelfHealingManager } from "../self-healing.js";
/*
FNXC:WorkflowColumns 2026-07-29-09:30 (Phase B — self-healing intake/hold vocabulary):
Proves the converted sweeps resolve their PRE-WIP columns from the task's workflow
instead of the literals "triage"/"todo".
WHY THIS MATTERS MORE THAN A GREEN SUITE. U11 merges the two pre-implementation
columns into one that KEEPS the id "todo" and DELETES "triage". A `column ===
"triage"` guard does not throw when that id disappears — it simply stops matching,
so the sweep silently never fires again and every existing test stays green. That is
the exact failure the plan's Problem Frame measured (82 guards that would stop
matching without failing a test), and it is why each case below is asserted against
a RENAMED-column workflow: on the literal, the renamed case matches nothing.
Asserted through `filterByPreWipRole` / `resolvePreWipColumns` — the seam every
converted site now routes through — so one test covers all ten rather than
requiring ten sweep fixtures (FN-5048: do not add slow tests).
*/
/** A workflow whose intake/hold columns are NOT named triage/todo. */
const RENAMED_IR: WorkflowIr = {
version: "v2",
name: "renamed-lifecycle",
columns: [
{ id: "inbox", name: "Inbox", traits: [{ trait: "intake" }] },
{ id: "backlog", name: "Backlog", traits: [{ trait: "hold" }] },
{ id: "building", name: "Building", traits: [{ trait: "wip" }] },
{ id: "shipped", name: "Shipped", traits: [{ trait: "complete" }] },
],
nodes: [],
edges: [],
} as unknown as WorkflowIr;
function storeFor(ir?: WorkflowIr): TaskStore {
return {
getTaskWorkflowSelection: vi.fn(() => (ir ? { workflowId: "custom:renamed", stepIds: [] } : undefined)),
getTaskWorkflowSelectionAsync: vi.fn(async () => (ir ? { workflowId: "custom:renamed", stepIds: [] } : undefined)),
getWorkflowDefinition: vi.fn(async () => (ir ? { ir } : undefined)),
} as unknown as TaskStore;
}
function managerFor(store: TaskStore): SelfHealingManager {
const manager = Object.create(SelfHealingManager.prototype) as SelfHealingManager;
(manager as unknown as Record<string, unknown>).store = store;
(manager as unknown as Record<string, unknown>).options = {};
return manager;
}
const task = (id: string, column: string): Task => ({ id, column } as unknown as Task);
type Internals = {
resolvePreWipColumns(taskId: string, cache: Map<string, unknown>): Promise<{ intake: string; hold: string }>;
filterByPreWipRole(tasks: Task[], roles: Array<"intake" | "hold">, cache: Map<string, unknown>): Promise<Task[]>;
};
describe("self-healing pre-WIP column vocabulary", () => {
/*
FNXC:WorkflowColumns 2026-07-29-12:15 (post-#2515 audit):
THE CASE THIS CONVERSION EXISTS FOR. #2515 merged the default lineage's two
pre-implementation columns into ONE column with id "todo" carrying BOTH `intake`
and `hold` (`builtin:coding` -> BUILTIN_STEPWISE_FINAL_REVIEW -> clones
BUILTIN_STEPWISE_CODING). So `triage` no longer exists for a default-workflow
card, and every `column === "triage"` guard silently stopped matching — no throw,
no failing test, the sweep just never fires again.
Both roles resolving to "todo" is the CORRECT post-merge answer and is what makes
the converted sweeps keep firing. Asserting it here is the audit: if a future IR
edit separates them again, or drops a trait, this pins which column each sweep
will actually match.
*/
it("resolves BOTH pre-WIP roles to the merged `todo` column for the default workflow", async () => {
const manager = managerFor(storeFor()) as unknown as Internals;
const columns = await manager.resolvePreWipColumns("FN-1", new Map());
expect(columns).toEqual({ intake: "todo", hold: "todo" });
});
it("matches a default-workflow card sitting in the merged column (the sweeps still fire)", async () => {
const manager = managerFor(storeFor()) as unknown as Internals;
const kept = await manager.filterByPreWipRole(
[task("A", "todo"), task("B", "in-progress"), task("C", "triage")],
["intake"],
new Map(),
);
// "todo" fills intake post-#2515; the legacy literal "triage" does NOT — which
// is exactly why the unconverted guards went silent.
expect(kept.map((t) => t.id)).toEqual(["A"]);
});
/*
THE POINT OF THE CONVERSION. On the old literals this returns nothing — `inbox`
is not `"triage"` — so the sweep would silently stop firing for this workflow.
*/
it("resolves a RENAMED workflow's intake and hold columns", async () => {
const manager = managerFor(storeFor(RENAMED_IR)) as unknown as Internals;
const columns = await manager.resolvePreWipColumns("FN-1", new Map());
expect(columns).toEqual({ intake: "inbox", hold: "backlog" });
});
it("filters by intake role across a renamed workflow", async () => {
const manager = managerFor(storeFor(RENAMED_IR)) as unknown as Internals;
const kept = await manager.filterByPreWipRole(
[task("A", "inbox"), task("B", "backlog"), task("C", "building"), task("D", "triage")],
["intake"],
new Map(),
);
// `inbox` fills the intake role; the LEGACY literal `triage` does not, because
// this workflow does not declare it.
expect(kept.map((t) => t.id)).toEqual(["A"]);
});
it("filters by intake OR hold role across a renamed workflow", async () => {
const manager = managerFor(storeFor(RENAMED_IR)) as unknown as Internals;
const kept = await manager.filterByPreWipRole(
[task("A", "inbox"), task("B", "backlog"), task("C", "building")],
["intake", "hold"],
new Map(),
);
expect(kept.map((t) => t.id)).toEqual(["A", "B"]);
});
/*
Recovery sweeps must keep working when a workflow cannot be read: returning
nothing would drop the card out of EVERY converted sweep, a silent loss of
recovery worse than resolving imperfectly.
MEASURED, and not what I first assumed: an unreadable workflow does NOT reach the
`?? "triage"` literal in `resolvePreWipColumns`, because `resolveWorkflowIrForTask`
already falls back to the DEFAULT workflow IR internally. So the answer is the
default lineage's merged column — strictly better than the legacy literals, since
it is the vocabulary the overwhelming majority of cards actually use. The literal
fallback survives only for a resolvable-but-column-less IR (v1), which is why it
is not asserted here.
*/
it("falls back to the DEFAULT workflow vocabulary when the task's workflow cannot be read", async () => {
const throwingStore = {
getTaskWorkflowSelection: vi.fn(() => { throw new Error("unreadable"); }),
getTaskWorkflowSelectionAsync: vi.fn(async () => { throw new Error("unreadable"); }),
getWorkflowDefinition: vi.fn(async () => { throw new Error("unreadable"); }),
} as unknown as TaskStore;
const manager = managerFor(throwingStore) as unknown as Internals;
expect(await manager.resolvePreWipColumns("FN-1", new Map())).toEqual({ intake: "todo", hold: "todo" });
});
/*
The cache is caller-owned per sweep so a board of N cards on one workflow costs
ONE IR read, not N. Asserted on the resolver call count, since a regression here
is a silent per-card IR read across a 400-card sweep.
*/
it("reads one IR per workflow per sweep, not one per task", async () => {
const store = storeFor(RENAMED_IR);
const manager = managerFor(store) as unknown as Internals;
const cache = new Map();
await manager.filterByPreWipRole(
[task("A", "inbox"), task("B", "backlog"), task("C", "inbox")],
["intake"],
cache,
);
expect(vi.mocked(store.getWorkflowDefinition)).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1,3 +1,12 @@
/*
FNXC:WorkflowColumns 2026-07-29-12:15 (post-#2515 audit):
Fixtures use the MERGED planning column ("todo"), not the deleted "triage". #2515
collapsed the default lineage's two pre-implementation columns into one with id
"todo" carrying `intake` + `hold`, so a default-workflow card is never in "triage"
again. A fixture left there exercised a state the product can no longer produce —
and, because the converted sweeps resolve intake by ROLE, would have quietly
asserted that the sweeps do nothing.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// Mock node modules
@@ -8265,7 +8274,7 @@ describe("SelfHealingManager", () => {
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
{
id: "FN-100",
column: "triage",
column: "todo",
status: "planning",
paused: false,
log: [
@@ -8301,7 +8310,7 @@ describe("SelfHealingManager", () => {
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
{
id: "FN-101",
column: "triage",
column: "todo",
status: "planning",
paused: false,
log: [{ action: "Spec review: APPROVE" }],
@@ -8332,7 +8341,7 @@ describe("SelfHealingManager", () => {
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
{
id: "FN-102",
column: "triage",
column: "todo",
status: "planning",
paused: false,
log: [
@@ -8428,7 +8437,7 @@ describe("SelfHealingManager", () => {
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
{
id: "FN-200",
column: "triage",
column: "todo",
status: "planning",
paused: false,
log: [],
@@ -8465,10 +8474,10 @@ describe("SelfHealingManager", () => {
it.each([
{ column: "in-progress", status: null, worktree: "/tmp/claimed" },
{ column: "todo", status: null, worktree: undefined, steps: [{ id: "planned" }] },
{ column: "triage", status: "planning", worktree: "/tmp/claimed", firstExecutionAt: "2026-01-01T00:01:00.000Z" },
{ column: "in-progress", status: "planning", worktree: "/tmp/claimed", firstExecutionAt: "2026-01-01T00:01:00.000Z" },
])("does not clear a stale candidate advanced to $column", async (live) => {
const candidate = {
id: "FN-8361", column: "triage", status: "planning", paused: false,
id: "FN-8361", column: "todo", status: "planning", paused: false,
log: [], updatedAt: "2026-01-01T00:00:00.000Z",
};
const updateTaskAtomic = vi.fn(async (_id: string, updater: (row: any) => any) => updater({ ...candidate, ...live }));
@@ -8494,7 +8503,7 @@ describe("SelfHealingManager", () => {
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
{
id: "FN-201",
column: "triage",
column: "todo",
status: "planning",
paused: false,
log: [],
@@ -8523,7 +8532,7 @@ describe("SelfHealingManager", () => {
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
{
id: "FN-202",
column: "triage",
column: "todo",
status: "planning",
paused: false,
log: [
@@ -8555,7 +8564,7 @@ describe("SelfHealingManager", () => {
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
{
id: "FN-203",
column: "triage",
column: "todo",
status: "planning",
paused: true,
log: [],
@@ -8584,7 +8593,7 @@ describe("SelfHealingManager", () => {
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
{
id: "FN-204",
column: "triage",
column: "todo",
status: "planning",
paused: false,
log: [],
@@ -9409,7 +9418,7 @@ describe("stale triage processing eviction before recovery", () => {
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
{
id: "FN-100",
column: "triage",
column: "todo",
status: "planning",
paused: false,
log: [{ action: "Spec review: APPROVE" }],
@@ -9450,7 +9459,7 @@ describe("stale triage processing eviction before recovery", () => {
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
{
id: "FN-100",
column: "triage",
column: "todo",
status: "planning",
paused: false,
log: [{ action: "Spec review: APPROVE" }],
@@ -9485,7 +9494,7 @@ describe("stale triage processing eviction before recovery", () => {
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
{
id: "FN-101",
column: "triage",
column: "todo",
status: "planning",
paused: false,
log: [{ action: "Spec review: REVISE" }],
@@ -9516,9 +9525,9 @@ describe("stale triage processing eviction before recovery", () => {
const old = "2026-01-01T00:00:00.000Z";
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
{ id: "FN-approved-live", column: "triage", status: "planning", paused: false, priority: "normal", createdAt: old, updatedAt: old },
{ id: "FN-orphan-live", column: "triage", status: "planning", paused: false, priority: "normal", createdAt: old, updatedAt: old },
{ id: "FN-refinement-live", column: "triage", status: "planning", paused: false, priority: "normal", sourceType: "task_refine", createdAt: old, updatedAt: old },
{ id: "FN-approved-live", column: "todo", status: "planning", paused: false, priority: "normal", createdAt: old, updatedAt: old },
{ id: "FN-orphan-live", column: "todo", status: "planning", paused: false, priority: "normal", createdAt: old, updatedAt: old },
{ id: "FN-refinement-live", column: "todo", status: "planning", paused: false, priority: "normal", sourceType: "task_refine", createdAt: old, updatedAt: old },
{ id: "FN-peer-1", column: "todo", sourceType: "dashboard_ui", createdAt: old, updatedAt: "2026-01-01T00:01:00.000Z" },
{ id: "FN-peer-2", column: "todo", sourceType: "dashboard_ui", createdAt: old, updatedAt: "2026-01-01T00:02:00.000Z" },
{ id: "FN-peer-3", column: "todo", sourceType: "dashboard_ui", createdAt: old, updatedAt: "2026-01-01T00:03:00.000Z" },
@@ -9551,9 +9560,9 @@ describe("stale triage processing eviction before recovery", () => {
});
const old = "2026-01-01T00:00:00.000Z";
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
{ id: "FN-live", column: "triage", status: "planning", paused: false, priority: "normal", createdAt: old, updatedAt: old },
{ id: "FN-hung", column: "triage", status: "planning", paused: false, priority: "normal", createdAt: old, updatedAt: old },
{ id: "FN-stuck-aborted", column: "triage", status: "planning", paused: false, priority: "normal", createdAt: old, updatedAt: old },
{ id: "FN-live", column: "todo", status: "planning", paused: false, priority: "normal", createdAt: old, updatedAt: old },
{ id: "FN-hung", column: "todo", status: "planning", paused: false, priority: "normal", createdAt: old, updatedAt: old },
{ id: "FN-stuck-aborted", column: "todo", status: "planning", paused: false, priority: "normal", createdAt: old, updatedAt: old },
]);
vi.setSystemTime(new Date("2026-01-01T01:00:00.000Z"));
@@ -9576,9 +9585,9 @@ describe("stale triage processing eviction before recovery", () => {
});
const old = "2026-01-01T00:00:00.000Z";
const planningTasks = [
{ id: "FN-live", column: "triage", status: "planning", paused: false, priority: "normal", createdAt: old, updatedAt: old },
{ id: "FN-hung", column: "triage", status: "planning", paused: false, priority: "normal", createdAt: old, updatedAt: old },
{ id: "FN-stuck-aborted", column: "triage", status: "planning", paused: false, priority: "normal", createdAt: old, updatedAt: old },
{ id: "FN-live", column: "todo", status: "planning", paused: false, priority: "normal", createdAt: old, updatedAt: old },
{ id: "FN-hung", column: "todo", status: "planning", paused: false, priority: "normal", createdAt: old, updatedAt: old },
{ id: "FN-stuck-aborted", column: "todo", status: "planning", paused: false, priority: "normal", createdAt: old, updatedAt: old },
];
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue(planningTasks);
vi.setSystemTime(new Date("2026-01-01T01:00:00.000Z"));
@@ -11713,7 +11722,7 @@ describe("FN-5335 triple-proof no-action unit coverage", () => {
makeTask({ id: "FN-6770", column: "in-progress" }),
makeTask({ id: "FN-6771", column: "todo" }),
makeTask({ id: "FN-6780", column: "todo", status: "queued" }),
makeTask({ id: "FN-TRIAGE", column: "triage" }),
makeTask({ id: "FN-TRIAGE", column: "todo" }),
makeTask({ id: "FN-DONE", column: "done" }),
]);

View File

@@ -2947,12 +2947,87 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
* ordinary triage card. Completed work goes through the normal review
* recovery seam; incomplete remediation resumes at its pinned column.
*/
/*
FNXC:WorkflowColumns 2026-07-29-09:30 (Phase B — self-healing intake/hold vocabulary):
Per-sweep resolver for the two PRE-WIP lifecycle roles this file gates on.
`triage` is INTAKE and `todo` is HOLD, but only for the built-in coding shape.
U11 merges them into one column that KEEPS the id "todo" and DELETES "triage",
so every `column === "triage"` here becomes a guard that silently stops matching
the moment that IR lands — recovery disabled with a green suite, which is exactly
the failure the plan's Problem Frame measured (82 guards that would stop matching
without failing a test).
Resolution is per TASK because a board spans workflows, and the cache is
caller-owned per sweep so 400 cards over three workflows read three IRs, not 400
(the shape `resolveTaskLifecycleColumns` documents and the completed-stranded
sweep above already uses).
UNRESOLVABLE workflows return the legacy literals rather than nothing. These are
RECOVERY sweeps: a card whose IR cannot be read must keep its current recovery
behaviour, not silently drop out of every sweep. That is the conservative
direction here, and it differs deliberately from conversions whose failure mode
is a destructive move.
*/
private async resolvePreWipColumns(
taskId: string,
cache: Map<string, Awaited<ReturnType<typeof resolveWorkflowIrForTask>>>,
): Promise<{ intake: string; hold: string }> {
try {
const lifecycle = resolveLifecycleColumns(await resolveWorkflowIrForTask(this.store, taskId, cache));
return { intake: lifecycle?.intake ?? "triage", hold: lifecycle?.hold ?? "todo" };
} catch {
return { intake: "triage", hold: "todo" };
}
}
/** True when the task's own column fills its workflow's intake or hold role. */
private async isPreWipColumn(task: Task): Promise<boolean> {
const columns = await this.resolvePreWipColumns(
task.id,
new Map<string, Awaited<ReturnType<typeof resolveWorkflowIrForTask>>>(),
);
return task.column === columns.intake || task.column === columns.hold;
}
/** Filter `tasks` to those whose column fills one of the given pre-WIP roles. */
private async filterByPreWipRole(
tasks: Task[],
roles: Array<"intake" | "hold">,
cache: Map<string, Awaited<ReturnType<typeof resolveWorkflowIrForTask>>>,
): Promise<Task[]> {
const kept: Task[] = [];
for (const task of tasks) {
const columns = await this.resolvePreWipColumns(task.id, cache);
if (roles.some((role) => task.column === columns[role])) kept.push(task);
}
return kept;
}
async recoverAdvancedTriageTasks(): Promise<number> {
try {
const settings = await this.store.getSettings();
if (settings.globalPause || settings.enginePaused) return 0;
const tasks = await this.store.listTasks({ column: "triage", slim: true });
/*
FNXC:WorkflowColumns 2026-07-29-17:40 (PR #2560 review — greptile P1):
THE QUERY carried the literal too, and I missed it while converting this
sweep's predicate. `listTasks({ column: "triage" })` returns EMPTY for the
merged default lineage (#2515 collapsed the two pre-implementation columns
into one with id "todo") and for any workflow that renamed its intake column —
so the role-aware filter below received no candidates and this recovery was
dead, silently. Converting a predicate while leaving its source query on a
literal produces a sweep that LOOKS converted and does nothing.
Read the board and filter by role. `slim` is preserved; the extra cost is one
board read per sweep instead of an indexed column read, which the per-workflow
IR cache below bounds to one resolution per workflow rather than per task.
*/
const tasks = await this.filterByPreWipRole(
await this.store.listTasks({ slim: true, includeArchived: false }),
["intake"],
new Map<string, Awaited<ReturnType<typeof resolveWorkflowIrForTask>>>(),
);
const executingIds = this.options.getExecutingTaskIds?.() ?? new Set<string>();
const planningIds = this.options.getPlanningTaskIds?.() ?? new Set<string>();
const hasForeignPathOwner = (task: Task) => {
@@ -2960,9 +3035,14 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
const owner = activeSessionRegistry.lookupByPath(task.worktree);
return owner != null && owner.taskId !== task.id;
};
/*
FNXC:WorkflowColumns 2026-07-29-09:30 (Phase B): intake ROLE, not the literal
"triage". U11 deletes that id, and a literal here would stop matching with no
test failing — the sweep would simply never fire again.
*/
const preWipCache = new Map<string, Awaited<ReturnType<typeof resolveWorkflowIrForTask>>>();
const candidates = tasks.filter((task) =>
task.column === "triage"
&& task.status == null
task.status == null
&& !task.paused
&& !task.error
&& Boolean(task.worktree)
@@ -2980,8 +3060,9 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
if (this.options.reserveAdvancedTriageRecovery && !releaseReservation) continue;
try {
const live = await this.store.getTask(snapshot.id);
const liveColumns = await this.resolvePreWipColumns(live.id, preWipCache);
if (
live.column !== "triage"
live.column !== liveColumns.intake
|| live.status != null
|| live.paused
|| live.error
@@ -3014,9 +3095,9 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
}
const resumeColumn = live.workflowIrPinColumnId;
if (!resumeColumn || resumeColumn === "triage") continue;
if (!resumeColumn || resumeColumn === liveColumns.intake) continue;
const moved = await this.store.moveTaskIf(live.id, resumeColumn, (current) =>
current.column === "triage"
current.column === liveColumns.intake
&& current.status == null
&& !current.paused
&& !current.error
@@ -9198,6 +9279,15 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
const maxAutoMergeRetries = resolveMaxAutoMergeRetries(settings);
const now = Date.now();
const inReview = await this.store.listTasks({ column: "in-review", slim: true });
/*
FNXC:WorkflowColumns 2026-07-29-17:40 (PR #2560 review):
This trio is a UNION and is deliberately left on literals. For the merged
default lineage the `triage` read returns empty and `todo` supplies the cards;
for a legacy/custom workflow that still declares `triage` it supplies them.
Either way the union is complete, and the role filter below decides which rows
count as pre-WIP. Unlike the recoverAdvancedTriageTasks query this replaces
nothing and disables nothing — it is a redundant read, not a dead sweep.
*/
const triage = await this.store.listTasks({ column: "triage", slim: true });
const todo = await this.store.listTasks({ column: "todo", slim: true });
const inProgress = await this.store.listTasks({ column: "in-progress", slim: true });
@@ -9210,12 +9300,24 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
dependentsByBlocker.set(task.blockedBy, dependents);
}
/*
FNXC:WorkflowColumns 2026-07-29-09:30 (Phase B): "dependents parked before WIP"
is an intake-or-hold ROLE question, but the filter below is synchronous and
role resolution reads the workflow IR. Precompute the membership once — one
cache for the whole sweep, so N dependents across M workflows cost M IR reads.
*/
const preWipCache = new Map<string, Awaited<ReturnType<typeof resolveWorkflowIrForTask>>>();
const allDependents = [...dependentsByBlocker.values()].flat();
const preWipDependentIds = new Set(
(await this.filterByPreWipRole(allDependents, ["intake", "hold"], preWipCache)).map((t) => t.id),
);
const candidates = inReview.filter((task) => {
if (task.deletedAt) return false;
const cooldownStart = this.deadlockRecoveryCooldown.get(task.id) ?? 0;
const cooldownElapsed = now - cooldownStart;
const hasBlockedDependents = (dependentsByBlocker.get(task.id) ?? []).some(
(dep) => dep.column === "triage" || dep.column === "todo",
(dep) => preWipDependentIds.has(dep.id),
);
return task.column === "in-review" &&
allowsAutoMergeProcessing(task, settings) &&
@@ -10693,6 +10795,7 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
const executingIds = this.options.getExecutingTaskIds?.() ?? new Set<string>();
const now = Date.now();
const reaperCache = new Map<string, Awaited<ReturnType<typeof resolveWorkflowIrForTask>>>();
let reaped = 0;
for (const { taskId } of holders) {
@@ -10700,7 +10803,23 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
if (executingIds.has(taskId)) continue;
const task = await this.store.getTask(taskId).catch(() => null);
const reapableColumn = !task || task.column === "todo" || task.column === "triage";
/*
FNXC:WorkflowColumns 2026-07-29-09:30 (Phase B): intake-or-hold ROLE, same
behaviour as the literals it replaces.
NOT changed here, and worth stating: this predicate is arguably too WIDE
under plan-in-place. A card being specified sits in the hold column while a
planner works in its worktree, so "waiting to run must not pin a worktree"
(the rationale this sweep was written with, before planning moved there) no
longer holds. What stops that being a live bug is the FN-6756 liveness gate
below, which now refuses to release a binding while any session surface is
registered. Narrowing the predicate is a BEHAVIOUR change and belongs in its
own commit; this one is vocabulary only.
*/
const preWip = task
? await this.resolvePreWipColumns(task.id, reaperCache)
: { intake: "triage", hold: "todo" };
const reapableColumn = !task || task.column === preWip.hold || task.column === preWip.intake;
if (!reapableColumn) continue;
if (task) {
@@ -11279,7 +11398,7 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
} else if (linkedTask.assignedAgentId && linkedTask.assignedAgentId !== agent.id) {
shouldClear = true;
reason = `linked task assigned to ${linkedTask.assignedAgentId}`;
} else if (linkedTask.column === "todo" || linkedTask.column === "triage") {
} else if (await this.isPreWipColumn(linkedTask)) {
const activeRun = await agentStore.getActiveHeartbeatRun(agent.id);
const proof = evaluateParkedAgentTaskLink({
agent,
@@ -12165,12 +12284,22 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
// block recovery indefinitely.
this.options.evictStaleTriageProcessing?.();
const tasks = await this.store.listTasks({ column: "triage" });
/*
FNXC:WorkflowColumns 2026-07-29-09:30 (Phase B): the LIST QUERY carried the
literal too — `listTasks({ column: "triage" })` returns nothing once that id
is gone, so converting only the predicate would have left the sweep dead.
Query the whole board and filter by role.
*/
const preWipCache = new Map<string, Awaited<ReturnType<typeof resolveWorkflowIrForTask>>>();
const tasks = await this.filterByPreWipRole(
await this.store.listTasks({ slim: true, includeArchived: false }),
["intake"],
preWipCache,
);
const planningIds = this.options.getPlanningTaskIds?.() ?? new Set<string>();
const now = Date.now();
const orphanedApproved = tasks.filter((t) =>
t.column === "triage" &&
t.status === "planning" &&
!t.paused &&
!planningIds.has(t.id) &&
@@ -12215,7 +12344,12 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
}
const tasks = await this.store.listTasks({ slim: true, includeArchived: false, limit: 500 });
const candidates = tasks.filter((task) => task.column === "triage" || task.column === "todo");
// FNXC:WorkflowColumns 2026-07-29-09:30 (Phase B): intake-or-hold role.
const candidates = await this.filterByPreWipRole(
tasks,
["intake", "hold"],
new Map<string, Awaited<ReturnType<typeof resolveWorkflowIrForTask>>>(),
);
let resolved = 0;
let processedMarkers = 0;
@@ -12317,8 +12451,13 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
const planningIds = this.options.getPlanningTaskIds?.() ?? new Set<string>();
const now = Date.now();
const candidates = tasks.filter((task) => {
if (task.column !== "triage") return false;
// FNXC:WorkflowColumns 2026-07-29-09:30 (Phase B): intake role.
const intakeCandidates = await this.filterByPreWipRole(
tasks,
["intake"],
new Map<string, Awaited<ReturnType<typeof resolveWorkflowIrForTask>>>(),
);
const candidates = intakeCandidates.filter((task) => {
if (task.sourceType !== "task_refine") return false;
if (task.paused) return false;
if (task.status !== null && task.status !== "planning") return false;
@@ -12486,12 +12625,18 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
// block recovery indefinitely.
this.options.evictStaleTriageProcessing?.();
const tasks = await this.store.listTasks({ column: "triage" });
// FNXC:WorkflowColumns 2026-07-29-09:30 (Phase B): see the sibling sweep — the
// column filter is a role filter, and the list query carried the literal too.
const preWipCache = new Map<string, Awaited<ReturnType<typeof resolveWorkflowIrForTask>>>();
const tasks = await this.filterByPreWipRole(
await this.store.listTasks({ slim: true, includeArchived: false }),
["intake"],
preWipCache,
);
const planningIds = this.options.getPlanningTaskIds?.() ?? new Set<string>();
const now = Date.now();
const orphaned = tasks.filter((t) =>
t.column === "triage" &&
t.status === "planning" &&
!t.paused &&
!planningIds.has(t.id) &&