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:
7
.changeset/self-healing-done-integrity-query.md
Normal file
7
.changeset/self-healing-done-integrity-query.md
Normal 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.
|
||||||
@@ -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.
|
- 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.
|
- 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
|
## 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.
|
- `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.
|
||||||
|
|||||||
@@ -349,8 +349,20 @@ export function getLatestFailedPreMergeReviewStep(
|
|||||||
})[0];
|
})[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(
|
export function getTaskHardMergeBlocker(
|
||||||
task: Pick<Task, "column" | "paused" | "status" | "error" | "steps" | "workflowStepResults">,
|
task: Pick<Task, "column" | "paused" | "status" | "error" | "steps" | "workflowStepResults">,
|
||||||
|
options: { reviewColumns?: ReadonlySet<string> } = {},
|
||||||
): string | undefined {
|
): string | undefined {
|
||||||
return getTaskMergeBlocker({
|
return getTaskMergeBlocker({
|
||||||
...task,
|
...task,
|
||||||
@@ -358,7 +370,7 @@ export function getTaskHardMergeBlocker(
|
|||||||
paused: false,
|
paused: false,
|
||||||
status: task.status === "failed" ? undefined : task.status,
|
status: task.status === "failed" ? undefined : task.status,
|
||||||
error: undefined,
|
error: undefined,
|
||||||
});
|
}, { reviewColumns: options.reviewColumns });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getTaskDoneBypassBlocker(
|
export function getTaskDoneBypassBlocker(
|
||||||
|
|||||||
@@ -77,6 +77,15 @@ function productionFaithfulStore(tasks: Task[]) {
|
|||||||
getTaskWorkflowSelectionAsync: vi.fn(async () => ({ workflowId: "self-healing-lifecycle", stepIds: [] })),
|
getTaskWorkflowSelectionAsync: vi.fn(async () => ({ workflowId: "self-healing-lifecycle", stepIds: [] })),
|
||||||
getTaskWorkflowSelection: vi.fn(() => ({ 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)),
|
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;
|
}) as unknown as TaskStore & EventEmitter;
|
||||||
return { store, listTasks };
|
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");
|
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
|
`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;
|
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 { store, listTasks } = productionFaithfulStore([shippedCard()]);
|
||||||
const manager = new SelfHealingManager(store, { rootDir: "/repo" });
|
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).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 () => {
|
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]);
|
const { store } = productionFaithfulStore([card]);
|
||||||
expect(await store.listTasks({ column: "done" as never })).toHaveLength(0);
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -30,7 +30,10 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync,
|
|||||||
import { readFile } from "node:fs/promises";
|
import { readFile } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { isAbsolute, join, relative, resolve } from "node:path";
|
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 { finalizePlanningSegment } from "@fusion/core";
|
||||||
import type { MeshLeaseManager } from "./mesh-lease-manager.js";
|
import type { MeshLeaseManager } from "./mesh-lease-manager.js";
|
||||||
import { createLogger, schedulerLog } from "./logger.js";
|
import { createLogger, schedulerLog } from "./logger.js";
|
||||||
@@ -7062,9 +7065,46 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
|||||||
try {
|
try {
|
||||||
const settings = await this.store.getSettings();
|
const settings = await this.store.getSettings();
|
||||||
if (settings.globalPause || settings.enginePaused) return 0;
|
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) =>
|
const candidates = tasks.filter((t) =>
|
||||||
t.column === "in-review" &&
|
(noOpLanesByTask.get(t.id) ?? new Set(["in-review"])).has(t.column) &&
|
||||||
allowsAutoMergeProcessing(t, settings) &&
|
allowsAutoMergeProcessing(t, settings) &&
|
||||||
!t.paused &&
|
!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.
|
// 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 !== "merging-pr" &&
|
||||||
t.status !== "awaiting-user-review" &&
|
t.status !== "awaiting-user-review" &&
|
||||||
t.status !== "failed" &&
|
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;
|
if (candidates.length === 0) return 0;
|
||||||
@@ -7265,12 +7306,100 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
|||||||
|
|
||||||
async reconcileDoneTaskIntegrity(): Promise<number> {
|
async reconcileDoneTaskIntegrity(): Promise<number> {
|
||||||
try {
|
try {
|
||||||
const tasks = await this.store.listTasks({ column: "done", slim: true });
|
/*
|
||||||
const candidates = tasks.filter((task) =>
|
FNXC:WorkflowResolvedColumns 2026-07-30-16:50 (the query-filter class, now unblocked):
|
||||||
task.column === "done" &&
|
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.mergeDetails?.commitSha || task.mergeDetails.commitSha.trim().length === 0) &&
|
||||||
(task.modifiedFiles?.length ?? 0) > 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;
|
if (candidates.length === 0) return 0;
|
||||||
const settings = await this.store.getSettings();
|
const settings = await this.store.getSettings();
|
||||||
@@ -7412,7 +7541,38 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
|||||||
const settings = await this.store.getSettings();
|
const settings = await this.store.getSettings();
|
||||||
if (settings.globalPause || settings.enginePaused) return 0;
|
if (settings.globalPause || settings.enginePaused) return 0;
|
||||||
const maxAutoMergeRetries = resolveMaxAutoMergeRetries(settings);
|
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:
|
FNXC:WorkflowReviewGates 2026-07-26-15:30:
|
||||||
Liveness gate, mirroring `recoverGhostReviewTasks` and
|
Liveness gate, mirroring `recoverGhostReviewTasks` and
|
||||||
@@ -7432,7 +7592,6 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
|||||||
const executingIds = this.options.getExecutingTaskIds?.() ?? new Set<string>();
|
const executingIds = this.options.getExecutingTaskIds?.() ?? new Set<string>();
|
||||||
|
|
||||||
const mergeable = tasks.filter((t) =>
|
const mergeable = tasks.filter((t) =>
|
||||||
t.column === "in-review" &&
|
|
||||||
allowsAutoMergeProcessing(t, settings) &&
|
allowsAutoMergeProcessing(t, settings) &&
|
||||||
!t.paused &&
|
!t.paused &&
|
||||||
!executingIds.has(t.id) &&
|
!executingIds.has(t.id) &&
|
||||||
@@ -7455,13 +7614,38 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
|||||||
// refreshes updatedAt, preventing cooldown-based retries from ever
|
// refreshes updatedAt, preventing cooldown-based retries from ever
|
||||||
// becoming eligible. Also skip tasks explicitly tagged as no-op merges
|
// becoming eligible. Also skip tasks explicitly tagged as no-op merges
|
||||||
// in case updateTask(moveTask) is briefly out-of-order during recovery.
|
// in case updateTask(moveTask) is briefly out-of-order during recovery.
|
||||||
(t.mergeRetries ?? 0) < maxAutoMergeRetries &&
|
(t.mergeRetries ?? 0) < maxAutoMergeRetries,
|
||||||
getTaskMergeBlocker(t) === undefined,
|
|
||||||
);
|
);
|
||||||
|
/*
|
||||||
|
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(
|
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 inReviewIds = new Set(tasks.map((task) => task.id));
|
||||||
const mergeableIds = new Set(unownedMergeable.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;
|
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.
|
// Prefer the engine's merge queue so `mergeStrategy` (direct vs.
|
||||||
// pull-request) is honored. Fall back to a direct store merge only
|
// 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();
|
const settings = await this.store.getSettings();
|
||||||
if (settings.globalPause || settings.enginePaused) return 0;
|
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 executingIds = this.options.getExecutingTaskIds?.() ?? new Set<string>();
|
||||||
|
|
||||||
const latestFailedPreMergeStep = (task: Pick<Task, "workflowStepResults">): WorkflowStepResult | undefined => {
|
const latestFailedPreMergeStep = (task: Pick<Task, "workflowStepResults">): WorkflowStepResult | undefined => {
|
||||||
@@ -7643,7 +7870,8 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const candidates = tasks.filter((task) => {
|
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 (!allowsAutoMergeProcessing(task, settings)) return false;
|
||||||
if (task.paused) 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 —
|
// Merge must be blocked *specifically* by the failed pre-merge step —
|
||||||
// not by an unrelated condition (incomplete steps, etc.) that is
|
// not by an unrelated condition (incomplete steps, etc.) that is
|
||||||
// already handled by a dedicated scan.
|
// 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;
|
if (!parkedRemediationFailure && blocker !== "task has failed pre-merge workflow steps") return false;
|
||||||
|
|
||||||
// The retry flow injects into PROMPT.md + re-executes on the worktree.
|
// 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;
|
const timeoutMs = settings.taskStuckTimeoutMs;
|
||||||
if (!timeoutMs || timeoutMs <= 0) return 0;
|
if (!timeoutMs || timeoutMs <= 0) return 0;
|
||||||
|
|
||||||
const tasks = await this.store.listTasks({ column: "in-review", slim: true });
|
/*
|
||||||
const statusCandidates = tasks.filter((task) =>
|
FNXC:WorkflowResolvedColumns 2026-07-30-19:50 (the query-filter class, fourth sweep):
|
||||||
task.column === "in-review" &&
|
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) &&
|
allowsAutoMergeProcessing(task, settings) &&
|
||||||
!task.paused &&
|
!task.paused &&
|
||||||
Boolean(task.status && ACTIVE_MERGE_STATUSES.has(task.status)),
|
Boolean(task.status && ACTIVE_MERGE_STATUSES.has(task.status)),
|
||||||
);
|
);
|
||||||
const candidates: Task[] = [];
|
const candidates: Task[] = [];
|
||||||
for (const task of statusCandidates) {
|
for (const task of statusCandidates) {
|
||||||
|
if (!(await inOwnReviewLaneForInterrupted(task))) continue;
|
||||||
if (await this.isPastInterruptedMergeGraceAsync(task, timeoutMs)) {
|
if (await this.isPastInterruptedMergeGraceAsync(task, timeoutMs)) {
|
||||||
candidates.push(task);
|
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;
|
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;
|
let recovered = 0;
|
||||||
for (const task of candidates) {
|
for (const task of candidates) {
|
||||||
@@ -9408,22 +9679,54 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
|||||||
if (settings.globalPause || settings.enginePaused) return 0;
|
if (settings.globalPause || settings.enginePaused) return 0;
|
||||||
const maxAutoMergeRetries = resolveMaxAutoMergeRetries(settings);
|
const maxAutoMergeRetries = resolveMaxAutoMergeRetries(settings);
|
||||||
const now = Date.now();
|
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):
|
FNXC:WorkflowResolvedColumns 2026-07-30-19:20 (the query-filter class, third sweep):
|
||||||
This trio is a UNION and is deliberately left on literals. For the merged
|
FOUR reads, two different jobs, and only the reads were broken.
|
||||||
default lineage the `triage` read returns empty and `todo` supplies the cards;
|
|
||||||
for a legacy/custom workflow that still declares `triage` it supplies them.
|
`inReview` supplies the CANDIDATES — cards blocked on merge — so it needs both halves: the project
|
||||||
Either way the union is complete, and the role filter below decides which rows
|
union for the read, and a per-card verdict against the card's own workflow, because this sweep
|
||||||
count as pre-WIP. Unlike the recoverAdvancedTriageTasks query this replaces
|
mutates. Same shape as `recoverAlreadyMergedReviewTasks`, provenance and reporting included.
|
||||||
nothing and disables nothing — it is a redundant read, not a dead sweep.
|
|
||||||
|
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 reviewColumnsForDeadlock = await resolveProjectColumnsForRoles(this.store, REVIEW_ROLES);
|
||||||
const todo = await this.store.listTasks({ column: "todo", slim: true });
|
const inReviewById = new Map<string, Task>();
|
||||||
const inProgress = await this.store.listTasks({ column: "in-progress", slim: true });
|
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[]>();
|
const dependentsByBlocker = new Map<string, Task[]>();
|
||||||
for (const task of [...triage, ...todo, ...inProgress]) {
|
for (const task of dependentSources) {
|
||||||
if (!task.blockedBy) continue;
|
if (!task.blockedBy) continue;
|
||||||
const dependents = dependentsByBlocker.get(task.blockedBy) ?? [];
|
const dependents = dependentsByBlocker.get(task.blockedBy) ?? [];
|
||||||
dependents.push(task);
|
dependents.push(task);
|
||||||
@@ -9449,8 +9752,9 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
|||||||
const hasBlockedDependents = (dependentsByBlocker.get(task.id) ?? []).some(
|
const hasBlockedDependents = (dependentsByBlocker.get(task.id) ?? []).some(
|
||||||
(dep) => preWipDependentIds.has(dep.id),
|
(dep) => preWipDependentIds.has(dep.id),
|
||||||
);
|
);
|
||||||
return task.column === "in-review" &&
|
/* Lane identity is decided per card by `inOwnReviewLaneForDeadlock` below — this synchronous
|
||||||
allowsAutoMergeProcessing(task, settings) &&
|
filter keeps every other condition, so the async check runs only for rows that already qualify. */
|
||||||
|
return allowsAutoMergeProcessing(task, settings) &&
|
||||||
!task.paused &&
|
!task.paused &&
|
||||||
task.status === "failed" &&
|
task.status === "failed" &&
|
||||||
(task.mergeRetries ?? 0) >= maxAutoMergeRetries &&
|
(task.mergeRetries ?? 0) >= maxAutoMergeRetries &&
|
||||||
@@ -9459,10 +9763,23 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
|||||||
cooldownElapsed >= DEADLOCK_RECOVERY_COOLDOWN_MS;
|
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;
|
let recovered = 0;
|
||||||
for (const task of candidates) {
|
for (const task of laneQualified) {
|
||||||
if (task.deletedAt) continue;
|
if (task.deletedAt) continue;
|
||||||
const blockedDependents = dependentsByBlocker.get(task.id) ?? [];
|
const blockedDependents = dependentsByBlocker.get(task.id) ?? [];
|
||||||
const blockedTaskIds = blockedDependents.map((dep) => dep.id);
|
const blockedTaskIds = blockedDependents.map((dep) => dep.id);
|
||||||
@@ -9819,10 +10136,65 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
|||||||
if (settings.globalPause || settings.enginePaused) return 0;
|
if (settings.globalPause || settings.enginePaused) return 0;
|
||||||
const maxAutoMergeRetries = resolveMaxAutoMergeRetries(settings);
|
const maxAutoMergeRetries = resolveMaxAutoMergeRetries(settings);
|
||||||
const executingIds = this.options.getExecutingTaskIds?.() ?? new Set<string>();
|
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.deletedAt &&
|
||||||
task.column === "in-review" &&
|
|
||||||
allowsAutoMergeProcessing(task, settings) &&
|
allowsAutoMergeProcessing(task, settings) &&
|
||||||
task.status === "failed" &&
|
task.status === "failed" &&
|
||||||
(task.mergeRetries ?? 0) >= maxAutoMergeRetries &&
|
(task.mergeRetries ?? 0) >= maxAutoMergeRetries &&
|
||||||
@@ -9830,6 +10202,19 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
|||||||
!executingIds.has(task.id),
|
!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;
|
if (candidates.length === 0) return 0;
|
||||||
|
|
||||||
let recovered = 0;
|
let recovered = 0;
|
||||||
@@ -9904,11 +10289,12 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
|||||||
mergeTargetSource: mergeTarget.source,
|
mergeTargetSource: mergeTarget.source,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* Wired with THIS card's review lanes — see the note on `ownReviewLanesForAlreadyMerged` above. */
|
||||||
const hardBlocker = getTaskHardMergeBlocker({
|
const hardBlocker = getTaskHardMergeBlocker({
|
||||||
...task,
|
...task,
|
||||||
steps: task.steps ?? [],
|
steps: task.steps ?? [],
|
||||||
workflowStepResults: task.workflowStepResults,
|
workflowStepResults: task.workflowStepResults,
|
||||||
});
|
}, { reviewColumns: await ownReviewLanesForAlreadyMerged(task) });
|
||||||
if (hardBlocker) {
|
if (hardBlocker) {
|
||||||
await this.store.updateTask(task.id, {
|
await this.store.updateTask(task.id, {
|
||||||
status: "failed",
|
status: "failed",
|
||||||
@@ -10292,11 +10678,40 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
|||||||
async recoverCompletionHandoffLimbo(): Promise<void> {
|
async recoverCompletionHandoffLimbo(): Promise<void> {
|
||||||
const settings = await this.store.getSettings();
|
const settings = await this.store.getSettings();
|
||||||
if (settings.globalPause || settings.enginePaused) return;
|
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();
|
const now = Date.now();
|
||||||
|
|
||||||
for (const task of tasks) {
|
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 (!allowsAutoMergeProcessing(task, settings)) continue;
|
||||||
if (await this.isFalseCompletionHandoffExhaustionWhileMergeOwned(task)) {
|
if (await this.isFalseCompletionHandoffExhaustionWhileMergeOwned(task)) {
|
||||||
await this.store.updateTask(task.id, {
|
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 (task.status != null || task.mergeDetails != null || task.review != null || task.reviewState != null) continue;
|
||||||
if (this.options.isTaskActive?.(task.id)) continue;
|
if (this.options.isTaskActive?.(task.id)) continue;
|
||||||
if (await this.isMergeLaneOwned(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");
|
const doneMarker = [...(task.log ?? [])].reverse().find((entry) => entry.action === "Task marked done by agent");
|
||||||
if (!doneMarker?.timestamp) continue;
|
if (!doneMarker?.timestamp) continue;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"generatedFrom": "node scripts/lifecycle-column-census.mjs --strict --update-baseline",
|
"generatedFrom": "node scripts/lifecycle-column-census.mjs --strict --update-baseline",
|
||||||
"byFile": {
|
"byFile": {
|
||||||
"packages/engine/src/self-healing.ts": 97,
|
"packages/engine/src/self-healing.ts": 89,
|
||||||
"packages/engine/src/scheduler.ts": 12,
|
"packages/engine/src/scheduler.ts": 12,
|
||||||
"packages/core/src/task-store/async-comments-attachments.ts": 9,
|
"packages/core/src/task-store/async-comments-attachments.ts": 9,
|
||||||
"packages/engine/src/executor.ts": 8,
|
"packages/engine/src/executor.ts": 8,
|
||||||
@@ -58,6 +58,7 @@
|
|||||||
"packages/engine/src/scheduler.ts\u0000done": 2,
|
"packages/engine/src/scheduler.ts\u0000done": 2,
|
||||||
"packages/engine/src/scheduler.ts\u0000in-progress": 2,
|
"packages/engine/src/scheduler.ts\u0000in-progress": 2,
|
||||||
"packages/engine/src/scheduler.ts\u0000in-review": 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\u0000archived": 2,
|
||||||
"packages/engine/src/usage-limit-detector.ts\u0000done": 2,
|
"packages/engine/src/usage-limit-detector.ts\u0000done": 2,
|
||||||
"plugins/fusion-plugin-reports/src/store/report-store.ts\u0000archived": 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/hold-release.ts\u0000in-review": 1,
|
||||||
"packages/engine/src/project-engine.ts\u0000in-review": 1,
|
"packages/engine/src/project-engine.ts\u0000in-review": 1,
|
||||||
"packages/engine/src/scheduler.ts\u0000todo": 1,
|
"packages/engine/src/scheduler.ts\u0000todo": 1,
|
||||||
|
"packages/engine/src/self-healing.ts\u0000done": 1,
|
||||||
"packages/engine/src/triage.ts\u0000triage": 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\u0000archived": 1,
|
||||||
"plugins/fusion-plugin-even-cards/src/cards/board-cards.ts\u0000done": 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
|
"plugins/fusion-plugin-reports/src/store/report-types.ts\u0000archived": 1
|
||||||
},
|
},
|
||||||
"queryByFile": {
|
"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/async-persistence.ts": 2,
|
||||||
"packages/core/src/task-store/merge-queue-ops.ts": 2,
|
"packages/core/src/task-store/merge-queue-ops.ts": 2,
|
||||||
"packages/cli/src/extension.ts": 1,
|
"packages/cli/src/extension.ts": 1,
|
||||||
|
|||||||
Reference in New Issue
Block a user