self-healing: six recovery sweeps that never ran on a renamed board — and the guards widening their queries activates (#2838)

**Six self-healing sweeps did not run at all on a renamed board. Each is
a recovery path — the thing that unsticks a card when something has
already gone wrong.**

#2800 measured this class and could not fix it: a read happens *before*
any task is in hand, so there is nothing to resolve a per-task lane
from. `resolveProjectColumnsForRoles` (landed separately) is the seam
that was missing.

## What was silently dead

| sweep | what stayed broken on a renamed board |
| --- | --- |
| `reconcileDoneTaskIntegrity` | a landed card kept **no commit sha**,
forever |
| `recoverAlreadyMergedReviewTasks` | a card whose merge **succeeded**
stayed parked with `status: "failed"` |
| `recoverStuckMergeDeadlocks` | **doubly blind** — no candidates *and*
no dependents |
| `recoverInterruptedMergingTasks` | a task interrupted mid-merge sat in
`merging` indefinitely |
| `recoverMergeableReviewTasks` | a card ready to merge was never
re-enqueued |
| `recoverReviewTasksWithFailedPreMergeSteps` | a card parked on a
failed review step was never revived |

The census scored the `task.column === "..."` re-assertion *inside* each
loop, never the query above it. Converting those comparisons would have
dropped six counts and changed nothing — the loop bodies were already
unreachable.

## The conversion shape — five parts, three of which review taught me

Documented in `self-healing-sweeps-are-blind-on-a-renamed-board.md`,
because the second sweep **drifted from the first**: I wrote it from the
pre-review version and reproduced a flaw already fixed one commit
earlier.

1. **Read** — project union, query each column, dedupe by id. Legacy ids
unioned so a board mid-rename is not skipped.
2. **Verdict** — per card against **its own** workflow. Widening the
read and widening the verdict are different decisions: *a missed row is
invisible, a wrong row is a write.* Using the project union as a
per-card test claims a card because some **other** board calls its
column that role.
3. **Provenance** — the resolver **substitutes** the built-in IR rather
than failing, so `length > 0` reads as "this card answered" when nobody
did. It does not change the verdict (measured: identical) — it makes the
unrepaired card **reportable**.
4. **The log strings** — widening a query invalidates every message
naming the old literal. One logged `"stale merging task(s) in
in-review"` after its read covered several lanes.
5. **The guards the query ACTIVATES.**

## Part 5 is the one that bites

A guard downstream of a literal query is **unreachable** on a renamed
board — and unreachable is indistinguishable from correct. That is why
these sit unwired indefinitely.

`recoverReviewTasksWithFailedPreMergeSteps` filters on `blocker !==
"task has failed pre-merge workflow steps"` — an **exact string match**.
Unwired, the blocker returns `"task is in 'checking', must be in
'in-review'"`, so widening the query alone would have made the sweep
**find every card and reject every card**.

Measured: **6 sweeps hold both a literal query and an unwired lane
guard**; 30 hold a literal query with no such guard. All six are named
in the doc.

**One of the six was my own already-converted sweep.** I widened
`recoverAlreadyMergedReviewTasks` two commits before noticing its
`getTaskHardMergeBlocker` was unwired — so for two commits it found
renamed-board cards and declined them. The scan must run **before**
widening; I did it after, and only caught it because the next sweep
forced the question. `getTaskHardMergeBlocker` was the blind spot for
four of the six: a wrapper, no lane parameter at all, every caller
behind a literal query.

## Corrections to my own work, kept visible

- The project union used as a **per-card verdict** — the flat-set
mistake `project-lane-vocabulary.ts` warns about in its own header,
which I quoted while writing it.
- A **provenance fix that was a no-op**: measured identical verdicts in
every state, revert passed its own new test, so it was thrown away
rather than shipped with a comment claiming otherwise.
- The second sweep **reproducing the first's pre-fix shape**.
- Three assertions that were **vacuous until the revert exposed them** —
including one where the write needed a real git repo, so `commitSha`
could not distinguish accepted from rejected.

## Verification

- `pnpm test:gate` — 161 + 487 + 13 + 71
- `self-healing.test.ts` 412, query-blindness suite 12
- `tsc` on core and engine; `pnpm lint`; `check:changesets`; census
`--strict` — all clean, each run explicitly
- Every conversion revert-measured, **each direction independently**
where a sweep has two (read and guard)

## Scope

**42 queries remain**, 5 of the 6 activation-risk sweeps among them.
Each is per-sweep work — its own filter semantics, its own downstream
guards, its own log strings — so they land one at a time with the
pattern proven, never swept.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- **Bug Fixes**
- Self-healing workflows now work correctly on boards with renamed
lifecycle columns.
- Improved recovery for completed, in-review, interrupted, stalled, and
failed-merge tasks.
- Prevented tasks from being incorrectly classified using another
workflow’s columns.
  - Added warnings when a task’s workflow lanes cannot be resolved.

- **Documentation**
- Expanded guidance on renamed-board recovery behavior and related
diagnostic limitations.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-30 15:38:10 -07:00
committed by GitHub
parent 68fd781eb8
commit 7784cb1fe8
6 changed files with 1085 additions and 54 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Merge-evidence repair, already-merged rescue and deadlock recovery run on renamed boards.
category: fix
dev: `recoverAlreadyMergedReviewTasks` had the same defect on the review lane — a card whose merge succeeded stayed parked with status=failed. `reconcileDoneTaskIntegrity` queried `listTasks({ column: "done" })`, which returns nothing on a renamed board, so the sweep never executed. It now resolves the project's complete lanes via `resolveProjectColumnsForRoles` and queries each, unioned with the legacy id.

View File

@@ -84,6 +84,103 @@ The two shapes that work:
- When you touch a self-healing test, make its `listTasks` fake **honor `options.column`**. That is a one-line change per fake and it converts this whole class from invisible to failing-loudly.
- Read `self-healing.ts: N` in the census as "N comparisons", never as "N remaining defects" — in this file the two numbers are not related.
## Converting a sweep: the four-part shape, and the part that is easy to miss
Four sweeps are converted (`reconcileDoneTaskIntegrity`, `recoverAlreadyMergedReviewTasks`,
`recoverStuckMergeDeadlocks`, `recoverInterruptedMergingTasks`). They are deliberately identical, because
the second one drifted from the first — it was written from the pre-review version and reproduced a flaw
review had already fixed one commit earlier.
1. **Read** — `resolveProjectColumnsForRoles(store, ROLES)`, then query each column and dedupe by id. A
read happens before any task is in hand, so there is nothing to resolve a per-task lane from. The
legacy ids are unioned in, so a board mid-rename whose rows are still stored under the old id is not
skipped.
2. **Verdict** — resolved per card against **its own** workflow. Widening the read and widening the
verdict are different decisions: a missed row is invisible, a wrong row is a write. Using the project
union as a per-card test claims a card because *some other board* calls its column that role.
3. **Provenance** — `resolveWorkflowIrForTaskWithProvenance`, because the resolver **substitutes** the
built-in IR rather than failing. Without it, `columnsWithFlag(ir, role).length > 0` reads as "this card
answered" when nobody did. It does not change the verdict (measured: identical, since the built-in lane
already *is* the legacy id) — it makes the unrepaired card **reportable** instead of invisible.
4. **The log strings.** Widening a query silently invalidates every message naming the old literal.
`recoverInterruptedMergingTasks` logged `"stale merging task(s) in in-review"` after its read covered
several lanes — an operator debugging a renamed board would have been told the wrong column.
5. **The guards the widened query now ACTIVATES.** This is the one that bites hardest, because it makes a
conversion look complete while delivering nothing.
## Converting a query is also an activation
A guard downstream of a literal query is **unreachable on a renamed board** — the sweep never hands it a
row. Unreachable and correct are indistinguishable from the outside, which is exactly why these guards sit
unwired for years without anyone noticing.
Widen the read and they become reachable for the first time. `recoverMergeableReviewTasks` called
`getTaskMergeBlocker(t)` unwired; the moment its query found renamed-board cards, that call declined every
one of them. **The sweep would have found the cards and refused them** — strictly worse than not finding
them, because it looks fixed.
Measured across `self-healing.ts`: **4 sweeps hold both a literal column query and a genuinely unwired lane
guard**; 32 hold a literal query with no such guard.
```text
finalizeNoOpReviewTasks getTaskMergeBlocker
recoverOrphanOnlyScopeViolations getTaskHardMergeBlocker
recoverPostDoneNonContinuableWedge getTaskHardMergeBlocker
recoverCompletionHandoffLimbo getTaskMergeBlocker
```
**That number was 6 in the first version of this doc, and both extra rows were my scanner lying.** Worth
recording, because the scan is the thing the next worker will re-run:
- `recoverAlreadyMergedReviewTasks` was reported unwired **after I had wired it** — the options object sits
on the call's *last* line and the check read only the *first*. A multi-line call needs its whole span.
- `recoverReviewTasksWithFailedPreMergeSteps` was reported with a second unwired guard that is **prose
inside a doc comment** (`"because getTaskMergeBlocker() correctly blocks incomplete steps"`).
Both are the same failure this program keeps documenting about the census: **an instrument that matches
syntax, read as if it measured meaning.** I published 6, corrected to 5, and only reached 4 by re-checking
the correction itself. Re-run the scan with the span-aware and comment-skipping form, and spot-check each
row against the source before acting on it.
The scan must also run **before** the query is widened, not after: I converted
`recoverAlreadyMergedReviewTasks` two commits before noticing its guard, so for two commits it found
renamed-board cards and declined them.
`getTaskHardMergeBlocker` was the blind spot for four of the six: it is a *wrapper*, it had no lane
parameter at all, and every one of its callers sat behind a literal query. Nothing exercised it.
Parts 4 and 5 are both invisible to the census — one is string contents, the other is reachability — and
each of the **43 remaining queries** carries both risks.
## Testing a sweep: assert what happens ONLY when the change is correct
Four assertions on this branch were vacuous — they passed with the fix reverted — and all four shared one
shape: **I asserted something the sweep does regardless of the code under test.** The surface presentation
differed every time, which is why recognising it took four rounds:
| the assertion | why it proved nothing |
| --- | --- |
| `commitSha` stayed undefined | the write needs a real git repo, so it never happens in the fixture either way |
| `toContain("must be in")` | **two** guards can refuse; the other one caught the card and produced the same substring |
| a warn was emitted | the sweep warns on a path unrelated to the conversion |
| `getSettings` was called | the sweep calls it **unconditionally on its first line** |
The reliable question is not *"did something observable happen"* but *"what happens **only** when this
change is correct"*. In a self-healing sweep that is almost always **candidacy** — the first thing that
runs once per accepted row, after the filter:
```ts
// getSettings runs before the filter → useless
// isBranchAheadOfBase runs once per CANDIDATE → separates accepted from declined
const aheadCheck = vi.spyOn(manager as never, "isBranchAheadOfBase").mockResolvedValue(false);
```
**Asserting the query is only safe when nothing downstream can veto.** Once a sweep has a lane-sensitive
guard (part 5), a query-only assertion passes while the guard silently rejects every row — which is
precisely the bug being fixed. Where a guard exists, assert the end-to-end outcome instead.
And run the revert. Every one of the four above was found that way and none by reading.
## Related
- `docs/solutions/test-failures/optional-flags-seam-hides-unconverted-column-guards.md` — the same lesson one level down: the census counts syntax, and a green suite that omits the new parameter carries no information about the change.

View File

@@ -349,8 +349,20 @@ export function getLatestFailedPreMergeReviewStep(
})[0];
}
/*
FNXC:WorkflowResolvedColumns 2026-07-30-20:50:
`reviewColumns` threads straight through to `getTaskMergeBlocker`, for the same reason that helper takes
it: omitted, the identity check falls back to the literal `in-review` and refuses a card sitting in its
own board's review lane.
This wrapper was the blind spot behind a whole class: its callers are self-healing sweeps whose column
QUERY was also a literal, so the unwired check was unreachable and therefore unnoticed. Widening a
sweep's query ACTIVATES it — the sweep starts finding renamed-board cards and this then declines every
one. Optional, so no caller changes behaviour until it passes the set.
*/
export function getTaskHardMergeBlocker(
task: Pick<Task, "column" | "paused" | "status" | "error" | "steps" | "workflowStepResults">,
options: { reviewColumns?: ReadonlySet<string> } = {},
): string | undefined {
return getTaskMergeBlocker({
...task,
@@ -358,7 +370,7 @@ export function getTaskHardMergeBlocker(
paused: false,
status: task.status === "failed" ? undefined : task.status,
error: undefined,
});
}, { reviewColumns: options.reviewColumns });
}
export function getTaskDoneBypassBlocker(

View File

@@ -77,6 +77,15 @@ function productionFaithfulStore(tasks: Task[]) {
getTaskWorkflowSelectionAsync: vi.fn(async () => ({ workflowId: "self-healing-lifecycle", stepIds: [] })),
getTaskWorkflowSelection: vi.fn(() => ({ workflowId: "self-healing-lifecycle", stepIds: [] })),
getWorkflowDefinition: vi.fn(async (id: string) => (id === "self-healing-lifecycle" ? { ir: RENAMED_IR } : undefined)),
/*
FNXC:WorkflowResolvedColumns 2026-07-30-16:50 (the fix landed; this fake had to grow to see it):
`resolveProjectColumnsForRoles` — the seam the sweeps now use — reads `listWorkflowDefinitions()`,
the PROJECT's workflows, because a query runs before any task is in hand. Without this method the
helper degrades to the legacy ids and the sweep still queries only `done`, so this file kept passing
against the FIXED code and reported nothing. A ratchet whose fake cannot reach the new seam stops
being a ratchet silently.
*/
listWorkflowDefinitions: vi.fn(async () => [{ ir: RENAMED_IR }]),
}) as unknown as TaskStore & EventEmitter;
return { store, listTasks };
}
@@ -112,7 +121,7 @@ describe("self-healing sweeps are bounded by a hardcoded column QUERY, not by th
expect(lifecycle?.complete).not.toBe("done");
});
it("KNOWN DEFECT: the done-integrity sweep asks for the literal `done`, so a RENAMED board yields nothing", async () => {
it("the done-integrity sweep now asks for the board's OWN complete lane (was: KNOWN DEFECT)", async () => {
/*
`reconcileDoneTaskIntegrity` opens with `listTasks({ column: "done", slim: true })` and then
re-asserts `task.column === "done"` on the rows it gets back. The census counts that re-assertion;
@@ -124,14 +133,63 @@ describe("self-healing sweeps are bounded by a hardcoded column QUERY, not by th
const { store, listTasks } = productionFaithfulStore([shippedCard()]);
const manager = new SelfHealingManager(store, { rootDir: "/repo" });
expect(await manager.reconcileDoneTaskIntegrity()).toBe(0);
await manager.reconcileDoneTaskIntegrity();
// The query asked for the legacy literal, NOT this workflow's resolved complete lane.
/*
THE ASSERTION THIS FILE WAS BUILT TO FLIP. It used to read `not.toHaveBeenCalledWith(… complete)`
and passed because the sweep only ever asked for the literal. The sweep now resolves the project's
complete lanes and queries each, so the renamed lane IS asked for.
`done` is STILL expected: `resolveProjectColumnsForRoles` unions the legacy ids deliberately, so a
board mid-rename whose rows are still stored under the old id is not skipped. Over-inclusion costs
one extra query the caller then filters; under-inclusion is invisible.
*/
expect(listTasks).toHaveBeenCalledWith(expect.objectContaining({ column: RENAMED_VOCAB.complete }));
expect(listTasks).toHaveBeenCalledWith(expect.objectContaining({ column: "done" }));
expect(listTasks).not.toHaveBeenCalledWith(expect.objectContaining({ column: RENAMED_VOCAB.complete }));
});
// And the card is untouched: still no commit sha, still unreconciled.
expect((await store.getTask("FN-BLIND"))?.mergeDetails?.commitSha).toBeUndefined();
/*
FNXC:WorkflowResolvedColumns 2026-07-30-18:05 (#2838 review — greptile P1):
A CARD WHOSE WORKFLOW CANNOT BE RESOLVED MUST NOT BE MISTAKEN FOR ONE THAT ANSWERED.
`resolveWorkflowIrForTask` does not throw when a task's selection is unresolvable — it SUBSTITUTES
the built-in coding IR, whose complete lane is `done`. The candidate filter therefore saw a non-empty
`columnsWithFlag(ir, "complete")` and treated the built-in vocabulary as this card's own answer, so a
renamed-lane card was rejected on every sweep and its missing merge evidence stayed unrepaired
forever. The provenance form separates the two, and only a real selection counts as an answer.
WHY THE STORE FAKE DROPS ONLY THE SELECTION READERS. That is precisely the production shape being
modelled: the workflow DEFINITION is fine, the card's link to it is what cannot be read. Deleting the
definition instead would take a different branch and prove nothing about this one.
*/
it("a card whose workflow selection cannot be resolved is not judged by the BUILT-IN complete lane", async () => {
const { store } = productionFaithfulStore([shippedCard()]);
/* No selection for this card: `resolveWorkflowIrForTaskWithProvenance` reports source "default". */
(store as unknown as { getTaskWorkflowSelectionAsync: unknown }).getTaskWorkflowSelectionAsync =
vi.fn(async () => undefined);
(store as unknown as { getTaskWorkflowSelection: unknown }).getTaskWorkflowSelection = vi.fn(() => undefined);
const manager = new SelfHealingManager(store, { rootDir: "/repo" });
const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
let warned = "";
try {
await manager.reconcileDoneTaskIntegrity();
} finally {
/* Read BEFORE restoring: `mockRestore` clears the recorded calls, so reading afterwards yields
an empty string and the assertion fails for a reason that has nothing to do with the code. */
warned = warn.mock.calls.map((call) => String(call[0])).join("\n");
warn.mockRestore();
}
/*
The observable claim: the card is REPORTED as unresolvable rather than silently discarded. The
verdict itself is deliberately unchanged — a sweep that WRITES merge evidence must not guess a lane
— so asserting "it got repaired" would be asserting the wrong fix. What must not survive is the
silence, which is what made this unrepairable-forever instead of merely unrepaired.
*/
expect(warned).toContain("done-task integrity sweep");
expect(warned).toContain("FN-BLIND");
});
it("proves the fake is what hides it: an ignoring `listTasks` hands the sweep rows production would not", async () => {
@@ -160,4 +218,443 @@ describe("self-healing sweeps are bounded by a hardcoded column QUERY, not by th
const { store } = productionFaithfulStore([card]);
expect(await store.listTasks({ column: "done" as never })).toHaveLength(0);
});
/*
FNXC:WorkflowResolvedColumns 2026-07-30-17:20 (#2838 review — greptile P1):
THE PROJECT UNION IS FOR THE QUERY, NEVER FOR THE PER-CARD VERDICT.
Two boards in one project: board A calls its COMPLETE lane `shipped`; board B calls its WIP lane
`shipped`. The project union therefore contains `shipped`, which is correct for the READ — board A's
finished cards must be found. Using that same set as the per-card test claims board B's card as
complete because SOME OTHER workflow calls that column complete, and this sweep WRITES merge evidence
onto whatever it accepts.
Widening the read and widening the verdict are different decisions: a missed row is invisible, a wrong
row is a write.
REVERT CHECK, measured: re-asserting `completeColumns.has(task.column)` instead of resolving each card
against its own workflow fails this case — the mid-implementation card is reconciled.
*/
it("does not claim a card whose OWN workflow calls its column WIP, even when another board calls it complete", async () => {
const boardB = {
version: "v2", id: "board-b", name: "board b",
columns: [
{ id: "planning", name: "Planning", traits: [{ trait: "intake" }, { trait: "hold", config: { release: "capacity" } }] },
/* Same id as board A's COMPLETE lane, but here it is WIP. */
{ id: "shipped", name: "Building", traits: [{ trait: "wip", config: { limitSetting: "maxConcurrent" } }] },
{ id: "closed", name: "Done", traits: [{ trait: "complete" }] },
],
nodes: [{ id: "start", kind: "start", column: "planning" }],
edges: [],
} as unknown as WorkflowIr;
const midImplementation = { ...shippedCard(), id: "FN-WIP" } as Task;
const tasksById = new Map([[midImplementation.id, midImplementation]]);
const store = Object.assign(new EventEmitter(), {
getSettings: vi.fn(async () => ({ globalPause: false, enginePaused: false }) as Settings),
listTasks: vi.fn(async (options?: { column?: string }) => {
const all = [...tasksById.values()];
return options?.column === undefined ? all : all.filter((t) => t.column === options.column);
}),
getTask: vi.fn(async (id: string) => tasksById.get(id)),
updateTask: vi.fn(async (id: string, patch: Partial<Task>) => {
tasksById.set(id, { ...tasksById.get(id)!, ...patch } as Task);
return tasksById.get(id)!;
}),
/* The PROJECT declares both boards, so `shipped` is legitimately in the union. */
listWorkflowDefinitions: vi.fn(async () => [{ ir: RENAMED_IR }, { ir: boardB }]),
/* But THIS card belongs to board B, where `shipped` is WIP. */
getTaskWorkflowSelectionAsync: vi.fn(async () => ({ workflowId: "board-b", stepIds: [] })),
getTaskWorkflowSelection: vi.fn(() => ({ workflowId: "board-b", stepIds: [] })),
getWorkflowDefinition: vi.fn(async (id: string) => (id === "board-b" ? { ir: boardB } : { ir: RENAMED_IR })),
}) as unknown as TaskStore & EventEmitter;
const manager = new SelfHealingManager(store, { rootDir: "/repo" });
await manager.reconcileDoneTaskIntegrity();
/*
ASSERTS CANDIDACY, not the write. My first version asserted `commitSha` stayed undefined, which is
true either way here — the write needs a real git repo, so it never happens in this fixture and the
assertion could not distinguish accepted from rejected. The revert passed and exposed it.
`reconcileDoneTaskIntegrity` returns BEFORE `getSettings()` when the candidate list is empty
(`if (candidates.length === 0) return 0;`), so that call is the observable proof that this card was
NOT accepted as complete.
*/
expect(store.getSettings).not.toHaveBeenCalled();
expect((await store.getTask("FN-WIP"))?.mergeDetails?.commitSha).toBeUndefined();
});
/*
FNXC:WorkflowResolvedColumns 2026-07-30-17:50 (#2838 review — greptile P1, second round):
THE GUESSED-WORKFLOW PATH. `resolveWorkflowIrForTask` returns the BUILT-IN IR when a task names no
workflow, and the built-in complete lane IS `done` — so a naive `columnsWithFlag(ir, "complete")`
yields `["done"]` for a card we could not resolve, the legacy branch never fires, and the card is
rejected on every sweep forever.
Resolution now goes through `...WithProvenance`, so only `source: "selection"` overrules the legacy
check. A card that DOES name a workflow keeps being judged by it; a card that does not falls back to
the legacy id rather than to the built-in board's vocabulary wearing a resolved disguise.
REVERT CHECK, measured: resolving without provenance fails this case — `ownComplete` becomes `["done"]`
from the built-in default, so the card in `done` with no selection is REJECTED and `getSettings` is
never reached.
*/
it("still repairs a legacy `done` card whose workflow selection is missing", async () => {
const legacyCard = { ...shippedCard(), id: "FN-LEGACY", column: "done" } as Task;
const tasksById = new Map([[legacyCard.id, legacyCard]]);
const store = Object.assign(new EventEmitter(), {
getSettings: vi.fn(async () => ({ globalPause: false, enginePaused: false }) as Settings),
listTasks: vi.fn(async (options?: { column?: string }) => {
const all = [...tasksById.values()];
return options?.column === undefined ? all : all.filter((t) => t.column === options.column);
}),
getTask: vi.fn(async (id: string) => tasksById.get(id)),
updateTask: vi.fn(async (id: string, patch: Partial<Task>) => {
tasksById.set(id, { ...tasksById.get(id)!, ...patch } as Task);
return tasksById.get(id)!;
}),
listWorkflowDefinitions: vi.fn(async () => [{ ir: RENAMED_IR }]),
/* NO selection for this card — the state that makes the resolver guess. */
getTaskWorkflowSelectionAsync: vi.fn(async () => undefined),
getTaskWorkflowSelection: vi.fn(() => undefined),
getWorkflowDefinition: vi.fn(async () => undefined),
}) as unknown as TaskStore & EventEmitter;
const manager = new SelfHealingManager(store, { rootDir: "/repo" });
await manager.reconcileDoneTaskIntegrity();
/* Accepted as a candidate: the sweep reached `getSettings`, which it only does with a non-empty list. */
expect(store.getSettings).toHaveBeenCalled();
});
/*
FNXC:WorkflowResolvedColumns 2026-07-30-18:20 (the query-filter class, second sweep):
`recoverAlreadyMergedReviewTasks` rescues a card whose merge ACTUALLY SUCCEEDED but is parked in review
with `status: "failed"`. Its read was `listTasks({ column: "in-review" })`, which returns nothing on a
renamed board — so the rescue never ran and that card stayed stuck permanently.
Asserts the QUERY, like the done-integrity case above and for the same reason: the outcome is 0 either
way, so only the question asked distinguishes fixed from broken. The per-card verdict uses the pattern
already revert-proven for the other sweep.
REVERT CHECK, measured: restoring `listTasks({ column: "in-review" })` fails this — the board's own
review lane is never asked for.
*/
it("the already-merged rescue asks for the board's OWN review lane", async () => {
const parked = {
...shippedCard(),
id: "FN-STUCK",
column: RENAMED_VOCAB.review,
status: "failed",
mergeRetries: 99,
} as unknown as Task;
const { store, listTasks } = productionFaithfulStore([parked]);
await new SelfHealingManager(store, { rootDir: "/repo" }).recoverAlreadyMergedReviewTasks();
expect(listTasks).toHaveBeenCalledWith(expect.objectContaining({ column: RENAMED_VOCAB.review }));
/* The legacy id is still asked for — the project union keeps mid-rename rows reachable. */
expect(listTasks).toHaveBeenCalledWith(expect.objectContaining({ column: "in-review" }));
});
/*
FNXC:WorkflowResolvedColumns 2026-07-30-18:50 (#2838 review — greptile P1, same class as the
done-integrity sweep):
I wrote this sweep before the provenance fix landed on its sibling and reproduced the pre-fix shape
verbatim: `resolveWorkflowIrForTask` SUBSTITUTES the built-in IR rather than failing, so a card whose
workflow could not be resolved was measured against the built-in `in-review`, rejected, and rejected
again on every pass — with nothing recorded.
The verdict stays conservative (this sweep mutates column AND status). What provenance buys is that the
unrescued card is REPORTED, which is the whole difference between a known gap and an invisible one.
REVERT CHECK, measured: resolving without provenance fails this — nothing is warned, because
`own.length > 0` reads the substituted built-in lane as an answer.
*/
it("reports an already-merged card whose workflow could not be resolved", async () => {
const parked = {
...shippedCard(),
id: "FN-UNRESOLVED",
column: RENAMED_VOCAB.review,
status: "failed",
mergeRetries: 99,
} as unknown as Task;
const tasksById = new Map([[parked.id, parked]]);
const store = Object.assign(new EventEmitter(), {
getSettings: vi.fn(async () => ({ globalPause: false, enginePaused: false }) as Settings),
listTasks: vi.fn(async (options?: { column?: string }) => {
const all = [...tasksById.values()];
return options?.column === undefined ? all : all.filter((t) => t.column === options.column);
}),
getTask: vi.fn(async (id: string) => tasksById.get(id)),
updateTask: vi.fn(async () => undefined),
/* The project DOES declare the renamed review lane, so the read finds the card... */
listWorkflowDefinitions: vi.fn(async () => [{ ir: RENAMED_IR }]),
/* ...but THIS card names no workflow, so its own lane vocabulary is unknown. */
getTaskWorkflowSelectionAsync: vi.fn(async () => undefined),
getTaskWorkflowSelection: vi.fn(() => undefined),
getWorkflowDefinition: vi.fn(async () => undefined),
}) as unknown as TaskStore & EventEmitter;
const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
let warned = "";
try {
await new SelfHealingManager(store, { rootDir: "/repo" }).recoverAlreadyMergedReviewTasks();
warned = warn.mock.calls.map((call) => String(call[0])).join("\n");
} finally {
warn.mockRestore();
}
expect(warned).toContain("already-merged review rescue");
expect(warned).toContain("FN-UNRESOLVED");
});
/*
FNXC:WorkflowResolvedColumns 2026-07-30-19:20 (the query-filter class, third sweep):
`recoverStuckMergeDeadlocks` reads FOUR lanes: the review lane for its candidates, and intake/hold/wip
for the DEPENDENTS whose blocked state proves the deadlock. All four were literals, so on a renamed
board the sweep saw no candidates AND no dependents — doubly blind.
Its 2026-07-29-17:40 note reasoned the literal `triage`/`todo` pair was a complete union "and the role
filter below decides which rows count". That held for the default and legacy lineages it considered and
fails on a renamed board, where the reads return nothing and the filter is handed nothing to decide
about. Widening the reads restores the property that note relied on; the filter itself is untouched.
REVERT CHECK, measured: restoring the literal review read fails this — the board's own review lane is
never asked for.
*/
it("the merge-deadlock recovery asks for the board's OWN review and dependent lanes", async () => {
const parked = {
...shippedCard(),
id: "FN-DEADLOCK",
column: RENAMED_VOCAB.review,
status: "failed",
mergeRetries: 99,
worktree: "/tmp/wt",
} as unknown as Task;
const { store, listTasks } = productionFaithfulStore([parked]);
await new SelfHealingManager(store, { rootDir: "/repo" }).recoverStuckMergeDeadlocks();
/* Candidates: the board's own review lane, plus the legacy id the union keeps reachable. */
expect(listTasks).toHaveBeenCalledWith(expect.objectContaining({ column: RENAMED_VOCAB.review }));
expect(listTasks).toHaveBeenCalledWith(expect.objectContaining({ column: "in-review" }));
/* Dependents: the board's own pre-WIP and WIP lanes, not just `triage`/`todo`/`in-progress`. */
expect(listTasks).toHaveBeenCalledWith(expect.objectContaining({ column: RENAMED_VOCAB.hold }));
expect(listTasks).toHaveBeenCalledWith(expect.objectContaining({ column: RENAMED_VOCAB.wip }));
});
/*
FNXC:WorkflowResolvedColumns 2026-07-30-19:50 (the query-filter class, fourth sweep):
`recoverInterruptedMergingTasks` rescues a task interrupted mid-merge — status still `merging`, no live
session behind it. Its read was the literal review lane, so on a renamed board that task sat in
`merging` indefinitely.
Also asserts the LOG, because the old message hardcoded "in in-review" and would have reported a lane
the sweep did not search. A message that names the wrong board is its own small lie.
REVERT CHECK, measured: restoring the literal read fails this — the board's own review lane is never
asked for.
*/
it("the interrupted-merge recovery asks for the board's OWN review lane and names it", async () => {
const stuck = {
...shippedCard(),
id: "FN-MERGING",
column: RENAMED_VOCAB.review,
status: "merging",
updatedAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(),
} as unknown as Task;
const { store, listTasks } = productionFaithfulStore([stuck]);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
globalPause: false,
enginePaused: false,
taskStuckTimeoutMs: 60_000,
} as Settings);
await new SelfHealingManager(store, { rootDir: "/repo" }).recoverInterruptedMergingTasks();
expect(listTasks).toHaveBeenCalledWith(expect.objectContaining({ column: RENAMED_VOCAB.review }));
expect(listTasks).toHaveBeenCalledWith(expect.objectContaining({ column: "in-review" }));
});
/*
FNXC:WorkflowResolvedColumns 2026-07-30-20:20 (the query-filter class, fifth sweep):
`recoverMergeableReviewTasks` re-enqueues a card that is genuinely ready to merge. Its read was the
literal review lane, so on a renamed board that card sat in review forever.
THE INTERESTING PART IS DOWNSTREAM. This sweep's filter calls `getTaskMergeBlocker(t)` — previously
UNWIRED, taking the legacy `in-review` default, and harmless only because the literal query meant a
renamed board never reached it. Widening the read makes that guard REACHABLE for the first time, so
left as-is it would refuse every card on exactly the boards this fix is for: found, then declined.
Converting a query activates every guard downstream of it. This case asserts the end-to-end outcome —
the card is enqueued — precisely because a query-only assertion would have passed while the blocker
silently rejected it.
REVERT CHECK, measured: dropping `{ reviewColumns }` from the blocker call fails this — the card is
found by the widened read and then refused.
*/
it("enqueues a mergeable card on a RENAMED board, past the now-reachable merge blocker", async () => {
const ready = {
...shippedCard(),
id: "FN-READY",
column: RENAMED_VOCAB.review,
status: null,
worktree: "/tmp/wt",
steps: [],
mergeDetails: {},
} as unknown as Task;
const { store } = productionFaithfulStore([ready]);
const enqueueMerge = vi.fn(async () => undefined);
/* Complete the fake: the recovery loop logs before enqueuing, and an incomplete fake turns a real
enqueue into a caught error the assertion cannot see. */
Object.assign(store, {
enqueueMerge,
isMergeLaneOwned: vi.fn(async () => false),
logEntry: vi.fn(async () => undefined),
recordRunAuditEvent: vi.fn(async () => undefined),
});
await new SelfHealingManager(store, { rootDir: "/repo", enqueueMerge } as never)
.recoverMergeableReviewTasks();
/* Not just "the query asked" — the card survived the blocker and was acted on. */
expect(enqueueMerge).toHaveBeenCalled();
});
/*
FNXC:WorkflowResolvedColumns 2026-07-30-21:20 (the query-filter class, sixth sweep — activation check
run FIRST this time):
`recoverReviewTasksWithFailedPreMergeSteps` auto-revives a card parked with a FAILED pre-merge review
step. Its literal read meant that card stayed parked on a renamed board until a human noticed.
This sweep is the sharpest example of part 5 of the shape. Its filter asks
`blocker !== "task has failed pre-merge workflow steps"` — an EXACT STRING match. Unwired on a renamed
board the blocker returns "task is in 'checking', must be in 'in-review'" instead, so widening the query
alone would have made the sweep find every card and then reject every card.
Asserts the END-TO-END outcome (the recover callback fires), not the query, precisely because a
query-only assertion passes while the blocker silently rejects.
REVERT CHECK, measured (each independently):
- literal read restored -> fails, the card is never found
- { reviewColumns } dropped -> fails, the card is found and then rejected by the string compare
*/
it("revives a failed-pre-merge-step card on a RENAMED board, past the now-reachable blocker", async () => {
const parked = {
...shippedCard(),
id: "FN-FAILEDSTEP",
column: RENAMED_VOCAB.review,
status: null,
worktree: "/tmp/wt",
steps: [],
mergeDetails: {},
workflowStepResults: [
{ phase: "pre-merge", source: "optional-group", status: "failed",
workflowStepId: "code-review", workflowStepName: "Code Review",
completedAt: new Date().toISOString() },
],
} as unknown as Task;
const { store } = productionFaithfulStore([parked]);
Object.assign(store, { logEntry: vi.fn(async () => undefined) });
const recoverFailedPreMergeStep = vi.fn(async () => true);
await new SelfHealingManager(store, { rootDir: "/repo", recoverFailedPreMergeStep } as never)
.recoverReviewTasksWithFailedPreMergeSteps();
expect(recoverFailedPreMergeStep).toHaveBeenCalled();
});
/*
FNXC:WorkflowResolvedColumns 2026-07-30-22:00 (the query-filter class, seventh sweep):
`finalizeNoOpReviewTasks` finalises a task whose branch has NO commits ahead of base — a genuine no-op
merge. Its literal read meant such a task sat in review forever on a renamed board.
One of the four sweeps holding both a literal query and an unwired `getTaskMergeBlocker`, so the guard
is wired in the same change: widening the read alone would have found the card and declined it.
REVERT CHECK, measured (each independently):
- literal read restored -> the card is never found
- { reviewColumns } dropped -> the card is found and then declined by the blocker
*/
it("finalizes a no-op card on a RENAMED board, past the now-reachable blocker", async () => {
const noOp = {
...shippedCard(),
id: "FN-NOOP",
column: RENAMED_VOCAB.review,
status: null,
worktree: "/tmp/wt",
steps: [],
mergeDetails: {},
} as unknown as Task;
const { store, listTasks } = productionFaithfulStore([noOp]);
Object.assign(store, { logEntry: vi.fn(async () => undefined) });
const manager = new SelfHealingManager(store, { rootDir: "/repo" });
/*
ASSERTS CANDIDACY. My first version asserted `getSettings` was called — which the sweep does
unconditionally on its first line, so it proved nothing. `isBranchAheadOfBase` runs ONCE PER
CANDIDATE, after the filter, so it is the first observable that separates "found and accepted"
from "found and declined by the blocker".
*/
const aheadCheck = vi
.spyOn(manager as unknown as { isBranchAheadOfBase: (t: Task, b: string) => Promise<boolean> },
"isBranchAheadOfBase")
.mockResolvedValue(false);
await manager.finalizeNoOpReviewTasks();
expect(listTasks).toHaveBeenCalledWith(expect.objectContaining({ column: RENAMED_VOCAB.review }));
expect(aheadCheck).toHaveBeenCalled();
});
/*
FNXC:WorkflowResolvedColumns 2026-07-30-22:40 (the query-filter class, eighth sweep):
`recoverCompletionHandoffLimbo` clears a task falsely marked completion-handoff-exhausted while the
merge queue already owns it. Its literal read meant such a task stayed wedged on a renamed board.
ASSERTS CANDIDACY, per the rule this file's siblings had to learn four times: `isMergeLaneOwned` runs
once per row that has already passed BOTH the lane test and the merge blocker, so it is the first
observable separating "found and accepted" from "found and skipped". `getSettings` would not do — the
sweep calls it on its first line.
REVERT CHECK, measured (each independently):
- literal read restored -> never reached, the card is not found
- { reviewColumns } dropped -> the card is found and then skipped by the blocker
*/
it("reaches a limbo card on a RENAMED board, past the now-reachable blocker", async () => {
const wedged = {
...shippedCard(),
id: "FN-LIMBO",
column: RENAMED_VOCAB.review,
status: null,
worktree: "/tmp/wt",
steps: [],
/* The limbo gate requires status/mergeDetails/review/reviewState ALL null — `{}` is not null. */
mergeDetails: undefined,
log: [{ action: "Task marked done by agent", timestamp: new Date(Date.now() - 86_400_000).toISOString() }],
} as unknown as Task;
const { store } = productionFaithfulStore([wedged]);
Object.assign(store, { logEntry: vi.fn(async () => undefined) });
const manager = new SelfHealingManager(store, { rootDir: "/repo" });
vi.spyOn(manager as unknown as { isMergeLaneOwned: (id: string) => Promise<boolean> }, "isMergeLaneOwned")
.mockResolvedValue(false);
/*
DOWNSTREAM of the blocker, deliberately. `isMergeLaneOwned` runs BEFORE it, so spying there would
prove the read and say nothing about the wiring — an observable upstream of the thing under test is
the same vacuity in a new costume. `recoverApprovedStrandedAiMergeCommit` is the first call after the
blocker check.
*/
const pastBlocker = vi
.spyOn(manager as unknown as {
recoverApprovedStrandedAiMergeCommit: (t: Task, s: unknown) => Promise<boolean>;
}, "recoverApprovedStrandedAiMergeCommit")
.mockResolvedValue(true);
await manager.recoverCompletionHandoffLimbo();
expect(pastBlocker).toHaveBeenCalled();
});
});

View File

@@ -30,7 +30,10 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync,
import { readFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { isAbsolute, join, relative, resolve } from "node:path";
import { resolveColumnFlags, IN_REVIEW_STALL_DEADLOCK_LOG_PREFIX, IN_REVIEW_STALL_LOG_PREFIX, IN_REVIEW_STALL_TERMINAL_LOG_PREFIX, allowsAutoMergeProcessing, resolveEffectiveAutoMerge, countRecentIdenticalStallEntries, detectDependencyCycle, detectSelfDefeatingDependency, evaluateNoCommitsNoOpFinalize, evaluateCompletedPromotionFailureProvenance, evaluateSkipBypassTaint, getInReviewStalledSignal, getInReviewStallReason, getPrimaryPrInfo, getStalePausedReviewSignal, getStalePausedTodoSignal, getTaskHardMergeBlocker, getTaskMergeBlocker, isEphemeralAgent, isMergeRequestContractShadowEnabled, isWorkspaceTask, isSharedBranchGroupMemberIntegration, isNearDuplicateCanonicalInactive, parseExplicitDuplicateMarker, flagTriageDuplicate, isTriageDuplicateKeepAcknowledged, resolveMaxAutoMergeRetries, resolveOptionalStepRevisionBudget, resolveOptionalReviewRevisionBudget, getBuiltinWorkflow, isBuiltinWorkflowId, resolveWorkflowIrForTask, resolveReboundTarget, columnsWithFlag, resolveLifecycleColumns, resolveTaskLifecycleColumns, workflowHasColumn, planLegacyAdoption, resolveOrphanedPendingStepResults, classifyReviewLease, PLAN_REVIEW_LEASE_STALENESS_MS, DEFAULT_MAX_POST_REVIEW_FIXES, ACTIVE_WORKFLOW_WORK_ITEM_STATES, AWAITING_APPROVAL_PAUSE_REASON, type Agent, type AgentStore, type ChatStore, type MessageStore, type TaskStore, type Settings, type Task, type MergeDetails, type TaskPriority, type MergeResult, type WorkflowStepResult, type WorkflowIr } from "@fusion/core";
import { resolveColumnFlags, IN_REVIEW_STALL_DEADLOCK_LOG_PREFIX, IN_REVIEW_STALL_LOG_PREFIX, IN_REVIEW_STALL_TERMINAL_LOG_PREFIX, allowsAutoMergeProcessing, resolveEffectiveAutoMerge, countRecentIdenticalStallEntries, detectDependencyCycle, detectSelfDefeatingDependency, evaluateNoCommitsNoOpFinalize, evaluateCompletedPromotionFailureProvenance, evaluateSkipBypassTaint, getInReviewStalledSignal, getInReviewStallReason, getPrimaryPrInfo, getStalePausedReviewSignal, getStalePausedTodoSignal, getTaskHardMergeBlocker, getTaskMergeBlocker, isEphemeralAgent, isMergeRequestContractShadowEnabled, isWorkspaceTask, isSharedBranchGroupMemberIntegration, isNearDuplicateCanonicalInactive, parseExplicitDuplicateMarker, flagTriageDuplicate, isTriageDuplicateKeepAcknowledged, resolveMaxAutoMergeRetries, resolveOptionalStepRevisionBudget, resolveOptionalReviewRevisionBudget, getBuiltinWorkflow, isBuiltinWorkflowId, resolveWorkflowIrForTask, resolveWorkflowIrForTaskWithProvenance, resolveReboundTarget, columnsWithFlag, resolveLifecycleColumns, resolveTaskLifecycleColumns, workflowHasColumn, planLegacyAdoption, resolveOrphanedPendingStepResults, classifyReviewLease, PLAN_REVIEW_LEASE_STALENESS_MS, DEFAULT_MAX_POST_REVIEW_FIXES, ACTIVE_WORKFLOW_WORK_ITEM_STATES, AWAITING_APPROVAL_PAUSE_REASON, type Agent, type AgentStore, type ChatStore, type MessageStore, type TaskStore, type Settings, type Task, type MergeDetails, type TaskPriority, type MergeResult, type WorkflowStepResult, type WorkflowIr,
resolveProjectColumnsForRoles,
REVIEW_ROLES,
} from "@fusion/core";
import { finalizePlanningSegment } from "@fusion/core";
import type { MeshLeaseManager } from "./mesh-lease-manager.js";
import { createLogger, schedulerLog } from "./logger.js";
@@ -7062,9 +7065,46 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
try {
const settings = await this.store.getSettings();
if (settings.globalPause || settings.enginePaused) return 0;
const tasks = await this.store.listTasks({ column: "in-review", slim: true });
/*
FNXC:WorkflowResolvedColumns 2026-07-30-22:00 (the query-filter class, seventh sweep):
`listTasks({ column: "in-review" })` returned EMPTY on a renamed board, so a task whose branch has
NO commits ahead of base — a genuine no-op merge — was never finalised and sat in review forever.
Activation check run first: this sweep is one of the four holding both a literal query and an
unwired `getTaskMergeBlocker`. Widening the read alone would have made it find renamed-board cards
and then decline every one, so the guard is wired in the same change.
Lane set precomputed per card so the candidate filter stays synchronous; one resolution feeds both
the lane test and the blocker.
*/
const noOpReviewColumns = await resolveProjectColumnsForRoles(this.store, REVIEW_ROLES);
const noOpById = new Map<string, Task>();
for (const column of noOpReviewColumns) {
for (const task of await this.store.listTasks({ column, slim: true })) noOpById.set(task.id, task);
}
const tasks = [...noOpById.values()];
const noOpIrCache = new Map<string, WorkflowIr>();
const unresolvedNoOpCards: string[] = [];
const noOpLanesByTask = new Map<string, ReadonlySet<string>>();
for (const task of tasks) {
const resolved = await resolveWorkflowIrForTaskWithProvenance(this.store, task.id, noOpIrCache)
.catch(() => undefined);
const own = resolved
? [...new Set(REVIEW_ROLES.flatMap((role) => columnsWithFlag(resolved.ir, role)))]
: [];
if (!resolved || resolved.source === "default") unresolvedNoOpCards.push(task.id);
/* DELIBERATE-LITERAL — the unresolvable-workflow default, reviewed 2026-07-30-22:00. */
noOpLanesByTask.set(task.id, own.length > 0 ? new Set(own) : new Set(["in-review"]));
}
if (unresolvedNoOpCards.length > 0) {
log.warn(
`no-op review finalize: ${unresolvedNoOpCards.length} card(s) measured against the built-in `
+ `review lane because their own workflow could not be resolved `
+ `(${unresolvedNoOpCards.slice(0, 5).join(", ")}); a renamed review lane there stays unfinalised.`,
);
}
const candidates = tasks.filter((t) =>
t.column === "in-review" &&
(noOpLanesByTask.get(t.id) ?? new Set(["in-review"])).has(t.column) &&
allowsAutoMergeProcessing(t, settings) &&
!t.paused &&
// FNXC:AutoMergeHold 2026-07-09-17:10: FN-7750 intentionally keeps the pure branchContext-shape predicate here. Stale shared-group members must stay OUT of solo no-op finalize even when their group is not live; only the positive auto-merge-off exemption gates use the live-group predicate.
@@ -7081,7 +7121,8 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
t.status !== "merging-pr" &&
t.status !== "awaiting-user-review" &&
t.status !== "failed" &&
getTaskMergeBlocker(t) === undefined,
/* Wired: unwired, this would decline every card the widened read now finds. */
getTaskMergeBlocker(t, { reviewColumns: noOpLanesByTask.get(t.id) ?? new Set(["in-review"]) }) === undefined,
);
if (candidates.length === 0) return 0;
@@ -7265,12 +7306,100 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
async reconcileDoneTaskIntegrity(): Promise<number> {
try {
const tasks = await this.store.listTasks({ column: "done", slim: true });
const candidates = tasks.filter((task) =>
task.column === "done" &&
/*
FNXC:WorkflowResolvedColumns 2026-07-30-16:50 (the query-filter class, now unblocked):
THE QUERY WAS THE BUG, not the comparison below it. `listTasks({ column: "done" })` returns an
EMPTY array on a board whose complete lane is renamed, so this sweep never ran — proven in
`self-healing-query-filter-blindness.test.ts` (#2800), which asserts the query ARGUMENT because
the outcome is 0 either way.
`resolveProjectColumnsForRoles` is the seam that fix needed: a read happens before any task is in
hand, so there is nothing to resolve a per-task lane from. It answers the PROJECT-level question —
every column any workflow here declares for the role — and unions the legacy ids, so a board
mid-rename still surfaces rows stored under the old one.
Same three-line shape as `stale-task-reporter` and `backlog-pressure-reporter`: resolve the roles,
iterate the set, dedupe by id.
FNXC:WorkflowResolvedColumns 2026-07-30-17:20 (#2838 review — greptile P1):
THE PROJECT UNION IS FOR THE QUERY, NEVER FOR THE PER-CARD TEST. My first version re-asserted
`completeColumns.has(task.column)`, which is the flat-set mistake `project-lane-vocabulary.ts`
warns about in its own header — a card is claimed as complete because SOME OTHER workflow in the
project calls its column complete. On a project with two boards, one naming its wip lane the same
as another's complete lane, this sweep would rewrite merge evidence onto a card still being worked.
Widening the read and widening the verdict are different decisions. The read must over-include
(a missed row is invisible); the verdict must not (a wrong row is a write). So the candidate filter
resolves each card against ITS OWN workflow.
*/
const completeColumns = await resolveProjectColumnsForRoles(this.store, ["complete"]);
const byId = new Map<string, Task>();
for (const column of completeColumns) {
for (const task of await this.store.listTasks({ column, slim: true })) byId.set(task.id, task);
}
const shortlist = [...byId.values()].filter((task) =>
(!task.mergeDetails?.commitSha || task.mergeDetails.commitSha.trim().length === 0) &&
(task.modifiedFiles?.length ?? 0) > 0,
).slice(0, DONE_TASK_INTEGRITY_SWEEP_LIMIT);
);
const perTaskIrCache = new Map<string, WorkflowIr>();
const candidates: Task[] = [];
/* Cards whose own workflow could not be resolved. Collected so an unrepairable backlog is
reportable rather than silent — see the provenance note in the loop. */
const unresolvedWorkflowCards: string[] = [];
for (const task of shortlist) {
if (candidates.length >= DONE_TASK_INTEGRITY_SWEEP_LIMIT) break;
/*
FNXC:WorkflowResolvedColumns 2026-07-30-17:55 (#2838 review — greptile P1, "workflow fallback
drops renamed completions"):
PROVENANCE, BECAUSE THIS RESOLVER DOES NOT FAIL — IT SUBSTITUTES.
`resolveWorkflowIrForTask` degrades to the BUILT-IN coding IR rather than throwing, so the
`.catch(() => undefined)` protected almost nothing: when a task's selection cannot be resolved
the call SUCCEEDS and hands back a board whose complete lane is `done`. The old line then read
`ownComplete.length > 0` as "this card answered", so a renamed-lane card was measured against
the built-in vocabulary, rejected, and — because this sweep is the thing that would have
repaired its missing merge evidence — rejected again on every subsequent sweep.
The provenance form separates "the card's own workflow says complete = X" from "nobody could
say, here is the default". Only the first is an answer.
THE VERDICT IS DELIBERATELY UNCHANGED, AND I MEASURED THAT RATHER THAN ASSUMING IT. My first
version also gated `ownComplete` on the provenance, which READS like the fix and is inert: a
defaulted card resolves to the built-in `complete = ["done"]`, and gating it to `[]` falls
through to the literal `done` — the same verdict by a different route. Mutating the gate away
left the suite green, which is how I found it. Shipping it would have been a conversion that
scores as a win and changes nothing, so it is gone.
The verdict SHOULD stay conservative here: this sweep WRITES merge evidence, and guessing a lane
to rewrite history onto the wrong card is worse than leaving one unrepaired. What the provenance
buys is the thing that was actually missing — the unresolvable card is now REPORTED instead of
being indistinguishable from a card that answered, so "unrepaired" stops meaning "unrepairable
and invisible forever".
*/
const resolved = await resolveWorkflowIrForTaskWithProvenance(this.store, task.id, perTaskIrCache)
.catch(() => undefined);
const ownComplete = resolved ? columnsWithFlag(resolved.ir, "complete") : [];
/* A THROW is unresolvable too. Checking only `source === "default"` left the rarer path — the
resolver raising rather than substituting — silently discarded, which is the same invisibility
this change exists to remove, one branch over. */
if (!resolved || resolved.source === "default") unresolvedWorkflowCards.push(task.id);
/* DELIBERATE-LITERAL — the degraded per-card default, reviewed 2026-07-30-17:20. Reached when the
card's own workflow could not be resolved AT ALL, or resolves and declares no complete lane. The
project union must not stand in here: that is the flat-set mistake this filter exists to avoid,
so the legacy id is the conservative answer. The census ratchet flagged this line when it
appeared, which is the guard working. */
const isComplete = ownComplete.length > 0 ? ownComplete.includes(task.column) : task.column === "done";
if (isComplete) candidates.push(task);
}
if (unresolvedWorkflowCards.length > 0) {
log.warn(
`done-task integrity sweep: ${unresolvedWorkflowCards.length} card(s) measured against the `
+ `built-in complete lane because their own workflow could not be resolved `
+ `(${unresolvedWorkflowCards.slice(0, 5).join(", ")}); a renamed completion lane there stays unrepaired.`,
);
}
if (candidates.length === 0) return 0;
const settings = await this.store.getSettings();
@@ -7412,7 +7541,38 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
const settings = await this.store.getSettings();
if (settings.globalPause || settings.enginePaused) return 0;
const maxAutoMergeRetries = resolveMaxAutoMergeRetries(settings);
const tasks = await this.store.listTasks({ column: "in-review", slim: true });
/*
FNXC:WorkflowResolvedColumns 2026-07-30-20:20 (the query-filter class, fifth sweep):
Fifth application of the four-part shape in
`docs/solutions/architecture-patterns/self-healing-sweeps-are-blind-on-a-renamed-board.md`.
`listTasks({ column: "in-review" })` returns EMPTY on a renamed board, so a card that is genuinely
ready to merge — every step done, no blocker — was never re-enqueued and sat in review forever.
Read via the project union; verdict per card against its own workflow, because this sweep enqueues
a MERGE; provenance so an unresolvable card is reported rather than silently skipped.
*/
const mergeableReviewColumns = await resolveProjectColumnsForRoles(this.store, REVIEW_ROLES);
const mergeableById = new Map<string, Task>();
for (const column of mergeableReviewColumns) {
for (const task of await this.store.listTasks({ column, slim: true })) mergeableById.set(task.id, task);
}
const tasks = [...mergeableById.values()];
const mergeableIrCache = new Map<string, WorkflowIr>();
const unresolvedMergeableCards: string[] = [];
/*
Returns the card's OWN review lanes, so one resolution feeds BOTH consumers below — the lane test
and `getTaskMergeBlocker`. Two resolutions of the same fact is how the halves of one decision drift.
*/
const ownReviewLanesFor = async (task: Task): Promise<ReadonlySet<string>> => {
const resolved = await resolveWorkflowIrForTaskWithProvenance(this.store, task.id, mergeableIrCache)
.catch(() => undefined);
const own = resolved
? [...new Set(REVIEW_ROLES.flatMap((role) => columnsWithFlag(resolved.ir, role)))]
: [];
if (!resolved || resolved.source === "default") unresolvedMergeableCards.push(task.id);
/* DELIBERATE-LITERAL — the unresolvable-workflow default, reviewed 2026-07-30-20:20. */
return own.length > 0 ? new Set(own) : new Set(["in-review"]);
};
/*
FNXC:WorkflowReviewGates 2026-07-26-15:30:
Liveness gate, mirroring `recoverGhostReviewTasks` and
@@ -7432,7 +7592,6 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
const executingIds = this.options.getExecutingTaskIds?.() ?? new Set<string>();
const mergeable = tasks.filter((t) =>
t.column === "in-review" &&
allowsAutoMergeProcessing(t, settings) &&
!t.paused &&
!executingIds.has(t.id) &&
@@ -7455,13 +7614,38 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
// refreshes updatedAt, preventing cooldown-based retries from ever
// becoming eligible. Also skip tasks explicitly tagged as no-op merges
// in case updateTask(moveTask) is briefly out-of-order during recovery.
(t.mergeRetries ?? 0) < maxAutoMergeRetries &&
getTaskMergeBlocker(t) === undefined,
(t.mergeRetries ?? 0) < maxAutoMergeRetries,
);
/*
FNXC:WorkflowResolvedColumns 2026-07-30-20:20 (widening a query makes a dormant guard REACHABLE):
`getTaskMergeBlocker` moved out of the synchronous filter above because it needs this card's
resolved review lanes, and those need an await.
This matters beyond tidiness. That call was previously UNWIRED — it took the legacy `in-review`
default — and was harmless only because the literal query above meant a renamed board never reached
it. Widening the read makes it reachable for the first time, so left as-is it would have refused
every card on exactly the boards this fix is for: the sweep would find them and then decline them.
Converting a query is therefore not just a read change; it activates every guard downstream of it.
*/
const laneQualifiedMergeable: Task[] = [];
for (const task of mergeable) {
const reviewColumns = await ownReviewLanesFor(task);
if (!reviewColumns.has(task.column)) continue;
if (getTaskMergeBlocker(task, { reviewColumns }) !== undefined) continue;
laneQualifiedMergeable.push(task);
}
if (unresolvedMergeableCards.length > 0) {
log.warn(
`mergeable-review recovery: ${unresolvedMergeableCards.length} card(s) measured against the `
+ `built-in review lane because their own workflow could not be resolved `
+ `(${unresolvedMergeableCards.slice(0, 5).join(", ")}); a renamed review lane there stays stuck.`,
);
}
const ownershipFlags = await Promise.all(
mergeable.map((task) => this.isMergeLaneOwned(task.id)),
laneQualifiedMergeable.map((task) => this.isMergeLaneOwned(task.id)),
);
const unownedMergeable = mergeable.filter((_, i) => !ownershipFlags[i]);
const unownedMergeable = laneQualifiedMergeable.filter((_, i) => !ownershipFlags[i]);
const inReviewIds = new Set(tasks.map((task) => task.id));
const mergeableIds = new Set(unownedMergeable.map((task) => task.id));
@@ -7473,7 +7657,9 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
if (unownedMergeable.length === 0) return 0;
log.warn(`Found ${unownedMergeable.length} mergeable review task(s) stuck in in-review`);
log.warn(
`Found ${unownedMergeable.length} mergeable review task(s) stuck in ${[...mergeableReviewColumns].join(", ")}`,
);
// Prefer the engine's merge queue so `mergeStrategy` (direct vs.
// pull-request) is honored. Fall back to a direct store merge only
@@ -7555,7 +7741,48 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
const settings = await this.store.getSettings();
if (settings.globalPause || settings.enginePaused) return 0;
const tasks = await this.store.listTasks({ column: "in-review", slim: true });
/*
FNXC:WorkflowResolvedColumns 2026-07-30-21:20 (the query-filter class, sixth sweep — and the first
converted with the ACTIVATION check run FIRST, per
`docs/solutions/architecture-patterns/self-healing-sweeps-are-blind-on-a-renamed-board.md`):
`listTasks({ column: "in-review" })` returned EMPTY on a renamed board, so a card parked with a
FAILED pre-merge review step was never auto-revived and stayed parked until a human noticed.
The activation check mattered here more than anywhere so far. This sweep's filter asks
`blocker !== "task has failed pre-merge workflow steps"` — an EXACT STRING match. Unwired on a
renamed board the blocker instead returns "task is in 'checking', must be in 'in-review'", which is
not that string, so every card would be rejected the moment the widened query started finding them.
Wiring the blocker is therefore part of this conversion, not a follow-up.
*/
const failedStepReviewColumns = await resolveProjectColumnsForRoles(this.store, REVIEW_ROLES);
const failedStepById = new Map<string, Task>();
for (const column of failedStepReviewColumns) {
for (const task of await this.store.listTasks({ column, slim: true })) failedStepById.set(task.id, task);
}
const tasks = [...failedStepById.values()];
const failedStepIrCache = new Map<string, WorkflowIr>();
const unresolvedFailedStepCards: string[] = [];
/* One resolution per card, feeding BOTH the lane test and the blocker below. */
const ownReviewLanesForFailedStep = async (task: Task): Promise<ReadonlySet<string>> => {
const resolved = await resolveWorkflowIrForTaskWithProvenance(this.store, task.id, failedStepIrCache)
.catch(() => undefined);
const own = resolved
? [...new Set(REVIEW_ROLES.flatMap((role) => columnsWithFlag(resolved.ir, role)))]
: [];
if (!resolved || resolved.source === "default") unresolvedFailedStepCards.push(task.id);
/* DELIBERATE-LITERAL — the unresolvable-workflow default, reviewed 2026-07-30-21:20. */
return own.length > 0 ? new Set(own) : new Set(["in-review"]);
};
const reviewLanesByTask = new Map<string, ReadonlySet<string>>();
for (const task of tasks) reviewLanesByTask.set(task.id, await ownReviewLanesForFailedStep(task));
if (unresolvedFailedStepCards.length > 0) {
log.warn(
`failed-pre-merge-step revival: ${unresolvedFailedStepCards.length} card(s) measured against the `
+ `built-in review lane because their own workflow could not be resolved `
+ `(${unresolvedFailedStepCards.slice(0, 5).join(", ")}); a renamed review lane there stays parked.`,
);
}
const executingIds = this.options.getExecutingTaskIds?.() ?? new Set<string>();
const latestFailedPreMergeStep = (task: Pick<Task, "workflowStepResults">): WorkflowStepResult | undefined => {
@@ -7643,7 +7870,8 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
};
const candidates = tasks.filter((task) => {
if (task.column !== "in-review") return false;
/* Precomputed above, so this filter stays synchronous. */
if (!(reviewLanesByTask.get(task.id) ?? new Set(["in-review"])).has(task.column)) return false;
if (!allowsAutoMergeProcessing(task, settings)) return false;
if (task.paused) return false;
/*
@@ -7666,7 +7894,11 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
// Merge must be blocked *specifically* by the failed pre-merge step —
// not by an unrelated condition (incomplete steps, etc.) that is
// already handled by a dedicated scan.
const blocker = getTaskMergeBlocker(task);
/* Wired: this comparison is an EXACT STRING match, so an unwired blocker returning
"task is in '<lane>', must be in 'in-review'" would reject every card on a renamed board. */
const blocker = getTaskMergeBlocker(task, {
reviewColumns: reviewLanesByTask.get(task.id) ?? new Set(["in-review"]),
});
if (!parkedRemediationFailure && blocker !== "task has failed pre-merge workflow steps") return false;
// The retry flow injects into PROMPT.md + re-executes on the worktree.
@@ -8401,23 +8633,62 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
const timeoutMs = settings.taskStuckTimeoutMs;
if (!timeoutMs || timeoutMs <= 0) return 0;
const tasks = await this.store.listTasks({ column: "in-review", slim: true });
const statusCandidates = tasks.filter((task) =>
task.column === "in-review" &&
/*
FNXC:WorkflowResolvedColumns 2026-07-30-19:50 (the query-filter class, fourth sweep):
Same shape as its three siblings: `listTasks({ column: "in-review" })` returns EMPTY on a renamed
board, so a task interrupted mid-merge — status still `merging`, no live session behind it — was
never recovered and sat in that state indefinitely.
Project union for the READ; per-card verdict for the MUTATION, since this sweep rewrites status and
clears merge state. Provenance so an unresolvable card is reported rather than silently skipped —
identical to `recoverAlreadyMergedReviewTasks` and `recoverStuckMergeDeadlocks`, so the four cannot
drift apart.
*/
const interruptedReviewColumns = await resolveProjectColumnsForRoles(this.store, REVIEW_ROLES);
const interruptedById = new Map<string, Task>();
for (const column of interruptedReviewColumns) {
for (const task of await this.store.listTasks({ column, slim: true })) interruptedById.set(task.id, task);
}
const interruptedIrCache = new Map<string, WorkflowIr>();
const unresolvedInterruptedCards: string[] = [];
const inOwnReviewLaneForInterrupted = async (task: Task): Promise<boolean> => {
const resolved = await resolveWorkflowIrForTaskWithProvenance(this.store, task.id, interruptedIrCache)
.catch(() => undefined);
const own = resolved
? [...new Set(REVIEW_ROLES.flatMap((role) => columnsWithFlag(resolved.ir, role)))]
: [];
if (!resolved || resolved.source === "default") unresolvedInterruptedCards.push(task.id);
/* DELIBERATE-LITERAL — the unresolvable-workflow default, reviewed 2026-07-30-19:50. */
return own.length > 0 ? own.includes(task.column) : task.column === "in-review";
};
const statusCandidates = [...interruptedById.values()].filter((task) =>
allowsAutoMergeProcessing(task, settings) &&
!task.paused &&
Boolean(task.status && ACTIVE_MERGE_STATUSES.has(task.status)),
);
const candidates: Task[] = [];
for (const task of statusCandidates) {
if (!(await inOwnReviewLaneForInterrupted(task))) continue;
if (await this.isPastInterruptedMergeGraceAsync(task, timeoutMs)) {
candidates.push(task);
}
}
if (unresolvedInterruptedCards.length > 0) {
log.warn(
`interrupted-merge recovery: ${unresolvedInterruptedCards.length} card(s) measured against the `
+ `built-in review lane because their own workflow could not be resolved `
+ `(${unresolvedInterruptedCards.slice(0, 5).join(", ")}); a renamed review lane there stays stuck.`,
);
}
if (candidates.length === 0) return 0;
log.warn(`Found ${candidates.length} stale merging task(s) in in-review`);
/* Names the lanes actually searched rather than the literal `in-review`, which is no longer what
was queried and would misreport the board this ran against. */
log.warn(
`Found ${candidates.length} stale merging task(s) in ${[...interruptedReviewColumns].join(", ")}`,
);
let recovered = 0;
for (const task of candidates) {
@@ -9408,22 +9679,54 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
if (settings.globalPause || settings.enginePaused) return 0;
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.
FNXC:WorkflowResolvedColumns 2026-07-30-19:20 (the query-filter class, third sweep):
FOUR reads, two different jobs, and only the reads were broken.
`inReview` supplies the CANDIDATES — cards blocked on merge — so it needs both halves: the project
union for the read, and a per-card verdict against the card's own workflow, because this sweep
mutates. Same shape as `recoverAlreadyMergedReviewTasks`, provenance and reporting included.
The other three supply DEPENDENTS, and their verdict is already correct: `filterByPreWipRole`
resolves intake/hold per card below. The 2026-07-29-17:40 note reasoned that the literal trio was a
complete union "and the role filter below decides which rows count" — which held for the default and
legacy lineages it considered, and does not hold for a RENAMED board, where all three reads return
empty and the role filter is handed nothing to decide about. Widening the reads restores exactly the
property that note relied on; the filter it points at is unchanged.
*/
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 });
const reviewColumnsForDeadlock = await resolveProjectColumnsForRoles(this.store, REVIEW_ROLES);
const inReviewById = new Map<string, Task>();
for (const column of reviewColumnsForDeadlock) {
for (const task of await this.store.listTasks({ column, slim: true })) inReviewById.set(task.id, task);
}
const deadlockIrCache = new Map<string, WorkflowIr>();
const unresolvedDeadlockCards: string[] = [];
const inOwnReviewLaneForDeadlock = async (task: Task): Promise<boolean> => {
const resolved = await resolveWorkflowIrForTaskWithProvenance(this.store, task.id, deadlockIrCache)
.catch(() => undefined);
const own = resolved
? [...new Set(REVIEW_ROLES.flatMap((role) => columnsWithFlag(resolved.ir, role)))]
: [];
if (!resolved || resolved.source === "default") unresolvedDeadlockCards.push(task.id);
/* DELIBERATE-LITERAL — the unresolvable-workflow default, reviewed 2026-07-30-19:20. */
return own.length > 0 ? own.includes(task.column) : task.column === "in-review";
};
const inReview = [...inReviewById.values()];
/* Dependents: read every pre-WIP and WIP lane the project declares; `filterByPreWipRole` below
still decides per card which of them actually count. */
const dependentSourceColumns = await resolveProjectColumnsForRoles(
this.store,
["intake", "hold", "countsTowardWip"],
);
const dependentsById = new Map<string, Task>();
for (const column of dependentSourceColumns) {
for (const task of await this.store.listTasks({ column, slim: true })) dependentsById.set(task.id, task);
}
const dependentSources = [...dependentsById.values()];
const dependentsByBlocker = new Map<string, Task[]>();
for (const task of [...triage, ...todo, ...inProgress]) {
for (const task of dependentSources) {
if (!task.blockedBy) continue;
const dependents = dependentsByBlocker.get(task.blockedBy) ?? [];
dependents.push(task);
@@ -9449,8 +9752,9 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
const hasBlockedDependents = (dependentsByBlocker.get(task.id) ?? []).some(
(dep) => preWipDependentIds.has(dep.id),
);
return task.column === "in-review" &&
allowsAutoMergeProcessing(task, settings) &&
/* Lane identity is decided per card by `inOwnReviewLaneForDeadlock` below — this synchronous
filter keeps every other condition, so the async check runs only for rows that already qualify. */
return allowsAutoMergeProcessing(task, settings) &&
!task.paused &&
task.status === "failed" &&
(task.mergeRetries ?? 0) >= maxAutoMergeRetries &&
@@ -9459,10 +9763,23 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
cooldownElapsed >= DEADLOCK_RECOVERY_COOLDOWN_MS;
});
if (candidates.length === 0) return 0;
/* The per-card lane verdict is async, so it cannot live in the filter above. */
const laneQualified: Task[] = [];
for (const task of candidates) {
if (await inOwnReviewLaneForDeadlock(task)) laneQualified.push(task);
}
if (unresolvedDeadlockCards.length > 0) {
log.warn(
`merge-deadlock recovery: ${unresolvedDeadlockCards.length} card(s) measured against the `
+ `built-in review lane because their own workflow could not be resolved `
+ `(${unresolvedDeadlockCards.slice(0, 5).join(", ")}); a renamed review lane there stays deadlocked.`,
);
}
if (laneQualified.length === 0) return 0;
let recovered = 0;
for (const task of candidates) {
for (const task of laneQualified) {
if (task.deletedAt) continue;
const blockedDependents = dependentsByBlocker.get(task.id) ?? [];
const blockedTaskIds = blockedDependents.map((dep) => dep.id);
@@ -9819,10 +10136,65 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
if (settings.globalPause || settings.enginePaused) return 0;
const maxAutoMergeRetries = resolveMaxAutoMergeRetries(settings);
const executingIds = this.options.getExecutingTaskIds?.() ?? new Set<string>();
const tasks = await this.store.listTasks({ column: "in-review", slim: true });
const candidates = tasks.filter((task) =>
/*
FNXC:WorkflowResolvedColumns 2026-07-30-18:20 (the query-filter class, second sweep):
THE QUERY WAS THE BUG. `listTasks({ column: "in-review" })` returns EMPTY on a board whose review
lane is renamed, so this sweep never ran — and it is the one that rescues a card whose merge
ACTUALLY SUCCEEDED but is parked in review with `status: "failed"`. Unrun, that card stays stuck
permanently with a merge nobody reconciles.
Read widened via the project union (a read happens before any task is in hand); the per-card verdict
below is resolved against THAT CARD's own workflow, because this sweep mutates. Widening the read
and widening the verdict are different decisions — see `reconcileDoneTaskIntegrity`.
*/
const reviewColumns = await resolveProjectColumnsForRoles(this.store, REVIEW_ROLES);
const byId = new Map<string, Task>();
for (const column of reviewColumns) {
for (const task of await this.store.listTasks({ column, slim: true })) byId.set(task.id, task);
}
const reviewIrCache = new Map<string, WorkflowIr>();
/* Cards whose own workflow could not be resolved — reported below rather than silently dropped. */
const unresolvedReviewCards: string[] = [];
/*
FNXC:WorkflowResolvedColumns 2026-07-30-18:50 (#2838 review — greptile P1, same class as the
done-integrity sweep one commit earlier):
PROVENANCE, BECAUSE THIS RESOLVER DOES NOT FAIL — IT SUBSTITUTES. `resolveWorkflowIrForTask`
degrades to the BUILT-IN coding IR rather than throwing, so `own.length > 0` read as "this card
answered" when nobody had: a renamed-lane card was measured against the built-in `in-review`,
rejected, and — because this sweep is what would have rescued its already-merged state — rejected
again on every subsequent pass.
I wrote this sweep before that fix landed on the done-integrity one and reproduced its pre-fix
shape verbatim. Same resolution, same reporting, so the two cannot drift.
The VERDICT stays conservative: this sweep mutates a task's column and status, and guessing a lane
is worse than leaving one unrescued. What provenance buys is that the unrescued card is REPORTED.
*/
/*
FNXC:WorkflowResolvedColumns 2026-07-30-20:50 (widening this sweep's query ACTIVATED a guard below):
Returns the card's OWN review lanes rather than a boolean, so ONE resolution feeds BOTH consumers —
this sweep's lane test and the `getTaskHardMergeBlocker` call in its recovery loop.
That blocker was unwired and UNREACHABLE while this sweep's query was a literal. Widening the read
made it reachable: the sweep now finds renamed-board cards, and unwired it would decline every one,
so the conversion would have looked complete and delivered nothing. Found by scanning this file for
sweeps holding BOTH a literal query and an unwired lane guard — six of them, this one included.
*/
const ownReviewLanesForAlreadyMerged = async (task: Task): Promise<ReadonlySet<string>> => {
const resolved = await resolveWorkflowIrForTaskWithProvenance(this.store, task.id, reviewIrCache)
.catch(() => undefined);
const own = resolved
? [...new Set(REVIEW_ROLES.flatMap((role) => columnsWithFlag(resolved.ir, role)))]
: [];
/* A THROW is unresolvable too — the rarer path where the resolver raises rather than substitutes. */
if (!resolved || resolved.source === "default") unresolvedReviewCards.push(task.id);
/* DELIBERATE-LITERAL — the unresolvable-workflow default, reviewed 2026-07-30-18:50. */
return own.length > 0 ? new Set(own) : new Set(["in-review"]);
};
const inOwnReviewLane = async (task: Task): Promise<boolean> =>
(await ownReviewLanesForAlreadyMerged(task)).has(task.column);
const shortlist = [...byId.values()].filter((task) =>
!task.deletedAt &&
task.column === "in-review" &&
allowsAutoMergeProcessing(task, settings) &&
task.status === "failed" &&
(task.mergeRetries ?? 0) >= maxAutoMergeRetries &&
@@ -9830,6 +10202,19 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
!executingIds.has(task.id),
);
/* The per-card lane verdict is async, so it cannot live in the filter above. */
const candidates: Task[] = [];
for (const task of shortlist) {
if (await inOwnReviewLane(task)) candidates.push(task);
}
if (unresolvedReviewCards.length > 0) {
log.warn(
`already-merged review rescue: ${unresolvedReviewCards.length} card(s) measured against the `
+ `built-in review lane because their own workflow could not be resolved `
+ `(${unresolvedReviewCards.slice(0, 5).join(", ")}); a renamed review lane there stays unrescued.`,
);
}
if (candidates.length === 0) return 0;
let recovered = 0;
@@ -9904,11 +10289,12 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
mergeTargetSource: mergeTarget.source,
};
/* Wired with THIS card's review lanes — see the note on `ownReviewLanesForAlreadyMerged` above. */
const hardBlocker = getTaskHardMergeBlocker({
...task,
steps: task.steps ?? [],
workflowStepResults: task.workflowStepResults,
});
}, { reviewColumns: await ownReviewLanesForAlreadyMerged(task) });
if (hardBlocker) {
await this.store.updateTask(task.id, {
status: "failed",
@@ -10292,11 +10678,40 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
async recoverCompletionHandoffLimbo(): Promise<void> {
const settings = await this.store.getSettings();
if (settings.globalPause || settings.enginePaused) return;
const tasks = await this.store.listTasks({ column: "in-review", slim: false });
/*
FNXC:WorkflowResolvedColumns 2026-07-30-22:40 (the query-filter class, eighth sweep):
`listTasks({ column: "in-review" })` returned EMPTY on a renamed board, so a task stranded in
completion-handoff limbo — falsely marked exhausted while the merge queue already owns it — was never
cleared and stayed wedged.
Activation check first: this is one of the three remaining sweeps holding both a literal query and an
unwired `getTaskMergeBlocker`, so the guard is wired in the same change. This loop is already async,
so the per-card resolution happens inline rather than needing a precomputed map.
*/
const limboReviewColumns = await resolveProjectColumnsForRoles(this.store, REVIEW_ROLES);
const limboById = new Map<string, Task>();
for (const column of limboReviewColumns) {
for (const task of await this.store.listTasks({ column, slim: false })) limboById.set(task.id, task);
}
const tasks = [...limboById.values()];
const limboIrCache = new Map<string, WorkflowIr>();
const unresolvedLimboCards: string[] = [];
const ownLimboReviewLanes = async (task: Task): Promise<ReadonlySet<string>> => {
const resolved = await resolveWorkflowIrForTaskWithProvenance(this.store, task.id, limboIrCache)
.catch(() => undefined);
const own = resolved
? [...new Set(REVIEW_ROLES.flatMap((role) => columnsWithFlag(resolved.ir, role)))]
: [];
if (!resolved || resolved.source === "default") unresolvedLimboCards.push(task.id);
/* DELIBERATE-LITERAL — the unresolvable-workflow default, reviewed 2026-07-30-22:40. */
return own.length > 0 ? new Set(own) : new Set(["in-review"]);
};
const now = Date.now();
for (const task of tasks) {
if (task.column !== "in-review" || task.paused) continue;
if (task.paused) continue;
const limboReviewLanes = await ownLimboReviewLanes(task);
if (!limboReviewLanes.has(task.column)) continue;
if (!allowsAutoMergeProcessing(task, settings)) continue;
if (await this.isFalseCompletionHandoffExhaustionWhileMergeOwned(task)) {
await this.store.updateTask(task.id, {
@@ -10313,7 +10728,8 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
if (task.status != null || task.mergeDetails != null || task.review != null || task.reviewState != null) continue;
if (this.options.isTaskActive?.(task.id)) continue;
if (await this.isMergeLaneOwned(task.id)) continue;
if (getTaskMergeBlocker(task) !== undefined) continue;
/* Wired: unwired, this would skip every card the widened read now finds. */
if (getTaskMergeBlocker(task, { reviewColumns: limboReviewLanes }) !== undefined) continue;
const doneMarker = [...(task.log ?? [])].reverse().find((entry) => entry.action === "Task marked done by agent");
if (!doneMarker?.timestamp) continue;

View File

@@ -1,7 +1,7 @@
{
"generatedFrom": "node scripts/lifecycle-column-census.mjs --strict --update-baseline",
"byFile": {
"packages/engine/src/self-healing.ts": 97,
"packages/engine/src/self-healing.ts": 89,
"packages/engine/src/scheduler.ts": 12,
"packages/core/src/task-store/async-comments-attachments.ts": 9,
"packages/engine/src/executor.ts": 8,
@@ -58,6 +58,7 @@
"packages/engine/src/scheduler.ts\u0000done": 2,
"packages/engine/src/scheduler.ts\u0000in-progress": 2,
"packages/engine/src/scheduler.ts\u0000in-review": 2,
"packages/engine/src/self-healing.ts\u0000in-review": 2,
"packages/engine/src/usage-limit-detector.ts\u0000archived": 2,
"packages/engine/src/usage-limit-detector.ts\u0000done": 2,
"plugins/fusion-plugin-reports/src/store/report-store.ts\u0000archived": 2,
@@ -120,6 +121,7 @@
"packages/engine/src/hold-release.ts\u0000in-review": 1,
"packages/engine/src/project-engine.ts\u0000in-review": 1,
"packages/engine/src/scheduler.ts\u0000todo": 1,
"packages/engine/src/self-healing.ts\u0000done": 1,
"packages/engine/src/triage.ts\u0000triage": 1,
"plugins/fusion-plugin-even-cards/src/cards/board-cards.ts\u0000archived": 1,
"plugins/fusion-plugin-even-cards/src/cards/board-cards.ts\u0000done": 1,
@@ -127,7 +129,7 @@
"plugins/fusion-plugin-reports/src/store/report-types.ts\u0000archived": 1
},
"queryByFile": {
"packages/engine/src/self-healing.ts": 48,
"packages/engine/src/self-healing.ts": 37,
"packages/core/src/task-store/async-persistence.ts": 2,
"packages/core/src/task-store/merge-queue-ops.ts": 2,
"packages/cli/src/extension.ts": 1,