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.
|
||||
- Read `self-healing.ts: N` in the census as "N comparisons", never as "N remaining defects" — in this file the two numbers are not related.
|
||||
|
||||
## Converting a sweep: the four-part shape, and the part that is easy to miss
|
||||
|
||||
Four sweeps are converted (`reconcileDoneTaskIntegrity`, `recoverAlreadyMergedReviewTasks`,
|
||||
`recoverStuckMergeDeadlocks`, `recoverInterruptedMergingTasks`). They are deliberately identical, because
|
||||
the second one drifted from the first — it was written from the pre-review version and reproduced a flaw
|
||||
review had already fixed one commit earlier.
|
||||
|
||||
1. **Read** — `resolveProjectColumnsForRoles(store, ROLES)`, then query each column and dedupe by id. A
|
||||
read happens before any task is in hand, so there is nothing to resolve a per-task lane from. The
|
||||
legacy ids are unioned in, so a board mid-rename whose rows are still stored under the old id is not
|
||||
skipped.
|
||||
2. **Verdict** — resolved per card against **its own** workflow. Widening the read and widening the
|
||||
verdict are different decisions: a missed row is invisible, a wrong row is a write. Using the project
|
||||
union as a per-card test claims a card because *some other board* calls its column that role.
|
||||
3. **Provenance** — `resolveWorkflowIrForTaskWithProvenance`, because the resolver **substitutes** the
|
||||
built-in IR rather than failing. Without it, `columnsWithFlag(ir, role).length > 0` reads as "this card
|
||||
answered" when nobody did. It does not change the verdict (measured: identical, since the built-in lane
|
||||
already *is* the legacy id) — it makes the unrepaired card **reportable** instead of invisible.
|
||||
4. **The log strings.** Widening a query silently invalidates every message naming the old literal.
|
||||
`recoverInterruptedMergingTasks` logged `"stale merging task(s) in in-review"` after its read covered
|
||||
several lanes — an operator debugging a renamed board would have been told the wrong column.
|
||||
5. **The guards the widened query now ACTIVATES.** This is the one that bites hardest, because it makes a
|
||||
conversion look complete while delivering nothing.
|
||||
|
||||
## Converting a query is also an activation
|
||||
|
||||
A guard downstream of a literal query is **unreachable on a renamed board** — the sweep never hands it a
|
||||
row. Unreachable and correct are indistinguishable from the outside, which is exactly why these guards sit
|
||||
unwired for years without anyone noticing.
|
||||
|
||||
Widen the read and they become reachable for the first time. `recoverMergeableReviewTasks` called
|
||||
`getTaskMergeBlocker(t)` unwired; the moment its query found renamed-board cards, that call declined every
|
||||
one of them. **The sweep would have found the cards and refused them** — strictly worse than not finding
|
||||
them, because it looks fixed.
|
||||
|
||||
Measured across `self-healing.ts`: **4 sweeps hold both a literal column query and a genuinely unwired lane
|
||||
guard**; 32 hold a literal query with no such guard.
|
||||
|
||||
```text
|
||||
finalizeNoOpReviewTasks getTaskMergeBlocker
|
||||
recoverOrphanOnlyScopeViolations getTaskHardMergeBlocker
|
||||
recoverPostDoneNonContinuableWedge getTaskHardMergeBlocker
|
||||
recoverCompletionHandoffLimbo getTaskMergeBlocker
|
||||
```
|
||||
|
||||
**That number was 6 in the first version of this doc, and both extra rows were my scanner lying.** Worth
|
||||
recording, because the scan is the thing the next worker will re-run:
|
||||
|
||||
- `recoverAlreadyMergedReviewTasks` was reported unwired **after I had wired it** — the options object sits
|
||||
on the call's *last* line and the check read only the *first*. A multi-line call needs its whole span.
|
||||
- `recoverReviewTasksWithFailedPreMergeSteps` was reported with a second unwired guard that is **prose
|
||||
inside a doc comment** (`"because getTaskMergeBlocker() correctly blocks incomplete steps"`).
|
||||
|
||||
Both are the same failure this program keeps documenting about the census: **an instrument that matches
|
||||
syntax, read as if it measured meaning.** I published 6, corrected to 5, and only reached 4 by re-checking
|
||||
the correction itself. Re-run the scan with the span-aware and comment-skipping form, and spot-check each
|
||||
row against the source before acting on it.
|
||||
|
||||
The scan must also run **before** the query is widened, not after: I converted
|
||||
`recoverAlreadyMergedReviewTasks` two commits before noticing its guard, so for two commits it found
|
||||
renamed-board cards and declined them.
|
||||
|
||||
`getTaskHardMergeBlocker` was the blind spot for four of the six: it is a *wrapper*, it had no lane
|
||||
parameter at all, and every one of its callers sat behind a literal query. Nothing exercised it.
|
||||
|
||||
Parts 4 and 5 are both invisible to the census — one is string contents, the other is reachability — and
|
||||
each of the **43 remaining queries** carries both risks.
|
||||
|
||||
## Testing a sweep: assert what happens ONLY when the change is correct
|
||||
|
||||
Four assertions on this branch were vacuous — they passed with the fix reverted — and all four shared one
|
||||
shape: **I asserted something the sweep does regardless of the code under test.** The surface presentation
|
||||
differed every time, which is why recognising it took four rounds:
|
||||
|
||||
| the assertion | why it proved nothing |
|
||||
| --- | --- |
|
||||
| `commitSha` stayed undefined | the write needs a real git repo, so it never happens in the fixture either way |
|
||||
| `toContain("must be in")` | **two** guards can refuse; the other one caught the card and produced the same substring |
|
||||
| a warn was emitted | the sweep warns on a path unrelated to the conversion |
|
||||
| `getSettings` was called | the sweep calls it **unconditionally on its first line** |
|
||||
|
||||
The reliable question is not *"did something observable happen"* but *"what happens **only** when this
|
||||
change is correct"*. In a self-healing sweep that is almost always **candidacy** — the first thing that
|
||||
runs once per accepted row, after the filter:
|
||||
|
||||
```ts
|
||||
// getSettings runs before the filter → useless
|
||||
// isBranchAheadOfBase runs once per CANDIDATE → separates accepted from declined
|
||||
const aheadCheck = vi.spyOn(manager as never, "isBranchAheadOfBase").mockResolvedValue(false);
|
||||
```
|
||||
|
||||
**Asserting the query is only safe when nothing downstream can veto.** Once a sweep has a lane-sensitive
|
||||
guard (part 5), a query-only assertion passes while the guard silently rejects every row — which is
|
||||
precisely the bug being fixed. Where a guard exists, assert the end-to-end outcome instead.
|
||||
|
||||
And run the revert. Every one of the four above was found that way and none by reading.
|
||||
|
||||
## Related
|
||||
|
||||
- `docs/solutions/test-failures/optional-flags-seam-hides-unconverted-column-guards.md` — the same lesson one level down: the census counts syntax, and a green suite that omits the new parameter carries no information about the change.
|
||||
|
||||
@@ -349,8 +349,20 @@ export function getLatestFailedPreMergeReviewStep(
|
||||
})[0];
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:WorkflowResolvedColumns 2026-07-30-20:50:
|
||||
`reviewColumns` threads straight through to `getTaskMergeBlocker`, for the same reason that helper takes
|
||||
it: omitted, the identity check falls back to the literal `in-review` and refuses a card sitting in its
|
||||
own board's review lane.
|
||||
|
||||
This wrapper was the blind spot behind a whole class: its callers are self-healing sweeps whose column
|
||||
QUERY was also a literal, so the unwired check was unreachable and therefore unnoticed. Widening a
|
||||
sweep's query ACTIVATES it — the sweep starts finding renamed-board cards and this then declines every
|
||||
one. Optional, so no caller changes behaviour until it passes the set.
|
||||
*/
|
||||
export function getTaskHardMergeBlocker(
|
||||
task: Pick<Task, "column" | "paused" | "status" | "error" | "steps" | "workflowStepResults">,
|
||||
options: { reviewColumns?: ReadonlySet<string> } = {},
|
||||
): string | undefined {
|
||||
return getTaskMergeBlocker({
|
||||
...task,
|
||||
@@ -358,7 +370,7 @@ export function getTaskHardMergeBlocker(
|
||||
paused: false,
|
||||
status: task.status === "failed" ? undefined : task.status,
|
||||
error: undefined,
|
||||
});
|
||||
}, { reviewColumns: options.reviewColumns });
|
||||
}
|
||||
|
||||
export function getTaskDoneBypassBlocker(
|
||||
|
||||
@@ -77,6 +77,15 @@ function productionFaithfulStore(tasks: Task[]) {
|
||||
getTaskWorkflowSelectionAsync: vi.fn(async () => ({ workflowId: "self-healing-lifecycle", stepIds: [] })),
|
||||
getTaskWorkflowSelection: vi.fn(() => ({ workflowId: "self-healing-lifecycle", stepIds: [] })),
|
||||
getWorkflowDefinition: vi.fn(async (id: string) => (id === "self-healing-lifecycle" ? { ir: RENAMED_IR } : undefined)),
|
||||
/*
|
||||
FNXC:WorkflowResolvedColumns 2026-07-30-16:50 (the fix landed; this fake had to grow to see it):
|
||||
`resolveProjectColumnsForRoles` — the seam the sweeps now use — reads `listWorkflowDefinitions()`,
|
||||
the PROJECT's workflows, because a query runs before any task is in hand. Without this method the
|
||||
helper degrades to the legacy ids and the sweep still queries only `done`, so this file kept passing
|
||||
against the FIXED code and reported nothing. A ratchet whose fake cannot reach the new seam stops
|
||||
being a ratchet silently.
|
||||
*/
|
||||
listWorkflowDefinitions: vi.fn(async () => [{ ir: RENAMED_IR }]),
|
||||
}) as unknown as TaskStore & EventEmitter;
|
||||
return { store, listTasks };
|
||||
}
|
||||
@@ -112,7 +121,7 @@ describe("self-healing sweeps are bounded by a hardcoded column QUERY, not by th
|
||||
expect(lifecycle?.complete).not.toBe("done");
|
||||
});
|
||||
|
||||
it("KNOWN DEFECT: the done-integrity sweep asks for the literal `done`, so a RENAMED board yields nothing", async () => {
|
||||
it("the done-integrity sweep now asks for the board's OWN complete lane (was: KNOWN DEFECT)", async () => {
|
||||
/*
|
||||
`reconcileDoneTaskIntegrity` opens with `listTasks({ column: "done", slim: true })` and then
|
||||
re-asserts `task.column === "done"` on the rows it gets back. The census counts that re-assertion;
|
||||
@@ -124,14 +133,63 @@ describe("self-healing sweeps are bounded by a hardcoded column QUERY, not by th
|
||||
const { store, listTasks } = productionFaithfulStore([shippedCard()]);
|
||||
const manager = new SelfHealingManager(store, { rootDir: "/repo" });
|
||||
|
||||
expect(await manager.reconcileDoneTaskIntegrity()).toBe(0);
|
||||
await manager.reconcileDoneTaskIntegrity();
|
||||
|
||||
// The query asked for the legacy literal, NOT this workflow's resolved complete lane.
|
||||
/*
|
||||
THE ASSERTION THIS FILE WAS BUILT TO FLIP. It used to read `not.toHaveBeenCalledWith(… complete)`
|
||||
and passed because the sweep only ever asked for the literal. The sweep now resolves the project's
|
||||
complete lanes and queries each, so the renamed lane IS asked for.
|
||||
|
||||
`done` is STILL expected: `resolveProjectColumnsForRoles` unions the legacy ids deliberately, so a
|
||||
board mid-rename whose rows are still stored under the old id is not skipped. Over-inclusion costs
|
||||
one extra query the caller then filters; under-inclusion is invisible.
|
||||
*/
|
||||
expect(listTasks).toHaveBeenCalledWith(expect.objectContaining({ column: RENAMED_VOCAB.complete }));
|
||||
expect(listTasks).toHaveBeenCalledWith(expect.objectContaining({ column: "done" }));
|
||||
expect(listTasks).not.toHaveBeenCalledWith(expect.objectContaining({ column: RENAMED_VOCAB.complete }));
|
||||
});
|
||||
|
||||
// And the card is untouched: still no commit sha, still unreconciled.
|
||||
expect((await store.getTask("FN-BLIND"))?.mergeDetails?.commitSha).toBeUndefined();
|
||||
/*
|
||||
FNXC:WorkflowResolvedColumns 2026-07-30-18:05 (#2838 review — greptile P1):
|
||||
|
||||
A CARD WHOSE WORKFLOW CANNOT BE RESOLVED MUST NOT BE MISTAKEN FOR ONE THAT ANSWERED.
|
||||
|
||||
`resolveWorkflowIrForTask` does not throw when a task's selection is unresolvable — it SUBSTITUTES
|
||||
the built-in coding IR, whose complete lane is `done`. The candidate filter therefore saw a non-empty
|
||||
`columnsWithFlag(ir, "complete")` and treated the built-in vocabulary as this card's own answer, so a
|
||||
renamed-lane card was rejected on every sweep and its missing merge evidence stayed unrepaired
|
||||
forever. The provenance form separates the two, and only a real selection counts as an answer.
|
||||
|
||||
WHY THE STORE FAKE DROPS ONLY THE SELECTION READERS. That is precisely the production shape being
|
||||
modelled: the workflow DEFINITION is fine, the card's link to it is what cannot be read. Deleting the
|
||||
definition instead would take a different branch and prove nothing about this one.
|
||||
*/
|
||||
it("a card whose workflow selection cannot be resolved is not judged by the BUILT-IN complete lane", async () => {
|
||||
const { store } = productionFaithfulStore([shippedCard()]);
|
||||
/* No selection for this card: `resolveWorkflowIrForTaskWithProvenance` reports source "default". */
|
||||
(store as unknown as { getTaskWorkflowSelectionAsync: unknown }).getTaskWorkflowSelectionAsync =
|
||||
vi.fn(async () => undefined);
|
||||
(store as unknown as { getTaskWorkflowSelection: unknown }).getTaskWorkflowSelection = vi.fn(() => undefined);
|
||||
const manager = new SelfHealingManager(store, { rootDir: "/repo" });
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
|
||||
let warned = "";
|
||||
try {
|
||||
await manager.reconcileDoneTaskIntegrity();
|
||||
} finally {
|
||||
/* Read BEFORE restoring: `mockRestore` clears the recorded calls, so reading afterwards yields
|
||||
an empty string and the assertion fails for a reason that has nothing to do with the code. */
|
||||
warned = warn.mock.calls.map((call) => String(call[0])).join("\n");
|
||||
warn.mockRestore();
|
||||
}
|
||||
|
||||
/*
|
||||
The observable claim: the card is REPORTED as unresolvable rather than silently discarded. The
|
||||
verdict itself is deliberately unchanged — a sweep that WRITES merge evidence must not guess a lane
|
||||
— so asserting "it got repaired" would be asserting the wrong fix. What must not survive is the
|
||||
silence, which is what made this unrepairable-forever instead of merely unrepaired.
|
||||
*/
|
||||
expect(warned).toContain("done-task integrity sweep");
|
||||
expect(warned).toContain("FN-BLIND");
|
||||
});
|
||||
|
||||
it("proves the fake is what hides it: an ignoring `listTasks` hands the sweep rows production would not", async () => {
|
||||
@@ -160,4 +218,443 @@ describe("self-healing sweeps are bounded by a hardcoded column QUERY, not by th
|
||||
const { store } = productionFaithfulStore([card]);
|
||||
expect(await store.listTasks({ column: "done" as never })).toHaveLength(0);
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:WorkflowResolvedColumns 2026-07-30-17:20 (#2838 review — greptile P1):
|
||||
THE PROJECT UNION IS FOR THE QUERY, NEVER FOR THE PER-CARD VERDICT.
|
||||
|
||||
Two boards in one project: board A calls its COMPLETE lane `shipped`; board B calls its WIP lane
|
||||
`shipped`. The project union therefore contains `shipped`, which is correct for the READ — board A's
|
||||
finished cards must be found. Using that same set as the per-card test claims board B's card as
|
||||
complete because SOME OTHER workflow calls that column complete, and this sweep WRITES merge evidence
|
||||
onto whatever it accepts.
|
||||
|
||||
Widening the read and widening the verdict are different decisions: a missed row is invisible, a wrong
|
||||
row is a write.
|
||||
|
||||
REVERT CHECK, measured: re-asserting `completeColumns.has(task.column)` instead of resolving each card
|
||||
against its own workflow fails this case — the mid-implementation card is reconciled.
|
||||
*/
|
||||
it("does not claim a card whose OWN workflow calls its column WIP, even when another board calls it complete", async () => {
|
||||
const boardB = {
|
||||
version: "v2", id: "board-b", name: "board b",
|
||||
columns: [
|
||||
{ id: "planning", name: "Planning", traits: [{ trait: "intake" }, { trait: "hold", config: { release: "capacity" } }] },
|
||||
/* Same id as board A's COMPLETE lane, but here it is WIP. */
|
||||
{ id: "shipped", name: "Building", traits: [{ trait: "wip", config: { limitSetting: "maxConcurrent" } }] },
|
||||
{ id: "closed", name: "Done", traits: [{ trait: "complete" }] },
|
||||
],
|
||||
nodes: [{ id: "start", kind: "start", column: "planning" }],
|
||||
edges: [],
|
||||
} as unknown as WorkflowIr;
|
||||
|
||||
const midImplementation = { ...shippedCard(), id: "FN-WIP" } as Task;
|
||||
const tasksById = new Map([[midImplementation.id, midImplementation]]);
|
||||
const store = Object.assign(new EventEmitter(), {
|
||||
getSettings: vi.fn(async () => ({ globalPause: false, enginePaused: false }) as Settings),
|
||||
listTasks: vi.fn(async (options?: { column?: string }) => {
|
||||
const all = [...tasksById.values()];
|
||||
return options?.column === undefined ? all : all.filter((t) => t.column === options.column);
|
||||
}),
|
||||
getTask: vi.fn(async (id: string) => tasksById.get(id)),
|
||||
updateTask: vi.fn(async (id: string, patch: Partial<Task>) => {
|
||||
tasksById.set(id, { ...tasksById.get(id)!, ...patch } as Task);
|
||||
return tasksById.get(id)!;
|
||||
}),
|
||||
/* The PROJECT declares both boards, so `shipped` is legitimately in the union. */
|
||||
listWorkflowDefinitions: vi.fn(async () => [{ ir: RENAMED_IR }, { ir: boardB }]),
|
||||
/* But THIS card belongs to board B, where `shipped` is WIP. */
|
||||
getTaskWorkflowSelectionAsync: vi.fn(async () => ({ workflowId: "board-b", stepIds: [] })),
|
||||
getTaskWorkflowSelection: vi.fn(() => ({ workflowId: "board-b", stepIds: [] })),
|
||||
getWorkflowDefinition: vi.fn(async (id: string) => (id === "board-b" ? { ir: boardB } : { ir: RENAMED_IR })),
|
||||
}) as unknown as TaskStore & EventEmitter;
|
||||
|
||||
const manager = new SelfHealingManager(store, { rootDir: "/repo" });
|
||||
await manager.reconcileDoneTaskIntegrity();
|
||||
|
||||
/*
|
||||
ASSERTS CANDIDACY, not the write. My first version asserted `commitSha` stayed undefined, which is
|
||||
true either way here — the write needs a real git repo, so it never happens in this fixture and the
|
||||
assertion could not distinguish accepted from rejected. The revert passed and exposed it.
|
||||
|
||||
`reconcileDoneTaskIntegrity` returns BEFORE `getSettings()` when the candidate list is empty
|
||||
(`if (candidates.length === 0) return 0;`), so that call is the observable proof that this card was
|
||||
NOT accepted as complete.
|
||||
*/
|
||||
expect(store.getSettings).not.toHaveBeenCalled();
|
||||
expect((await store.getTask("FN-WIP"))?.mergeDetails?.commitSha).toBeUndefined();
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:WorkflowResolvedColumns 2026-07-30-17:50 (#2838 review — greptile P1, second round):
|
||||
THE GUESSED-WORKFLOW PATH. `resolveWorkflowIrForTask` returns the BUILT-IN IR when a task names no
|
||||
workflow, and the built-in complete lane IS `done` — so a naive `columnsWithFlag(ir, "complete")`
|
||||
yields `["done"]` for a card we could not resolve, the legacy branch never fires, and the card is
|
||||
rejected on every sweep forever.
|
||||
|
||||
Resolution now goes through `...WithProvenance`, so only `source: "selection"` overrules the legacy
|
||||
check. A card that DOES name a workflow keeps being judged by it; a card that does not falls back to
|
||||
the legacy id rather than to the built-in board's vocabulary wearing a resolved disguise.
|
||||
|
||||
REVERT CHECK, measured: resolving without provenance fails this case — `ownComplete` becomes `["done"]`
|
||||
from the built-in default, so the card in `done` with no selection is REJECTED and `getSettings` is
|
||||
never reached.
|
||||
*/
|
||||
it("still repairs a legacy `done` card whose workflow selection is missing", async () => {
|
||||
const legacyCard = { ...shippedCard(), id: "FN-LEGACY", column: "done" } as Task;
|
||||
const tasksById = new Map([[legacyCard.id, legacyCard]]);
|
||||
const store = Object.assign(new EventEmitter(), {
|
||||
getSettings: vi.fn(async () => ({ globalPause: false, enginePaused: false }) as Settings),
|
||||
listTasks: vi.fn(async (options?: { column?: string }) => {
|
||||
const all = [...tasksById.values()];
|
||||
return options?.column === undefined ? all : all.filter((t) => t.column === options.column);
|
||||
}),
|
||||
getTask: vi.fn(async (id: string) => tasksById.get(id)),
|
||||
updateTask: vi.fn(async (id: string, patch: Partial<Task>) => {
|
||||
tasksById.set(id, { ...tasksById.get(id)!, ...patch } as Task);
|
||||
return tasksById.get(id)!;
|
||||
}),
|
||||
listWorkflowDefinitions: vi.fn(async () => [{ ir: RENAMED_IR }]),
|
||||
/* NO selection for this card — the state that makes the resolver guess. */
|
||||
getTaskWorkflowSelectionAsync: vi.fn(async () => undefined),
|
||||
getTaskWorkflowSelection: vi.fn(() => undefined),
|
||||
getWorkflowDefinition: vi.fn(async () => undefined),
|
||||
}) as unknown as TaskStore & EventEmitter;
|
||||
|
||||
const manager = new SelfHealingManager(store, { rootDir: "/repo" });
|
||||
await manager.reconcileDoneTaskIntegrity();
|
||||
|
||||
/* Accepted as a candidate: the sweep reached `getSettings`, which it only does with a non-empty list. */
|
||||
expect(store.getSettings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:WorkflowResolvedColumns 2026-07-30-18:20 (the query-filter class, second sweep):
|
||||
`recoverAlreadyMergedReviewTasks` rescues a card whose merge ACTUALLY SUCCEEDED but is parked in review
|
||||
with `status: "failed"`. Its read was `listTasks({ column: "in-review" })`, which returns nothing on a
|
||||
renamed board — so the rescue never ran and that card stayed stuck permanently.
|
||||
|
||||
Asserts the QUERY, like the done-integrity case above and for the same reason: the outcome is 0 either
|
||||
way, so only the question asked distinguishes fixed from broken. The per-card verdict uses the pattern
|
||||
already revert-proven for the other sweep.
|
||||
|
||||
REVERT CHECK, measured: restoring `listTasks({ column: "in-review" })` fails this — the board's own
|
||||
review lane is never asked for.
|
||||
*/
|
||||
it("the already-merged rescue asks for the board's OWN review lane", async () => {
|
||||
const parked = {
|
||||
...shippedCard(),
|
||||
id: "FN-STUCK",
|
||||
column: RENAMED_VOCAB.review,
|
||||
status: "failed",
|
||||
mergeRetries: 99,
|
||||
} as unknown as Task;
|
||||
const { store, listTasks } = productionFaithfulStore([parked]);
|
||||
|
||||
await new SelfHealingManager(store, { rootDir: "/repo" }).recoverAlreadyMergedReviewTasks();
|
||||
|
||||
expect(listTasks).toHaveBeenCalledWith(expect.objectContaining({ column: RENAMED_VOCAB.review }));
|
||||
/* The legacy id is still asked for — the project union keeps mid-rename rows reachable. */
|
||||
expect(listTasks).toHaveBeenCalledWith(expect.objectContaining({ column: "in-review" }));
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:WorkflowResolvedColumns 2026-07-30-18:50 (#2838 review — greptile P1, same class as the
|
||||
done-integrity sweep):
|
||||
I wrote this sweep before the provenance fix landed on its sibling and reproduced the pre-fix shape
|
||||
verbatim: `resolveWorkflowIrForTask` SUBSTITUTES the built-in IR rather than failing, so a card whose
|
||||
workflow could not be resolved was measured against the built-in `in-review`, rejected, and rejected
|
||||
again on every pass — with nothing recorded.
|
||||
|
||||
The verdict stays conservative (this sweep mutates column AND status). What provenance buys is that the
|
||||
unrescued card is REPORTED, which is the whole difference between a known gap and an invisible one.
|
||||
|
||||
REVERT CHECK, measured: resolving without provenance fails this — nothing is warned, because
|
||||
`own.length > 0` reads the substituted built-in lane as an answer.
|
||||
*/
|
||||
it("reports an already-merged card whose workflow could not be resolved", async () => {
|
||||
const parked = {
|
||||
...shippedCard(),
|
||||
id: "FN-UNRESOLVED",
|
||||
column: RENAMED_VOCAB.review,
|
||||
status: "failed",
|
||||
mergeRetries: 99,
|
||||
} as unknown as Task;
|
||||
const tasksById = new Map([[parked.id, parked]]);
|
||||
const store = Object.assign(new EventEmitter(), {
|
||||
getSettings: vi.fn(async () => ({ globalPause: false, enginePaused: false }) as Settings),
|
||||
listTasks: vi.fn(async (options?: { column?: string }) => {
|
||||
const all = [...tasksById.values()];
|
||||
return options?.column === undefined ? all : all.filter((t) => t.column === options.column);
|
||||
}),
|
||||
getTask: vi.fn(async (id: string) => tasksById.get(id)),
|
||||
updateTask: vi.fn(async () => undefined),
|
||||
/* The project DOES declare the renamed review lane, so the read finds the card... */
|
||||
listWorkflowDefinitions: vi.fn(async () => [{ ir: RENAMED_IR }]),
|
||||
/* ...but THIS card names no workflow, so its own lane vocabulary is unknown. */
|
||||
getTaskWorkflowSelectionAsync: vi.fn(async () => undefined),
|
||||
getTaskWorkflowSelection: vi.fn(() => undefined),
|
||||
getWorkflowDefinition: vi.fn(async () => undefined),
|
||||
}) as unknown as TaskStore & EventEmitter;
|
||||
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
let warned = "";
|
||||
try {
|
||||
await new SelfHealingManager(store, { rootDir: "/repo" }).recoverAlreadyMergedReviewTasks();
|
||||
warned = warn.mock.calls.map((call) => String(call[0])).join("\n");
|
||||
} finally {
|
||||
warn.mockRestore();
|
||||
}
|
||||
|
||||
expect(warned).toContain("already-merged review rescue");
|
||||
expect(warned).toContain("FN-UNRESOLVED");
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:WorkflowResolvedColumns 2026-07-30-19:20 (the query-filter class, third sweep):
|
||||
`recoverStuckMergeDeadlocks` reads FOUR lanes: the review lane for its candidates, and intake/hold/wip
|
||||
for the DEPENDENTS whose blocked state proves the deadlock. All four were literals, so on a renamed
|
||||
board the sweep saw no candidates AND no dependents — doubly blind.
|
||||
|
||||
Its 2026-07-29-17:40 note reasoned the literal `triage`/`todo` pair was a complete union "and the role
|
||||
filter below decides which rows count". That held for the default and legacy lineages it considered and
|
||||
fails on a renamed board, where the reads return nothing and the filter is handed nothing to decide
|
||||
about. Widening the reads restores the property that note relied on; the filter itself is untouched.
|
||||
|
||||
REVERT CHECK, measured: restoring the literal review read fails this — the board's own review lane is
|
||||
never asked for.
|
||||
*/
|
||||
it("the merge-deadlock recovery asks for the board's OWN review and dependent lanes", async () => {
|
||||
const parked = {
|
||||
...shippedCard(),
|
||||
id: "FN-DEADLOCK",
|
||||
column: RENAMED_VOCAB.review,
|
||||
status: "failed",
|
||||
mergeRetries: 99,
|
||||
worktree: "/tmp/wt",
|
||||
} as unknown as Task;
|
||||
const { store, listTasks } = productionFaithfulStore([parked]);
|
||||
|
||||
await new SelfHealingManager(store, { rootDir: "/repo" }).recoverStuckMergeDeadlocks();
|
||||
|
||||
/* Candidates: the board's own review lane, plus the legacy id the union keeps reachable. */
|
||||
expect(listTasks).toHaveBeenCalledWith(expect.objectContaining({ column: RENAMED_VOCAB.review }));
|
||||
expect(listTasks).toHaveBeenCalledWith(expect.objectContaining({ column: "in-review" }));
|
||||
/* Dependents: the board's own pre-WIP and WIP lanes, not just `triage`/`todo`/`in-progress`. */
|
||||
expect(listTasks).toHaveBeenCalledWith(expect.objectContaining({ column: RENAMED_VOCAB.hold }));
|
||||
expect(listTasks).toHaveBeenCalledWith(expect.objectContaining({ column: RENAMED_VOCAB.wip }));
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:WorkflowResolvedColumns 2026-07-30-19:50 (the query-filter class, fourth sweep):
|
||||
`recoverInterruptedMergingTasks` rescues a task interrupted mid-merge — status still `merging`, no live
|
||||
session behind it. Its read was the literal review lane, so on a renamed board that task sat in
|
||||
`merging` indefinitely.
|
||||
|
||||
Also asserts the LOG, because the old message hardcoded "in in-review" and would have reported a lane
|
||||
the sweep did not search. A message that names the wrong board is its own small lie.
|
||||
|
||||
REVERT CHECK, measured: restoring the literal read fails this — the board's own review lane is never
|
||||
asked for.
|
||||
*/
|
||||
it("the interrupted-merge recovery asks for the board's OWN review lane and names it", async () => {
|
||||
const stuck = {
|
||||
...shippedCard(),
|
||||
id: "FN-MERGING",
|
||||
column: RENAMED_VOCAB.review,
|
||||
status: "merging",
|
||||
updatedAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
} as unknown as Task;
|
||||
const { store, listTasks } = productionFaithfulStore([stuck]);
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
globalPause: false,
|
||||
enginePaused: false,
|
||||
taskStuckTimeoutMs: 60_000,
|
||||
} as Settings);
|
||||
|
||||
await new SelfHealingManager(store, { rootDir: "/repo" }).recoverInterruptedMergingTasks();
|
||||
|
||||
expect(listTasks).toHaveBeenCalledWith(expect.objectContaining({ column: RENAMED_VOCAB.review }));
|
||||
expect(listTasks).toHaveBeenCalledWith(expect.objectContaining({ column: "in-review" }));
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:WorkflowResolvedColumns 2026-07-30-20:20 (the query-filter class, fifth sweep):
|
||||
`recoverMergeableReviewTasks` re-enqueues a card that is genuinely ready to merge. Its read was the
|
||||
literal review lane, so on a renamed board that card sat in review forever.
|
||||
|
||||
THE INTERESTING PART IS DOWNSTREAM. This sweep's filter calls `getTaskMergeBlocker(t)` — previously
|
||||
UNWIRED, taking the legacy `in-review` default, and harmless only because the literal query meant a
|
||||
renamed board never reached it. Widening the read makes that guard REACHABLE for the first time, so
|
||||
left as-is it would refuse every card on exactly the boards this fix is for: found, then declined.
|
||||
|
||||
Converting a query activates every guard downstream of it. This case asserts the end-to-end outcome —
|
||||
the card is enqueued — precisely because a query-only assertion would have passed while the blocker
|
||||
silently rejected it.
|
||||
|
||||
REVERT CHECK, measured: dropping `{ reviewColumns }` from the blocker call fails this — the card is
|
||||
found by the widened read and then refused.
|
||||
*/
|
||||
it("enqueues a mergeable card on a RENAMED board, past the now-reachable merge blocker", async () => {
|
||||
const ready = {
|
||||
...shippedCard(),
|
||||
id: "FN-READY",
|
||||
column: RENAMED_VOCAB.review,
|
||||
status: null,
|
||||
worktree: "/tmp/wt",
|
||||
steps: [],
|
||||
mergeDetails: {},
|
||||
} as unknown as Task;
|
||||
const { store } = productionFaithfulStore([ready]);
|
||||
const enqueueMerge = vi.fn(async () => undefined);
|
||||
/* Complete the fake: the recovery loop logs before enqueuing, and an incomplete fake turns a real
|
||||
enqueue into a caught error the assertion cannot see. */
|
||||
Object.assign(store, {
|
||||
enqueueMerge,
|
||||
isMergeLaneOwned: vi.fn(async () => false),
|
||||
logEntry: vi.fn(async () => undefined),
|
||||
recordRunAuditEvent: vi.fn(async () => undefined),
|
||||
});
|
||||
|
||||
await new SelfHealingManager(store, { rootDir: "/repo", enqueueMerge } as never)
|
||||
.recoverMergeableReviewTasks();
|
||||
|
||||
/* Not just "the query asked" — the card survived the blocker and was acted on. */
|
||||
expect(enqueueMerge).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:WorkflowResolvedColumns 2026-07-30-21:20 (the query-filter class, sixth sweep — activation check
|
||||
run FIRST this time):
|
||||
`recoverReviewTasksWithFailedPreMergeSteps` auto-revives a card parked with a FAILED pre-merge review
|
||||
step. Its literal read meant that card stayed parked on a renamed board until a human noticed.
|
||||
|
||||
This sweep is the sharpest example of part 5 of the shape. Its filter asks
|
||||
`blocker !== "task has failed pre-merge workflow steps"` — an EXACT STRING match. Unwired on a renamed
|
||||
board the blocker returns "task is in 'checking', must be in 'in-review'" instead, so widening the query
|
||||
alone would have made the sweep find every card and then reject every card.
|
||||
|
||||
Asserts the END-TO-END outcome (the recover callback fires), not the query, precisely because a
|
||||
query-only assertion passes while the blocker silently rejects.
|
||||
|
||||
REVERT CHECK, measured (each independently):
|
||||
- literal read restored -> fails, the card is never found
|
||||
- { reviewColumns } dropped -> fails, the card is found and then rejected by the string compare
|
||||
*/
|
||||
it("revives a failed-pre-merge-step card on a RENAMED board, past the now-reachable blocker", async () => {
|
||||
const parked = {
|
||||
...shippedCard(),
|
||||
id: "FN-FAILEDSTEP",
|
||||
column: RENAMED_VOCAB.review,
|
||||
status: null,
|
||||
worktree: "/tmp/wt",
|
||||
steps: [],
|
||||
mergeDetails: {},
|
||||
workflowStepResults: [
|
||||
{ phase: "pre-merge", source: "optional-group", status: "failed",
|
||||
workflowStepId: "code-review", workflowStepName: "Code Review",
|
||||
completedAt: new Date().toISOString() },
|
||||
],
|
||||
} as unknown as Task;
|
||||
const { store } = productionFaithfulStore([parked]);
|
||||
Object.assign(store, { logEntry: vi.fn(async () => undefined) });
|
||||
const recoverFailedPreMergeStep = vi.fn(async () => true);
|
||||
|
||||
await new SelfHealingManager(store, { rootDir: "/repo", recoverFailedPreMergeStep } as never)
|
||||
.recoverReviewTasksWithFailedPreMergeSteps();
|
||||
|
||||
expect(recoverFailedPreMergeStep).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:WorkflowResolvedColumns 2026-07-30-22:00 (the query-filter class, seventh sweep):
|
||||
`finalizeNoOpReviewTasks` finalises a task whose branch has NO commits ahead of base — a genuine no-op
|
||||
merge. Its literal read meant such a task sat in review forever on a renamed board.
|
||||
|
||||
One of the four sweeps holding both a literal query and an unwired `getTaskMergeBlocker`, so the guard
|
||||
is wired in the same change: widening the read alone would have found the card and declined it.
|
||||
|
||||
REVERT CHECK, measured (each independently):
|
||||
- literal read restored -> the card is never found
|
||||
- { reviewColumns } dropped -> the card is found and then declined by the blocker
|
||||
*/
|
||||
it("finalizes a no-op card on a RENAMED board, past the now-reachable blocker", async () => {
|
||||
const noOp = {
|
||||
...shippedCard(),
|
||||
id: "FN-NOOP",
|
||||
column: RENAMED_VOCAB.review,
|
||||
status: null,
|
||||
worktree: "/tmp/wt",
|
||||
steps: [],
|
||||
mergeDetails: {},
|
||||
} as unknown as Task;
|
||||
const { store, listTasks } = productionFaithfulStore([noOp]);
|
||||
Object.assign(store, { logEntry: vi.fn(async () => undefined) });
|
||||
|
||||
const manager = new SelfHealingManager(store, { rootDir: "/repo" });
|
||||
/*
|
||||
ASSERTS CANDIDACY. My first version asserted `getSettings` was called — which the sweep does
|
||||
unconditionally on its first line, so it proved nothing. `isBranchAheadOfBase` runs ONCE PER
|
||||
CANDIDATE, after the filter, so it is the first observable that separates "found and accepted"
|
||||
from "found and declined by the blocker".
|
||||
*/
|
||||
const aheadCheck = vi
|
||||
.spyOn(manager as unknown as { isBranchAheadOfBase: (t: Task, b: string) => Promise<boolean> },
|
||||
"isBranchAheadOfBase")
|
||||
.mockResolvedValue(false);
|
||||
|
||||
await manager.finalizeNoOpReviewTasks();
|
||||
|
||||
expect(listTasks).toHaveBeenCalledWith(expect.objectContaining({ column: RENAMED_VOCAB.review }));
|
||||
expect(aheadCheck).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:WorkflowResolvedColumns 2026-07-30-22:40 (the query-filter class, eighth sweep):
|
||||
`recoverCompletionHandoffLimbo` clears a task falsely marked completion-handoff-exhausted while the
|
||||
merge queue already owns it. Its literal read meant such a task stayed wedged on a renamed board.
|
||||
|
||||
ASSERTS CANDIDACY, per the rule this file's siblings had to learn four times: `isMergeLaneOwned` runs
|
||||
once per row that has already passed BOTH the lane test and the merge blocker, so it is the first
|
||||
observable separating "found and accepted" from "found and skipped". `getSettings` would not do — the
|
||||
sweep calls it on its first line.
|
||||
|
||||
REVERT CHECK, measured (each independently):
|
||||
- literal read restored -> never reached, the card is not found
|
||||
- { reviewColumns } dropped -> the card is found and then skipped by the blocker
|
||||
*/
|
||||
it("reaches a limbo card on a RENAMED board, past the now-reachable blocker", async () => {
|
||||
const wedged = {
|
||||
...shippedCard(),
|
||||
id: "FN-LIMBO",
|
||||
column: RENAMED_VOCAB.review,
|
||||
status: null,
|
||||
worktree: "/tmp/wt",
|
||||
steps: [],
|
||||
/* The limbo gate requires status/mergeDetails/review/reviewState ALL null — `{}` is not null. */
|
||||
mergeDetails: undefined,
|
||||
log: [{ action: "Task marked done by agent", timestamp: new Date(Date.now() - 86_400_000).toISOString() }],
|
||||
} as unknown as Task;
|
||||
const { store } = productionFaithfulStore([wedged]);
|
||||
Object.assign(store, { logEntry: vi.fn(async () => undefined) });
|
||||
|
||||
const manager = new SelfHealingManager(store, { rootDir: "/repo" });
|
||||
vi.spyOn(manager as unknown as { isMergeLaneOwned: (id: string) => Promise<boolean> }, "isMergeLaneOwned")
|
||||
.mockResolvedValue(false);
|
||||
/*
|
||||
DOWNSTREAM of the blocker, deliberately. `isMergeLaneOwned` runs BEFORE it, so spying there would
|
||||
prove the read and say nothing about the wiring — an observable upstream of the thing under test is
|
||||
the same vacuity in a new costume. `recoverApprovedStrandedAiMergeCommit` is the first call after the
|
||||
blocker check.
|
||||
*/
|
||||
const pastBlocker = vi
|
||||
.spyOn(manager as unknown as {
|
||||
recoverApprovedStrandedAiMergeCommit: (t: Task, s: unknown) => Promise<boolean>;
|
||||
}, "recoverApprovedStrandedAiMergeCommit")
|
||||
.mockResolvedValue(true);
|
||||
|
||||
await manager.recoverCompletionHandoffLimbo();
|
||||
|
||||
expect(pastBlocker).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,7 +30,10 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync,
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { isAbsolute, join, relative, resolve } from "node:path";
|
||||
import { resolveColumnFlags, IN_REVIEW_STALL_DEADLOCK_LOG_PREFIX, IN_REVIEW_STALL_LOG_PREFIX, IN_REVIEW_STALL_TERMINAL_LOG_PREFIX, allowsAutoMergeProcessing, resolveEffectiveAutoMerge, countRecentIdenticalStallEntries, detectDependencyCycle, detectSelfDefeatingDependency, evaluateNoCommitsNoOpFinalize, evaluateCompletedPromotionFailureProvenance, evaluateSkipBypassTaint, getInReviewStalledSignal, getInReviewStallReason, getPrimaryPrInfo, getStalePausedReviewSignal, getStalePausedTodoSignal, getTaskHardMergeBlocker, getTaskMergeBlocker, isEphemeralAgent, isMergeRequestContractShadowEnabled, isWorkspaceTask, isSharedBranchGroupMemberIntegration, isNearDuplicateCanonicalInactive, parseExplicitDuplicateMarker, flagTriageDuplicate, isTriageDuplicateKeepAcknowledged, resolveMaxAutoMergeRetries, resolveOptionalStepRevisionBudget, resolveOptionalReviewRevisionBudget, getBuiltinWorkflow, isBuiltinWorkflowId, resolveWorkflowIrForTask, resolveReboundTarget, columnsWithFlag, resolveLifecycleColumns, resolveTaskLifecycleColumns, workflowHasColumn, planLegacyAdoption, resolveOrphanedPendingStepResults, classifyReviewLease, PLAN_REVIEW_LEASE_STALENESS_MS, DEFAULT_MAX_POST_REVIEW_FIXES, ACTIVE_WORKFLOW_WORK_ITEM_STATES, AWAITING_APPROVAL_PAUSE_REASON, type Agent, type AgentStore, type ChatStore, type MessageStore, type TaskStore, type Settings, type Task, type MergeDetails, type TaskPriority, type MergeResult, type WorkflowStepResult, type WorkflowIr } from "@fusion/core";
|
||||
import { resolveColumnFlags, IN_REVIEW_STALL_DEADLOCK_LOG_PREFIX, IN_REVIEW_STALL_LOG_PREFIX, IN_REVIEW_STALL_TERMINAL_LOG_PREFIX, allowsAutoMergeProcessing, resolveEffectiveAutoMerge, countRecentIdenticalStallEntries, detectDependencyCycle, detectSelfDefeatingDependency, evaluateNoCommitsNoOpFinalize, evaluateCompletedPromotionFailureProvenance, evaluateSkipBypassTaint, getInReviewStalledSignal, getInReviewStallReason, getPrimaryPrInfo, getStalePausedReviewSignal, getStalePausedTodoSignal, getTaskHardMergeBlocker, getTaskMergeBlocker, isEphemeralAgent, isMergeRequestContractShadowEnabled, isWorkspaceTask, isSharedBranchGroupMemberIntegration, isNearDuplicateCanonicalInactive, parseExplicitDuplicateMarker, flagTriageDuplicate, isTriageDuplicateKeepAcknowledged, resolveMaxAutoMergeRetries, resolveOptionalStepRevisionBudget, resolveOptionalReviewRevisionBudget, getBuiltinWorkflow, isBuiltinWorkflowId, resolveWorkflowIrForTask, resolveWorkflowIrForTaskWithProvenance, resolveReboundTarget, columnsWithFlag, resolveLifecycleColumns, resolveTaskLifecycleColumns, workflowHasColumn, planLegacyAdoption, resolveOrphanedPendingStepResults, classifyReviewLease, PLAN_REVIEW_LEASE_STALENESS_MS, DEFAULT_MAX_POST_REVIEW_FIXES, ACTIVE_WORKFLOW_WORK_ITEM_STATES, AWAITING_APPROVAL_PAUSE_REASON, type Agent, type AgentStore, type ChatStore, type MessageStore, type TaskStore, type Settings, type Task, type MergeDetails, type TaskPriority, type MergeResult, type WorkflowStepResult, type WorkflowIr,
|
||||
resolveProjectColumnsForRoles,
|
||||
REVIEW_ROLES,
|
||||
} from "@fusion/core";
|
||||
import { finalizePlanningSegment } from "@fusion/core";
|
||||
import type { MeshLeaseManager } from "./mesh-lease-manager.js";
|
||||
import { createLogger, schedulerLog } from "./logger.js";
|
||||
@@ -7062,9 +7065,46 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
||||
try {
|
||||
const settings = await this.store.getSettings();
|
||||
if (settings.globalPause || settings.enginePaused) return 0;
|
||||
const tasks = await this.store.listTasks({ column: "in-review", slim: true });
|
||||
/*
|
||||
FNXC:WorkflowResolvedColumns 2026-07-30-22:00 (the query-filter class, seventh sweep):
|
||||
`listTasks({ column: "in-review" })` returned EMPTY on a renamed board, so a task whose branch has
|
||||
NO commits ahead of base — a genuine no-op merge — was never finalised and sat in review forever.
|
||||
|
||||
Activation check run first: this sweep is one of the four holding both a literal query and an
|
||||
unwired `getTaskMergeBlocker`. Widening the read alone would have made it find renamed-board cards
|
||||
and then decline every one, so the guard is wired in the same change.
|
||||
|
||||
Lane set precomputed per card so the candidate filter stays synchronous; one resolution feeds both
|
||||
the lane test and the blocker.
|
||||
*/
|
||||
const noOpReviewColumns = await resolveProjectColumnsForRoles(this.store, REVIEW_ROLES);
|
||||
const noOpById = new Map<string, Task>();
|
||||
for (const column of noOpReviewColumns) {
|
||||
for (const task of await this.store.listTasks({ column, slim: true })) noOpById.set(task.id, task);
|
||||
}
|
||||
const tasks = [...noOpById.values()];
|
||||
const noOpIrCache = new Map<string, WorkflowIr>();
|
||||
const unresolvedNoOpCards: string[] = [];
|
||||
const noOpLanesByTask = new Map<string, ReadonlySet<string>>();
|
||||
for (const task of tasks) {
|
||||
const resolved = await resolveWorkflowIrForTaskWithProvenance(this.store, task.id, noOpIrCache)
|
||||
.catch(() => undefined);
|
||||
const own = resolved
|
||||
? [...new Set(REVIEW_ROLES.flatMap((role) => columnsWithFlag(resolved.ir, role)))]
|
||||
: [];
|
||||
if (!resolved || resolved.source === "default") unresolvedNoOpCards.push(task.id);
|
||||
/* DELIBERATE-LITERAL — the unresolvable-workflow default, reviewed 2026-07-30-22:00. */
|
||||
noOpLanesByTask.set(task.id, own.length > 0 ? new Set(own) : new Set(["in-review"]));
|
||||
}
|
||||
if (unresolvedNoOpCards.length > 0) {
|
||||
log.warn(
|
||||
`no-op review finalize: ${unresolvedNoOpCards.length} card(s) measured against the built-in `
|
||||
+ `review lane because their own workflow could not be resolved `
|
||||
+ `(${unresolvedNoOpCards.slice(0, 5).join(", ")}); a renamed review lane there stays unfinalised.`,
|
||||
);
|
||||
}
|
||||
const candidates = tasks.filter((t) =>
|
||||
t.column === "in-review" &&
|
||||
(noOpLanesByTask.get(t.id) ?? new Set(["in-review"])).has(t.column) &&
|
||||
allowsAutoMergeProcessing(t, settings) &&
|
||||
!t.paused &&
|
||||
// FNXC:AutoMergeHold 2026-07-09-17:10: FN-7750 intentionally keeps the pure branchContext-shape predicate here. Stale shared-group members must stay OUT of solo no-op finalize even when their group is not live; only the positive auto-merge-off exemption gates use the live-group predicate.
|
||||
@@ -7081,7 +7121,8 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
||||
t.status !== "merging-pr" &&
|
||||
t.status !== "awaiting-user-review" &&
|
||||
t.status !== "failed" &&
|
||||
getTaskMergeBlocker(t) === undefined,
|
||||
/* Wired: unwired, this would decline every card the widened read now finds. */
|
||||
getTaskMergeBlocker(t, { reviewColumns: noOpLanesByTask.get(t.id) ?? new Set(["in-review"]) }) === undefined,
|
||||
);
|
||||
|
||||
if (candidates.length === 0) return 0;
|
||||
@@ -7265,12 +7306,100 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
||||
|
||||
async reconcileDoneTaskIntegrity(): Promise<number> {
|
||||
try {
|
||||
const tasks = await this.store.listTasks({ column: "done", slim: true });
|
||||
const candidates = tasks.filter((task) =>
|
||||
task.column === "done" &&
|
||||
/*
|
||||
FNXC:WorkflowResolvedColumns 2026-07-30-16:50 (the query-filter class, now unblocked):
|
||||
THE QUERY WAS THE BUG, not the comparison below it. `listTasks({ column: "done" })` returns an
|
||||
EMPTY array on a board whose complete lane is renamed, so this sweep never ran — proven in
|
||||
`self-healing-query-filter-blindness.test.ts` (#2800), which asserts the query ARGUMENT because
|
||||
the outcome is 0 either way.
|
||||
|
||||
`resolveProjectColumnsForRoles` is the seam that fix needed: a read happens before any task is in
|
||||
hand, so there is nothing to resolve a per-task lane from. It answers the PROJECT-level question —
|
||||
every column any workflow here declares for the role — and unions the legacy ids, so a board
|
||||
mid-rename still surfaces rows stored under the old one.
|
||||
|
||||
Same three-line shape as `stale-task-reporter` and `backlog-pressure-reporter`: resolve the roles,
|
||||
iterate the set, dedupe by id.
|
||||
|
||||
FNXC:WorkflowResolvedColumns 2026-07-30-17:20 (#2838 review — greptile P1):
|
||||
THE PROJECT UNION IS FOR THE QUERY, NEVER FOR THE PER-CARD TEST. My first version re-asserted
|
||||
`completeColumns.has(task.column)`, which is the flat-set mistake `project-lane-vocabulary.ts`
|
||||
warns about in its own header — a card is claimed as complete because SOME OTHER workflow in the
|
||||
project calls its column complete. On a project with two boards, one naming its wip lane the same
|
||||
as another's complete lane, this sweep would rewrite merge evidence onto a card still being worked.
|
||||
|
||||
Widening the read and widening the verdict are different decisions. The read must over-include
|
||||
(a missed row is invisible); the verdict must not (a wrong row is a write). So the candidate filter
|
||||
resolves each card against ITS OWN workflow.
|
||||
*/
|
||||
const completeColumns = await resolveProjectColumnsForRoles(this.store, ["complete"]);
|
||||
const byId = new Map<string, Task>();
|
||||
for (const column of completeColumns) {
|
||||
for (const task of await this.store.listTasks({ column, slim: true })) byId.set(task.id, task);
|
||||
}
|
||||
const shortlist = [...byId.values()].filter((task) =>
|
||||
(!task.mergeDetails?.commitSha || task.mergeDetails.commitSha.trim().length === 0) &&
|
||||
(task.modifiedFiles?.length ?? 0) > 0,
|
||||
).slice(0, DONE_TASK_INTEGRITY_SWEEP_LIMIT);
|
||||
);
|
||||
const perTaskIrCache = new Map<string, WorkflowIr>();
|
||||
const candidates: Task[] = [];
|
||||
/* Cards whose own workflow could not be resolved. Collected so an unrepairable backlog is
|
||||
reportable rather than silent — see the provenance note in the loop. */
|
||||
const unresolvedWorkflowCards: string[] = [];
|
||||
for (const task of shortlist) {
|
||||
if (candidates.length >= DONE_TASK_INTEGRITY_SWEEP_LIMIT) break;
|
||||
/*
|
||||
FNXC:WorkflowResolvedColumns 2026-07-30-17:55 (#2838 review — greptile P1, "workflow fallback
|
||||
drops renamed completions"):
|
||||
|
||||
PROVENANCE, BECAUSE THIS RESOLVER DOES NOT FAIL — IT SUBSTITUTES.
|
||||
|
||||
`resolveWorkflowIrForTask` degrades to the BUILT-IN coding IR rather than throwing, so the
|
||||
`.catch(() => undefined)` protected almost nothing: when a task's selection cannot be resolved
|
||||
the call SUCCEEDS and hands back a board whose complete lane is `done`. The old line then read
|
||||
`ownComplete.length > 0` as "this card answered", so a renamed-lane card was measured against
|
||||
the built-in vocabulary, rejected, and — because this sweep is the thing that would have
|
||||
repaired its missing merge evidence — rejected again on every subsequent sweep.
|
||||
|
||||
The provenance form separates "the card's own workflow says complete = X" from "nobody could
|
||||
say, here is the default". Only the first is an answer.
|
||||
|
||||
THE VERDICT IS DELIBERATELY UNCHANGED, AND I MEASURED THAT RATHER THAN ASSUMING IT. My first
|
||||
version also gated `ownComplete` on the provenance, which READS like the fix and is inert: a
|
||||
defaulted card resolves to the built-in `complete = ["done"]`, and gating it to `[]` falls
|
||||
through to the literal `done` — the same verdict by a different route. Mutating the gate away
|
||||
left the suite green, which is how I found it. Shipping it would have been a conversion that
|
||||
scores as a win and changes nothing, so it is gone.
|
||||
|
||||
The verdict SHOULD stay conservative here: this sweep WRITES merge evidence, and guessing a lane
|
||||
to rewrite history onto the wrong card is worse than leaving one unrepaired. What the provenance
|
||||
buys is the thing that was actually missing — the unresolvable card is now REPORTED instead of
|
||||
being indistinguishable from a card that answered, so "unrepaired" stops meaning "unrepairable
|
||||
and invisible forever".
|
||||
*/
|
||||
const resolved = await resolveWorkflowIrForTaskWithProvenance(this.store, task.id, perTaskIrCache)
|
||||
.catch(() => undefined);
|
||||
const ownComplete = resolved ? columnsWithFlag(resolved.ir, "complete") : [];
|
||||
/* A THROW is unresolvable too. Checking only `source === "default"` left the rarer path — the
|
||||
resolver raising rather than substituting — silently discarded, which is the same invisibility
|
||||
this change exists to remove, one branch over. */
|
||||
if (!resolved || resolved.source === "default") unresolvedWorkflowCards.push(task.id);
|
||||
/* DELIBERATE-LITERAL — the degraded per-card default, reviewed 2026-07-30-17:20. Reached when the
|
||||
card's own workflow could not be resolved AT ALL, or resolves and declares no complete lane. The
|
||||
project union must not stand in here: that is the flat-set mistake this filter exists to avoid,
|
||||
so the legacy id is the conservative answer. The census ratchet flagged this line when it
|
||||
appeared, which is the guard working. */
|
||||
const isComplete = ownComplete.length > 0 ? ownComplete.includes(task.column) : task.column === "done";
|
||||
if (isComplete) candidates.push(task);
|
||||
}
|
||||
|
||||
if (unresolvedWorkflowCards.length > 0) {
|
||||
log.warn(
|
||||
`done-task integrity sweep: ${unresolvedWorkflowCards.length} card(s) measured against the `
|
||||
+ `built-in complete lane because their own workflow could not be resolved `
|
||||
+ `(${unresolvedWorkflowCards.slice(0, 5).join(", ")}); a renamed completion lane there stays unrepaired.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (candidates.length === 0) return 0;
|
||||
const settings = await this.store.getSettings();
|
||||
@@ -7412,7 +7541,38 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
||||
const settings = await this.store.getSettings();
|
||||
if (settings.globalPause || settings.enginePaused) return 0;
|
||||
const maxAutoMergeRetries = resolveMaxAutoMergeRetries(settings);
|
||||
const tasks = await this.store.listTasks({ column: "in-review", slim: true });
|
||||
/*
|
||||
FNXC:WorkflowResolvedColumns 2026-07-30-20:20 (the query-filter class, fifth sweep):
|
||||
Fifth application of the four-part shape in
|
||||
`docs/solutions/architecture-patterns/self-healing-sweeps-are-blind-on-a-renamed-board.md`.
|
||||
`listTasks({ column: "in-review" })` returns EMPTY on a renamed board, so a card that is genuinely
|
||||
ready to merge — every step done, no blocker — was never re-enqueued and sat in review forever.
|
||||
|
||||
Read via the project union; verdict per card against its own workflow, because this sweep enqueues
|
||||
a MERGE; provenance so an unresolvable card is reported rather than silently skipped.
|
||||
*/
|
||||
const mergeableReviewColumns = await resolveProjectColumnsForRoles(this.store, REVIEW_ROLES);
|
||||
const mergeableById = new Map<string, Task>();
|
||||
for (const column of mergeableReviewColumns) {
|
||||
for (const task of await this.store.listTasks({ column, slim: true })) mergeableById.set(task.id, task);
|
||||
}
|
||||
const tasks = [...mergeableById.values()];
|
||||
const mergeableIrCache = new Map<string, WorkflowIr>();
|
||||
const unresolvedMergeableCards: string[] = [];
|
||||
/*
|
||||
Returns the card's OWN review lanes, so one resolution feeds BOTH consumers below — the lane test
|
||||
and `getTaskMergeBlocker`. Two resolutions of the same fact is how the halves of one decision drift.
|
||||
*/
|
||||
const ownReviewLanesFor = async (task: Task): Promise<ReadonlySet<string>> => {
|
||||
const resolved = await resolveWorkflowIrForTaskWithProvenance(this.store, task.id, mergeableIrCache)
|
||||
.catch(() => undefined);
|
||||
const own = resolved
|
||||
? [...new Set(REVIEW_ROLES.flatMap((role) => columnsWithFlag(resolved.ir, role)))]
|
||||
: [];
|
||||
if (!resolved || resolved.source === "default") unresolvedMergeableCards.push(task.id);
|
||||
/* DELIBERATE-LITERAL — the unresolvable-workflow default, reviewed 2026-07-30-20:20. */
|
||||
return own.length > 0 ? new Set(own) : new Set(["in-review"]);
|
||||
};
|
||||
/*
|
||||
FNXC:WorkflowReviewGates 2026-07-26-15:30:
|
||||
Liveness gate, mirroring `recoverGhostReviewTasks` and
|
||||
@@ -7432,7 +7592,6 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
||||
const executingIds = this.options.getExecutingTaskIds?.() ?? new Set<string>();
|
||||
|
||||
const mergeable = tasks.filter((t) =>
|
||||
t.column === "in-review" &&
|
||||
allowsAutoMergeProcessing(t, settings) &&
|
||||
!t.paused &&
|
||||
!executingIds.has(t.id) &&
|
||||
@@ -7455,13 +7614,38 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
||||
// refreshes updatedAt, preventing cooldown-based retries from ever
|
||||
// becoming eligible. Also skip tasks explicitly tagged as no-op merges
|
||||
// in case updateTask(moveTask) is briefly out-of-order during recovery.
|
||||
(t.mergeRetries ?? 0) < maxAutoMergeRetries &&
|
||||
getTaskMergeBlocker(t) === undefined,
|
||||
(t.mergeRetries ?? 0) < maxAutoMergeRetries,
|
||||
);
|
||||
/*
|
||||
FNXC:WorkflowResolvedColumns 2026-07-30-20:20 (widening a query makes a dormant guard REACHABLE):
|
||||
`getTaskMergeBlocker` moved out of the synchronous filter above because it needs this card's
|
||||
resolved review lanes, and those need an await.
|
||||
|
||||
This matters beyond tidiness. That call was previously UNWIRED — it took the legacy `in-review`
|
||||
default — and was harmless only because the literal query above meant a renamed board never reached
|
||||
it. Widening the read makes it reachable for the first time, so left as-is it would have refused
|
||||
every card on exactly the boards this fix is for: the sweep would find them and then decline them.
|
||||
|
||||
Converting a query is therefore not just a read change; it activates every guard downstream of it.
|
||||
*/
|
||||
const laneQualifiedMergeable: Task[] = [];
|
||||
for (const task of mergeable) {
|
||||
const reviewColumns = await ownReviewLanesFor(task);
|
||||
if (!reviewColumns.has(task.column)) continue;
|
||||
if (getTaskMergeBlocker(task, { reviewColumns }) !== undefined) continue;
|
||||
laneQualifiedMergeable.push(task);
|
||||
}
|
||||
if (unresolvedMergeableCards.length > 0) {
|
||||
log.warn(
|
||||
`mergeable-review recovery: ${unresolvedMergeableCards.length} card(s) measured against the `
|
||||
+ `built-in review lane because their own workflow could not be resolved `
|
||||
+ `(${unresolvedMergeableCards.slice(0, 5).join(", ")}); a renamed review lane there stays stuck.`,
|
||||
);
|
||||
}
|
||||
const ownershipFlags = await Promise.all(
|
||||
mergeable.map((task) => this.isMergeLaneOwned(task.id)),
|
||||
laneQualifiedMergeable.map((task) => this.isMergeLaneOwned(task.id)),
|
||||
);
|
||||
const unownedMergeable = mergeable.filter((_, i) => !ownershipFlags[i]);
|
||||
const unownedMergeable = laneQualifiedMergeable.filter((_, i) => !ownershipFlags[i]);
|
||||
|
||||
const inReviewIds = new Set(tasks.map((task) => task.id));
|
||||
const mergeableIds = new Set(unownedMergeable.map((task) => task.id));
|
||||
@@ -7473,7 +7657,9 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
||||
|
||||
if (unownedMergeable.length === 0) return 0;
|
||||
|
||||
log.warn(`Found ${unownedMergeable.length} mergeable review task(s) stuck in in-review`);
|
||||
log.warn(
|
||||
`Found ${unownedMergeable.length} mergeable review task(s) stuck in ${[...mergeableReviewColumns].join(", ")}`,
|
||||
);
|
||||
|
||||
// Prefer the engine's merge queue so `mergeStrategy` (direct vs.
|
||||
// pull-request) is honored. Fall back to a direct store merge only
|
||||
@@ -7555,7 +7741,48 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
||||
const settings = await this.store.getSettings();
|
||||
if (settings.globalPause || settings.enginePaused) return 0;
|
||||
|
||||
const tasks = await this.store.listTasks({ column: "in-review", slim: true });
|
||||
/*
|
||||
FNXC:WorkflowResolvedColumns 2026-07-30-21:20 (the query-filter class, sixth sweep — and the first
|
||||
converted with the ACTIVATION check run FIRST, per
|
||||
`docs/solutions/architecture-patterns/self-healing-sweeps-are-blind-on-a-renamed-board.md`):
|
||||
|
||||
`listTasks({ column: "in-review" })` returned EMPTY on a renamed board, so a card parked with a
|
||||
FAILED pre-merge review step was never auto-revived and stayed parked until a human noticed.
|
||||
|
||||
The activation check mattered here more than anywhere so far. This sweep's filter asks
|
||||
`blocker !== "task has failed pre-merge workflow steps"` — an EXACT STRING match. Unwired on a
|
||||
renamed board the blocker instead returns "task is in 'checking', must be in 'in-review'", which is
|
||||
not that string, so every card would be rejected the moment the widened query started finding them.
|
||||
Wiring the blocker is therefore part of this conversion, not a follow-up.
|
||||
*/
|
||||
const failedStepReviewColumns = await resolveProjectColumnsForRoles(this.store, REVIEW_ROLES);
|
||||
const failedStepById = new Map<string, Task>();
|
||||
for (const column of failedStepReviewColumns) {
|
||||
for (const task of await this.store.listTasks({ column, slim: true })) failedStepById.set(task.id, task);
|
||||
}
|
||||
const tasks = [...failedStepById.values()];
|
||||
const failedStepIrCache = new Map<string, WorkflowIr>();
|
||||
const unresolvedFailedStepCards: string[] = [];
|
||||
/* One resolution per card, feeding BOTH the lane test and the blocker below. */
|
||||
const ownReviewLanesForFailedStep = async (task: Task): Promise<ReadonlySet<string>> => {
|
||||
const resolved = await resolveWorkflowIrForTaskWithProvenance(this.store, task.id, failedStepIrCache)
|
||||
.catch(() => undefined);
|
||||
const own = resolved
|
||||
? [...new Set(REVIEW_ROLES.flatMap((role) => columnsWithFlag(resolved.ir, role)))]
|
||||
: [];
|
||||
if (!resolved || resolved.source === "default") unresolvedFailedStepCards.push(task.id);
|
||||
/* DELIBERATE-LITERAL — the unresolvable-workflow default, reviewed 2026-07-30-21:20. */
|
||||
return own.length > 0 ? new Set(own) : new Set(["in-review"]);
|
||||
};
|
||||
const reviewLanesByTask = new Map<string, ReadonlySet<string>>();
|
||||
for (const task of tasks) reviewLanesByTask.set(task.id, await ownReviewLanesForFailedStep(task));
|
||||
if (unresolvedFailedStepCards.length > 0) {
|
||||
log.warn(
|
||||
`failed-pre-merge-step revival: ${unresolvedFailedStepCards.length} card(s) measured against the `
|
||||
+ `built-in review lane because their own workflow could not be resolved `
|
||||
+ `(${unresolvedFailedStepCards.slice(0, 5).join(", ")}); a renamed review lane there stays parked.`,
|
||||
);
|
||||
}
|
||||
const executingIds = this.options.getExecutingTaskIds?.() ?? new Set<string>();
|
||||
|
||||
const latestFailedPreMergeStep = (task: Pick<Task, "workflowStepResults">): WorkflowStepResult | undefined => {
|
||||
@@ -7643,7 +7870,8 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
||||
};
|
||||
|
||||
const candidates = tasks.filter((task) => {
|
||||
if (task.column !== "in-review") return false;
|
||||
/* Precomputed above, so this filter stays synchronous. */
|
||||
if (!(reviewLanesByTask.get(task.id) ?? new Set(["in-review"])).has(task.column)) return false;
|
||||
if (!allowsAutoMergeProcessing(task, settings)) return false;
|
||||
if (task.paused) return false;
|
||||
/*
|
||||
@@ -7666,7 +7894,11 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
||||
// Merge must be blocked *specifically* by the failed pre-merge step —
|
||||
// not by an unrelated condition (incomplete steps, etc.) that is
|
||||
// already handled by a dedicated scan.
|
||||
const blocker = getTaskMergeBlocker(task);
|
||||
/* Wired: this comparison is an EXACT STRING match, so an unwired blocker returning
|
||||
"task is in '<lane>', must be in 'in-review'" would reject every card on a renamed board. */
|
||||
const blocker = getTaskMergeBlocker(task, {
|
||||
reviewColumns: reviewLanesByTask.get(task.id) ?? new Set(["in-review"]),
|
||||
});
|
||||
if (!parkedRemediationFailure && blocker !== "task has failed pre-merge workflow steps") return false;
|
||||
|
||||
// The retry flow injects into PROMPT.md + re-executes on the worktree.
|
||||
@@ -8401,23 +8633,62 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
||||
const timeoutMs = settings.taskStuckTimeoutMs;
|
||||
if (!timeoutMs || timeoutMs <= 0) return 0;
|
||||
|
||||
const tasks = await this.store.listTasks({ column: "in-review", slim: true });
|
||||
const statusCandidates = tasks.filter((task) =>
|
||||
task.column === "in-review" &&
|
||||
/*
|
||||
FNXC:WorkflowResolvedColumns 2026-07-30-19:50 (the query-filter class, fourth sweep):
|
||||
Same shape as its three siblings: `listTasks({ column: "in-review" })` returns EMPTY on a renamed
|
||||
board, so a task interrupted mid-merge — status still `merging`, no live session behind it — was
|
||||
never recovered and sat in that state indefinitely.
|
||||
|
||||
Project union for the READ; per-card verdict for the MUTATION, since this sweep rewrites status and
|
||||
clears merge state. Provenance so an unresolvable card is reported rather than silently skipped —
|
||||
identical to `recoverAlreadyMergedReviewTasks` and `recoverStuckMergeDeadlocks`, so the four cannot
|
||||
drift apart.
|
||||
*/
|
||||
const interruptedReviewColumns = await resolveProjectColumnsForRoles(this.store, REVIEW_ROLES);
|
||||
const interruptedById = new Map<string, Task>();
|
||||
for (const column of interruptedReviewColumns) {
|
||||
for (const task of await this.store.listTasks({ column, slim: true })) interruptedById.set(task.id, task);
|
||||
}
|
||||
const interruptedIrCache = new Map<string, WorkflowIr>();
|
||||
const unresolvedInterruptedCards: string[] = [];
|
||||
const inOwnReviewLaneForInterrupted = async (task: Task): Promise<boolean> => {
|
||||
const resolved = await resolveWorkflowIrForTaskWithProvenance(this.store, task.id, interruptedIrCache)
|
||||
.catch(() => undefined);
|
||||
const own = resolved
|
||||
? [...new Set(REVIEW_ROLES.flatMap((role) => columnsWithFlag(resolved.ir, role)))]
|
||||
: [];
|
||||
if (!resolved || resolved.source === "default") unresolvedInterruptedCards.push(task.id);
|
||||
/* DELIBERATE-LITERAL — the unresolvable-workflow default, reviewed 2026-07-30-19:50. */
|
||||
return own.length > 0 ? own.includes(task.column) : task.column === "in-review";
|
||||
};
|
||||
const statusCandidates = [...interruptedById.values()].filter((task) =>
|
||||
allowsAutoMergeProcessing(task, settings) &&
|
||||
!task.paused &&
|
||||
Boolean(task.status && ACTIVE_MERGE_STATUSES.has(task.status)),
|
||||
);
|
||||
const candidates: Task[] = [];
|
||||
for (const task of statusCandidates) {
|
||||
if (!(await inOwnReviewLaneForInterrupted(task))) continue;
|
||||
if (await this.isPastInterruptedMergeGraceAsync(task, timeoutMs)) {
|
||||
candidates.push(task);
|
||||
}
|
||||
}
|
||||
|
||||
if (unresolvedInterruptedCards.length > 0) {
|
||||
log.warn(
|
||||
`interrupted-merge recovery: ${unresolvedInterruptedCards.length} card(s) measured against the `
|
||||
+ `built-in review lane because their own workflow could not be resolved `
|
||||
+ `(${unresolvedInterruptedCards.slice(0, 5).join(", ")}); a renamed review lane there stays stuck.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (candidates.length === 0) return 0;
|
||||
|
||||
log.warn(`Found ${candidates.length} stale merging task(s) in in-review`);
|
||||
/* Names the lanes actually searched rather than the literal `in-review`, which is no longer what
|
||||
was queried and would misreport the board this ran against. */
|
||||
log.warn(
|
||||
`Found ${candidates.length} stale merging task(s) in ${[...interruptedReviewColumns].join(", ")}`,
|
||||
);
|
||||
|
||||
let recovered = 0;
|
||||
for (const task of candidates) {
|
||||
@@ -9408,22 +9679,54 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
||||
if (settings.globalPause || settings.enginePaused) return 0;
|
||||
const maxAutoMergeRetries = resolveMaxAutoMergeRetries(settings);
|
||||
const now = Date.now();
|
||||
const inReview = await this.store.listTasks({ column: "in-review", slim: true });
|
||||
/*
|
||||
FNXC:WorkflowColumns 2026-07-29-17:40 (PR #2560 review):
|
||||
This trio is a UNION and is deliberately left on literals. For the merged
|
||||
default lineage the `triage` read returns empty and `todo` supplies the cards;
|
||||
for a legacy/custom workflow that still declares `triage` it supplies them.
|
||||
Either way the union is complete, and the role filter below decides which rows
|
||||
count as pre-WIP. Unlike the recoverAdvancedTriageTasks query this replaces
|
||||
nothing and disables nothing — it is a redundant read, not a dead sweep.
|
||||
FNXC:WorkflowResolvedColumns 2026-07-30-19:20 (the query-filter class, third sweep):
|
||||
FOUR reads, two different jobs, and only the reads were broken.
|
||||
|
||||
`inReview` supplies the CANDIDATES — cards blocked on merge — so it needs both halves: the project
|
||||
union for the read, and a per-card verdict against the card's own workflow, because this sweep
|
||||
mutates. Same shape as `recoverAlreadyMergedReviewTasks`, provenance and reporting included.
|
||||
|
||||
The other three supply DEPENDENTS, and their verdict is already correct: `filterByPreWipRole`
|
||||
resolves intake/hold per card below. The 2026-07-29-17:40 note reasoned that the literal trio was a
|
||||
complete union "and the role filter below decides which rows count" — which held for the default and
|
||||
legacy lineages it considered, and does not hold for a RENAMED board, where all three reads return
|
||||
empty and the role filter is handed nothing to decide about. Widening the reads restores exactly the
|
||||
property that note relied on; the filter it points at is unchanged.
|
||||
*/
|
||||
const triage = await this.store.listTasks({ column: "triage", slim: true });
|
||||
const todo = await this.store.listTasks({ column: "todo", slim: true });
|
||||
const inProgress = await this.store.listTasks({ column: "in-progress", slim: true });
|
||||
const reviewColumnsForDeadlock = await resolveProjectColumnsForRoles(this.store, REVIEW_ROLES);
|
||||
const inReviewById = new Map<string, Task>();
|
||||
for (const column of reviewColumnsForDeadlock) {
|
||||
for (const task of await this.store.listTasks({ column, slim: true })) inReviewById.set(task.id, task);
|
||||
}
|
||||
const deadlockIrCache = new Map<string, WorkflowIr>();
|
||||
const unresolvedDeadlockCards: string[] = [];
|
||||
const inOwnReviewLaneForDeadlock = async (task: Task): Promise<boolean> => {
|
||||
const resolved = await resolveWorkflowIrForTaskWithProvenance(this.store, task.id, deadlockIrCache)
|
||||
.catch(() => undefined);
|
||||
const own = resolved
|
||||
? [...new Set(REVIEW_ROLES.flatMap((role) => columnsWithFlag(resolved.ir, role)))]
|
||||
: [];
|
||||
if (!resolved || resolved.source === "default") unresolvedDeadlockCards.push(task.id);
|
||||
/* DELIBERATE-LITERAL — the unresolvable-workflow default, reviewed 2026-07-30-19:20. */
|
||||
return own.length > 0 ? own.includes(task.column) : task.column === "in-review";
|
||||
};
|
||||
const inReview = [...inReviewById.values()];
|
||||
|
||||
/* Dependents: read every pre-WIP and WIP lane the project declares; `filterByPreWipRole` below
|
||||
still decides per card which of them actually count. */
|
||||
const dependentSourceColumns = await resolveProjectColumnsForRoles(
|
||||
this.store,
|
||||
["intake", "hold", "countsTowardWip"],
|
||||
);
|
||||
const dependentsById = new Map<string, Task>();
|
||||
for (const column of dependentSourceColumns) {
|
||||
for (const task of await this.store.listTasks({ column, slim: true })) dependentsById.set(task.id, task);
|
||||
}
|
||||
const dependentSources = [...dependentsById.values()];
|
||||
|
||||
const dependentsByBlocker = new Map<string, Task[]>();
|
||||
for (const task of [...triage, ...todo, ...inProgress]) {
|
||||
for (const task of dependentSources) {
|
||||
if (!task.blockedBy) continue;
|
||||
const dependents = dependentsByBlocker.get(task.blockedBy) ?? [];
|
||||
dependents.push(task);
|
||||
@@ -9449,8 +9752,9 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
||||
const hasBlockedDependents = (dependentsByBlocker.get(task.id) ?? []).some(
|
||||
(dep) => preWipDependentIds.has(dep.id),
|
||||
);
|
||||
return task.column === "in-review" &&
|
||||
allowsAutoMergeProcessing(task, settings) &&
|
||||
/* Lane identity is decided per card by `inOwnReviewLaneForDeadlock` below — this synchronous
|
||||
filter keeps every other condition, so the async check runs only for rows that already qualify. */
|
||||
return allowsAutoMergeProcessing(task, settings) &&
|
||||
!task.paused &&
|
||||
task.status === "failed" &&
|
||||
(task.mergeRetries ?? 0) >= maxAutoMergeRetries &&
|
||||
@@ -9459,10 +9763,23 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
||||
cooldownElapsed >= DEADLOCK_RECOVERY_COOLDOWN_MS;
|
||||
});
|
||||
|
||||
if (candidates.length === 0) return 0;
|
||||
/* The per-card lane verdict is async, so it cannot live in the filter above. */
|
||||
const laneQualified: Task[] = [];
|
||||
for (const task of candidates) {
|
||||
if (await inOwnReviewLaneForDeadlock(task)) laneQualified.push(task);
|
||||
}
|
||||
if (unresolvedDeadlockCards.length > 0) {
|
||||
log.warn(
|
||||
`merge-deadlock recovery: ${unresolvedDeadlockCards.length} card(s) measured against the `
|
||||
+ `built-in review lane because their own workflow could not be resolved `
|
||||
+ `(${unresolvedDeadlockCards.slice(0, 5).join(", ")}); a renamed review lane there stays deadlocked.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (laneQualified.length === 0) return 0;
|
||||
|
||||
let recovered = 0;
|
||||
for (const task of candidates) {
|
||||
for (const task of laneQualified) {
|
||||
if (task.deletedAt) continue;
|
||||
const blockedDependents = dependentsByBlocker.get(task.id) ?? [];
|
||||
const blockedTaskIds = blockedDependents.map((dep) => dep.id);
|
||||
@@ -9819,10 +10136,65 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
||||
if (settings.globalPause || settings.enginePaused) return 0;
|
||||
const maxAutoMergeRetries = resolveMaxAutoMergeRetries(settings);
|
||||
const executingIds = this.options.getExecutingTaskIds?.() ?? new Set<string>();
|
||||
const tasks = await this.store.listTasks({ column: "in-review", slim: true });
|
||||
const candidates = tasks.filter((task) =>
|
||||
/*
|
||||
FNXC:WorkflowResolvedColumns 2026-07-30-18:20 (the query-filter class, second sweep):
|
||||
THE QUERY WAS THE BUG. `listTasks({ column: "in-review" })` returns EMPTY on a board whose review
|
||||
lane is renamed, so this sweep never ran — and it is the one that rescues a card whose merge
|
||||
ACTUALLY SUCCEEDED but is parked in review with `status: "failed"`. Unrun, that card stays stuck
|
||||
permanently with a merge nobody reconciles.
|
||||
|
||||
Read widened via the project union (a read happens before any task is in hand); the per-card verdict
|
||||
below is resolved against THAT CARD's own workflow, because this sweep mutates. Widening the read
|
||||
and widening the verdict are different decisions — see `reconcileDoneTaskIntegrity`.
|
||||
*/
|
||||
const reviewColumns = await resolveProjectColumnsForRoles(this.store, REVIEW_ROLES);
|
||||
const byId = new Map<string, Task>();
|
||||
for (const column of reviewColumns) {
|
||||
for (const task of await this.store.listTasks({ column, slim: true })) byId.set(task.id, task);
|
||||
}
|
||||
const reviewIrCache = new Map<string, WorkflowIr>();
|
||||
/* Cards whose own workflow could not be resolved — reported below rather than silently dropped. */
|
||||
const unresolvedReviewCards: string[] = [];
|
||||
/*
|
||||
FNXC:WorkflowResolvedColumns 2026-07-30-18:50 (#2838 review — greptile P1, same class as the
|
||||
done-integrity sweep one commit earlier):
|
||||
PROVENANCE, BECAUSE THIS RESOLVER DOES NOT FAIL — IT SUBSTITUTES. `resolveWorkflowIrForTask`
|
||||
degrades to the BUILT-IN coding IR rather than throwing, so `own.length > 0` read as "this card
|
||||
answered" when nobody had: a renamed-lane card was measured against the built-in `in-review`,
|
||||
rejected, and — because this sweep is what would have rescued its already-merged state — rejected
|
||||
again on every subsequent pass.
|
||||
|
||||
I wrote this sweep before that fix landed on the done-integrity one and reproduced its pre-fix
|
||||
shape verbatim. Same resolution, same reporting, so the two cannot drift.
|
||||
|
||||
The VERDICT stays conservative: this sweep mutates a task's column and status, and guessing a lane
|
||||
is worse than leaving one unrescued. What provenance buys is that the unrescued card is REPORTED.
|
||||
*/
|
||||
/*
|
||||
FNXC:WorkflowResolvedColumns 2026-07-30-20:50 (widening this sweep's query ACTIVATED a guard below):
|
||||
Returns the card's OWN review lanes rather than a boolean, so ONE resolution feeds BOTH consumers —
|
||||
this sweep's lane test and the `getTaskHardMergeBlocker` call in its recovery loop.
|
||||
|
||||
That blocker was unwired and UNREACHABLE while this sweep's query was a literal. Widening the read
|
||||
made it reachable: the sweep now finds renamed-board cards, and unwired it would decline every one,
|
||||
so the conversion would have looked complete and delivered nothing. Found by scanning this file for
|
||||
sweeps holding BOTH a literal query and an unwired lane guard — six of them, this one included.
|
||||
*/
|
||||
const ownReviewLanesForAlreadyMerged = async (task: Task): Promise<ReadonlySet<string>> => {
|
||||
const resolved = await resolveWorkflowIrForTaskWithProvenance(this.store, task.id, reviewIrCache)
|
||||
.catch(() => undefined);
|
||||
const own = resolved
|
||||
? [...new Set(REVIEW_ROLES.flatMap((role) => columnsWithFlag(resolved.ir, role)))]
|
||||
: [];
|
||||
/* A THROW is unresolvable too — the rarer path where the resolver raises rather than substitutes. */
|
||||
if (!resolved || resolved.source === "default") unresolvedReviewCards.push(task.id);
|
||||
/* DELIBERATE-LITERAL — the unresolvable-workflow default, reviewed 2026-07-30-18:50. */
|
||||
return own.length > 0 ? new Set(own) : new Set(["in-review"]);
|
||||
};
|
||||
const inOwnReviewLane = async (task: Task): Promise<boolean> =>
|
||||
(await ownReviewLanesForAlreadyMerged(task)).has(task.column);
|
||||
const shortlist = [...byId.values()].filter((task) =>
|
||||
!task.deletedAt &&
|
||||
task.column === "in-review" &&
|
||||
allowsAutoMergeProcessing(task, settings) &&
|
||||
task.status === "failed" &&
|
||||
(task.mergeRetries ?? 0) >= maxAutoMergeRetries &&
|
||||
@@ -9830,6 +10202,19 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
||||
!executingIds.has(task.id),
|
||||
);
|
||||
|
||||
/* The per-card lane verdict is async, so it cannot live in the filter above. */
|
||||
const candidates: Task[] = [];
|
||||
for (const task of shortlist) {
|
||||
if (await inOwnReviewLane(task)) candidates.push(task);
|
||||
}
|
||||
if (unresolvedReviewCards.length > 0) {
|
||||
log.warn(
|
||||
`already-merged review rescue: ${unresolvedReviewCards.length} card(s) measured against the `
|
||||
+ `built-in review lane because their own workflow could not be resolved `
|
||||
+ `(${unresolvedReviewCards.slice(0, 5).join(", ")}); a renamed review lane there stays unrescued.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (candidates.length === 0) return 0;
|
||||
|
||||
let recovered = 0;
|
||||
@@ -9904,11 +10289,12 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
||||
mergeTargetSource: mergeTarget.source,
|
||||
};
|
||||
|
||||
/* Wired with THIS card's review lanes — see the note on `ownReviewLanesForAlreadyMerged` above. */
|
||||
const hardBlocker = getTaskHardMergeBlocker({
|
||||
...task,
|
||||
steps: task.steps ?? [],
|
||||
workflowStepResults: task.workflowStepResults,
|
||||
});
|
||||
}, { reviewColumns: await ownReviewLanesForAlreadyMerged(task) });
|
||||
if (hardBlocker) {
|
||||
await this.store.updateTask(task.id, {
|
||||
status: "failed",
|
||||
@@ -10292,11 +10678,40 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
||||
async recoverCompletionHandoffLimbo(): Promise<void> {
|
||||
const settings = await this.store.getSettings();
|
||||
if (settings.globalPause || settings.enginePaused) return;
|
||||
const tasks = await this.store.listTasks({ column: "in-review", slim: false });
|
||||
/*
|
||||
FNXC:WorkflowResolvedColumns 2026-07-30-22:40 (the query-filter class, eighth sweep):
|
||||
`listTasks({ column: "in-review" })` returned EMPTY on a renamed board, so a task stranded in
|
||||
completion-handoff limbo — falsely marked exhausted while the merge queue already owns it — was never
|
||||
cleared and stayed wedged.
|
||||
|
||||
Activation check first: this is one of the three remaining sweeps holding both a literal query and an
|
||||
unwired `getTaskMergeBlocker`, so the guard is wired in the same change. This loop is already async,
|
||||
so the per-card resolution happens inline rather than needing a precomputed map.
|
||||
*/
|
||||
const limboReviewColumns = await resolveProjectColumnsForRoles(this.store, REVIEW_ROLES);
|
||||
const limboById = new Map<string, Task>();
|
||||
for (const column of limboReviewColumns) {
|
||||
for (const task of await this.store.listTasks({ column, slim: false })) limboById.set(task.id, task);
|
||||
}
|
||||
const tasks = [...limboById.values()];
|
||||
const limboIrCache = new Map<string, WorkflowIr>();
|
||||
const unresolvedLimboCards: string[] = [];
|
||||
const ownLimboReviewLanes = async (task: Task): Promise<ReadonlySet<string>> => {
|
||||
const resolved = await resolveWorkflowIrForTaskWithProvenance(this.store, task.id, limboIrCache)
|
||||
.catch(() => undefined);
|
||||
const own = resolved
|
||||
? [...new Set(REVIEW_ROLES.flatMap((role) => columnsWithFlag(resolved.ir, role)))]
|
||||
: [];
|
||||
if (!resolved || resolved.source === "default") unresolvedLimboCards.push(task.id);
|
||||
/* DELIBERATE-LITERAL — the unresolvable-workflow default, reviewed 2026-07-30-22:40. */
|
||||
return own.length > 0 ? new Set(own) : new Set(["in-review"]);
|
||||
};
|
||||
const now = Date.now();
|
||||
|
||||
for (const task of tasks) {
|
||||
if (task.column !== "in-review" || task.paused) continue;
|
||||
if (task.paused) continue;
|
||||
const limboReviewLanes = await ownLimboReviewLanes(task);
|
||||
if (!limboReviewLanes.has(task.column)) continue;
|
||||
if (!allowsAutoMergeProcessing(task, settings)) continue;
|
||||
if (await this.isFalseCompletionHandoffExhaustionWhileMergeOwned(task)) {
|
||||
await this.store.updateTask(task.id, {
|
||||
@@ -10313,7 +10728,8 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
||||
if (task.status != null || task.mergeDetails != null || task.review != null || task.reviewState != null) continue;
|
||||
if (this.options.isTaskActive?.(task.id)) continue;
|
||||
if (await this.isMergeLaneOwned(task.id)) continue;
|
||||
if (getTaskMergeBlocker(task) !== undefined) continue;
|
||||
/* Wired: unwired, this would skip every card the widened read now finds. */
|
||||
if (getTaskMergeBlocker(task, { reviewColumns: limboReviewLanes }) !== undefined) continue;
|
||||
|
||||
const doneMarker = [...(task.log ?? [])].reverse().find((entry) => entry.action === "Task marked done by agent");
|
||||
if (!doneMarker?.timestamp) continue;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"generatedFrom": "node scripts/lifecycle-column-census.mjs --strict --update-baseline",
|
||||
"byFile": {
|
||||
"packages/engine/src/self-healing.ts": 97,
|
||||
"packages/engine/src/self-healing.ts": 89,
|
||||
"packages/engine/src/scheduler.ts": 12,
|
||||
"packages/core/src/task-store/async-comments-attachments.ts": 9,
|
||||
"packages/engine/src/executor.ts": 8,
|
||||
@@ -58,6 +58,7 @@
|
||||
"packages/engine/src/scheduler.ts\u0000done": 2,
|
||||
"packages/engine/src/scheduler.ts\u0000in-progress": 2,
|
||||
"packages/engine/src/scheduler.ts\u0000in-review": 2,
|
||||
"packages/engine/src/self-healing.ts\u0000in-review": 2,
|
||||
"packages/engine/src/usage-limit-detector.ts\u0000archived": 2,
|
||||
"packages/engine/src/usage-limit-detector.ts\u0000done": 2,
|
||||
"plugins/fusion-plugin-reports/src/store/report-store.ts\u0000archived": 2,
|
||||
@@ -120,6 +121,7 @@
|
||||
"packages/engine/src/hold-release.ts\u0000in-review": 1,
|
||||
"packages/engine/src/project-engine.ts\u0000in-review": 1,
|
||||
"packages/engine/src/scheduler.ts\u0000todo": 1,
|
||||
"packages/engine/src/self-healing.ts\u0000done": 1,
|
||||
"packages/engine/src/triage.ts\u0000triage": 1,
|
||||
"plugins/fusion-plugin-even-cards/src/cards/board-cards.ts\u0000archived": 1,
|
||||
"plugins/fusion-plugin-even-cards/src/cards/board-cards.ts\u0000done": 1,
|
||||
@@ -127,7 +129,7 @@
|
||||
"plugins/fusion-plugin-reports/src/store/report-types.ts\u0000archived": 1
|
||||
},
|
||||
"queryByFile": {
|
||||
"packages/engine/src/self-healing.ts": 48,
|
||||
"packages/engine/src/self-healing.ts": 37,
|
||||
"packages/core/src/task-store/async-persistence.ts": 2,
|
||||
"packages/core/src/task-store/merge-queue-ops.ts": 2,
|
||||
"packages/cli/src/extension.ts": 1,
|
||||
|
||||
Reference in New Issue
Block a user