Skip to content

batch: self-healing sweeps were blind on renamed boards (26 sweeps, folds 23 PRs) - #2944

Merged
gsxdsm merged 34 commits into
mainfrom
batch-self-healing-renamed-boards
Jul 31, 2026
Merged

batch: self-healing sweeps were blind on renamed boards (26 sweeps, folds 23 PRs)#2944
gsxdsm merged 34 commits into
mainfrom
batch-self-healing-renamed-boards

Conversation

@gsxdsm

@gsxdsm gsxdsm commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Consolidation of 23 open PRs into one. Every one shared a single root cause and mostly touched a single file; 23 CI runs for that was indefensible.

Folds and supersedes: #2867 #2869 #2876 #2879 #2883 #2891 #2899 #2901 #2902 #2905 #2906 #2914 #2916 #2918 #2919 #2920 #2922 #2927 #2929 #2932 #2934 #2937 #2939.
(#2865, #2882, #2897, #2909, #2912 already merged and are not re-folded.)

The root cause

A self-healing sweep selects its work with listTasks({ column: "in-review" }). On a board whose lanes are renamed that returns nothing, so the sweep never runs — no error, no log line, no failed task. Several sweeps had already had their predicates converted to resolved lanes, which dropped a census count and changed nothing, because the query above the loop had already returned an empty list.

26 sweeps converted. Each one: read the project's columns for the role, then decide each card against its own workflow, with the legacy ids unioned so a board mid-rename is never skipped.

What each sweep stops silently failing to do

stale merger status one finished card held the merge queue for everything behind it
stale blockedBy / completed-task release dependents stayed blocked on work that had already finished — the board stops moving
workspace partial lands a task left with some repos merged and some not
mid-merge retry stamp the card stalled and the operator's manual Retry was gated by the same stamp
in-progress limbo / no-progress failures dead cards held a work slot forever
partial-progress retry real work parked failed with its retry budget unspent
orphaned-execution signal visibility only — the one signal pointing at an orphan went silent
zero-commit audit went half-blind: the error arm kept working, the lane arm did not

Plus: ghost review cards, transient merge failures, misclassified failures, branch misbinding, missing-worktree failures, merged-but-unfinished finalization, done-metadata repair, self-owned branch conflicts, orphan-only scope violations, post-done wedges, idle assigned agents, PR-conflict worktree ownership, and orphaned workspace worktrees.

Two defects the conversion itself introduced, both caught and fixed

  1. Missed pairs. Widening a read without converting the guards beneath it is worse than not converting: the sweep starts admitting renamed-board cards and then mis-decides every one. Review caught a second guard on a re-read row; the audit that triggered found five more, one of which gates the reviewProof triple-proof — a renamed review card would have been moved backward with the safety check silently skipped. Column guards 86 → 81.
  2. Duplicate processing. The literal reads were disjoint by construction; resolved reads are not, so a column carrying two role flags put one card in two buckets — duplicate moves, duplicate audit rows, inflated counts.

Both now have ratchets. self-healing-converted-sweeps-have-no-literal-lane-guards.test.ts derives its sweep list (a sweep counts as converted when its body calls resolveProjectColumnsForRoles), so it cannot go stale, and it carries two positive controls because a broken regex finds no offenders and a broken derivation iterates nothing — an empty loop registers no tests and reads green.

Deliberately unchanged

  • 22 moveTask destinations carrying recoveryRehome: truemoves.ts exempts these so a card stranded in an undeclared column stays rescuable.
  • One literal in clearStaleBlockedBy's log-dedup closure (allowed by name in the ratchet, with the reason).
  • surfaceInReviewStalls — hot list-read path, needs a batched prefetch; that is a performance design decision, not a conversion.
  • scheduler.ts and replan-target.ts — built on resolveTaskWorkflowIrSync, which returns the default IR for every task in production. Converting there produces inert code.

The fold itself is worth one note

All 23 branches appended to the same test file at the same anchor, so every automatic strategy — git 3-way, merge-file --union, and three hand-written resolvers — interleaved them mid-block. Two attempts committed conflict markers before I caught it. The file is therefore reconstructed: head authored once, body assembled as the union of each branch's own intact top-level segments keyed by test title, with the nested already-merged hard blocker describe appended whole (flattening it orphaned its helper). Verified by parsing after every step rather than trusting the merge — which is how each interleaving was caught.

Verification

pnpm test:gate 161 + 487 + 13 + 71. Scoped suites 592 passed (self-healing, the blindness suite at 68 cases, the ratchet, and the notification suite). tsc engine clean; pnpm lint, check:changesets, lifecycle-column-census --strict and check-sql-column-literals all clean, each run explicitly.

Each folded conversion was individually revert-proven on its original branch — the read reverted alone, and the per-card verdict reverted alone — and those measurements are recorded in the commit messages carried into this branch.

gsxdsm and others added 30 commits July 30, 2026 18:29
…(ninth sweep)

recoverOrphanOnlyScopeViolations recovers a task failed by an ORPHAN-ONLY
file-scope violation — commits belonging to no declared scope. Its literal read
meant such a task stayed failed.

Activation check run first: one of the two sweeps still holding both a literal
query and an unwired getTaskHardMergeBlocker, so the guard is wired in the same
change. Activation-risk sweeps: 2 -> 1 (recoverPostDoneNonContinuableWedge).

MEASURED, comments stripped: literal column queries 36 -> 35.

COVERAGE SCOPED HONESTLY. The new case asserts CANDIDACY via getAgentLogs — the
first call made once per candidate after the filter — which proves the read
conversion. It does NOT cover the hard-blocker wiring in the same sweep: that
guard sits behind resolveSelfHealingMergeTarget and findAlreadyMergedTaskCommit,
both needing a real git repo, so reaching it would make this a git fixture rather
than a lane test. The wiring is type-checked and identical to the shape
revert-proven in recoverMergeableReviewTasks and finalizeNoOpReviewTasks.

Saying which half is proven matters more here than usual: this branch has already
produced five assertions that looked like proof and were not.

Revert measured: restoring the literal read fails the case — the card is never
found, so getAgentLogs is never called for it.

Fusion-Task-Id: KB-SELF-HEALING-QUERIES
…enth sweep, BOTH halves proven)

recoverPostDoneNonContinuableWedge clears a `failed` status on a task that
finished every step and was wedged only because a post-done session continuation
hit a non-continuable error. Its literal read meant a renamed board's card stayed
failed forever with all work done.

The last of the sweeps holding both a literal query and an unwired
getTaskHardMergeBlocker. Activation-risk sweeps: 1 -> 0.

FIRST SWEEP WHERE BOTH HALVES ARE REVERT-PROVEN. Its outcome — updateTask
clearing status/error — is downstream of the read AND of the blocker, and nothing
on the path needs a git repo. The nine before it could only prove the read.

Reverts measured, each run alone:
  - literal read restored              -> fails, the card is never listed
  - { reviewColumns: wedgeLanes } dropped -> fails, the blocker judges the renamed
    lane as not-a-review-lane and declines the card the widened read just found

MEASURED against main, comments stripped: literal column queries 36 -> 35.

Fusion-Task-Id: KB-SELF-HEALING-QUERIES
…ep too

traits contributes nothing to the project union, so the card is not in `wedgeById` and the per-card
fallback below — including its own legacy degraded answer — never runs for it. A fallback cannot
rescue a card the query never returned.

Deferred for the reason recorded on the sibling sweep: the safe direction differs by caller, so the
shared helper needs an opt-in rather than a changed default. Fixture note included because the
reported premise names v1, and a v1-upgraded board cannot express a renamed lane at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…th sweep)

clearStaleBlockedBy unsticks a card still pointing at a blocker that has since
finished. Its BODY was already fully lane-resolved — per-referenced-task lanes, a
shared IR cache, legacy ids unioned — and none of it ran, because the three reads
above it asked for the literal todo/in-progress/in-review.

The clearest instance yet of what this class costs: the expensive half was
converted, the count went down, and an operator saw no change because the cheap
half above it stayed literal.

THREE reads, not one union. The buckets are treated differently downstream (hold
cards seed the queued-dependency pass; review cards are exempted when paused), so
the union read is followed by a per-card classification against its own workflow.

SCOPE HELD: the hold read resolves role `hold` only, not `intake`. The original
asked for `todo`; adding intake would newly scan `triage` cards, which is a
behavior change riding along in a conversion.

Legacy ids are UNIONED into each bucket rather than compared, mirroring lanesOf
below — the first draft used `|| task.column === "todo"` and the census correctly
scored three new guards for it.

MEASURED: self-healing.ts census allowance 37 -> 34; baseline rewritten downward
and committed.

Revert measured: with the three literal reads restored, the new case fails — the
blocked card is never listed, so its stale blockedBy is never cleared.

Fusion-Task-Id: KB-SELF-HEALING-QUERIES
…er than leaving it silent

#2876 review, confirmed. `resolveProjectColumnsForRoles` returns its legacy floor plus what workflows
DECLARE for the role, so a board that renames its lanes but declares no lifecycle traits contributes
nothing — the card never enters `blockedCandidates` and the correct per-card classification below
never runs for it. Invisible before the guard is reached.

DEFERRED, not ignored, and a blanket fix would be wrong: the safe direction differs by caller. For a
sweep, over-inclusion costs extra listTasks calls the per-card check discards; for the analytics
aggregators the same widening inflates a number an operator reads. It needs an opt-in on the shared
helper, which touches every sweep, both aggregators and the notifier.

Premise corrected for whoever writes the fixture: this is a hand-authored V2 board, not a v1 upgrade.
synthesizeDefaultColumns emits the DEFAULT ids with traits:[], so a v1-shaped fixture passes vacuously.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… board (twelfth sweep)

reclaimSelfOwnedBranchConflicts frees a task whose OWN worktree is holding its OWN
branch hostage — a conflict no other sweep resolves. Three literal reads plus three
lane guards in the body, converted together: widening the read alone would admit
renamed-board cards and mis-decide every one, since the phantom-binding check, the
blocked-hold skip and the review triple-proof are each keyed on lane.

BUCKETS ARE BUILT FROM THE READ, NOT FROM task.column. First draft re-derived each
bucket by testing task.column against the resolved lanes, and 8 existing tests went
red. The cause was not the product: those fixture cards carry no column field at
all, so re-deriving dropped them. A row returned by listTasks({ column: X }) is by
definition in X — re-deriving adds nothing and silently drops rows. The per-card
lanes are kept for the three GUARDS, which is where the question is actually live.

DELIBERATELY UNCHANGED: the moveTask(task.id, "todo", { recoveryRehome: true })
re-home. That is one of the 22 documented deliberate escapes — moves.ts exempts
recoveryRehome so a card stranded in an undeclared column stays rescuable.

Reverts measured, each run alone:
  - literal reads restored -> fails, the card is never listed
  - wip guard back to task.column === "in-progress" -> fails, the renamed wip lane
    does not match and the sweep falls through to the no-action path

Fusion-Task-Id: KB-SELF-HEALING-QUERIES
…e same stale snapshot

#2879 review. `readBucket` dedupes by id inside ONE role's read. A custom workflow may put more than
one queried flag on a single column — `hold` plus `countsTowardWip` on a lane that both parks and
counts as work, or a review role beside either — and that column is returned by two reads. The task
landed in two buckets and the concatenation handed the recovery loop the SAME snapshot twice.

Not a wasted iteration: the second pass re-reads branch/worktree from state captured before the first
pass mutated anything, so an already-reclaimed worktree is reclaimed again against state that no
longer exists. The lane-resolution loop above already guards with `reclaimLanes.has(task.id)`; the
consumption side was missing the same guard. Deduping by id preserves order, so role precedence is
unchanged.

SHIPS UNCOVERED, AND I DELETED THE TEST THAT PRETENDED OTHERWISE. My case reported 0 recoveries: the
card it built reaches the BRANCH-LEVEL scan (subsumed branch, no worktree), not the candidates loop
the dedupe lives in. Reaching that loop needs branch AND worktree set plus matching git state — the
git fixture this suite avoids. A test that passes without exercising the loop is worse than none.
What would cover it is recorded at the site.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…dence claim

Two follow-ups to the P1 on #2879, both found by running the revert.

1. THE DEDUPE HAD NO TEST. Added one: a board whose hold column also carries the
   wip trait, so one column is returned by two role reads. Without the dedupe the
   loop runs twice on the same stale snapshot (measured: 2 calls, expected 1).

2. THE COMMENT CLAIMED THE WRONG PRECEDENCE. `new Map(entries)` keeps first
   INSERTION ORDER but the LAST value for a repeated key, so the code gave
   last-bucket precedence while the comment said first-bucket. Replaced with an
   explicit `has` guard so the code does what the comment says.

The test was vacuous on its first run and passed with the dedupe reverted. The stub
returned null and the sweep reads `.phantom` straight off it, so it THREW and
aborted the loop after one card — capping the count at 1 in both states. Seventh
vacuous assertion on this branch; seventh caught by the revert rather than by
reading. Stub now returns the real `{ phantom, metadata }` shape.

Revert measured: without the dedupe the new case fails with 2 calls instead of 1.

Fusion-Task-Id: KB-SELF-HEALING-QUERIES
… plus a ratchet

SELF-AUDIT triggered by #2916, where the behavioural test caught a SECOND lane guard
on a re-read row. I scanned the other sweeps I had converted and found five more in
this one, all inside the loop body under the widened read.

One of them matters a great deal: `if (task.column === "in-review")` decides whether
a backward move needs its triple-proof. Left literal, a renamed review card admitted
by the widened read reads as NOT-in-review, so the proof gate is skipped and the card
is moved back without it — a safety check silently bypassed BY the conversion. The
other four gate the resume-limbo path the same way.

MEASURED: self-healing.ts column guards 86 -> 81.

Adds a ratchet for the class, because a scan found it and a scan can keep finding it.
It DERIVES the sweep list (a sweep is converted when its body calls
resolveProjectColumnsForRoles) rather than hardcoding one, so it is correct on every
branch and cannot go stale.

Three refinements it needed, each from a real false positive:
  - strip comments: the FNXC notes quote the old form to explain its removal
  - excuse the FALLBACK arm of a resolved ternary (`own.length > 0 ? own.includes(c)
    : c === "in-review"`) — the correct shape, and the first version reported all
    three such lines as defects
  - allow the one documented literal in clearStaleBlockedBy's log-dedup closure

Two positive controls, because both halves fail silently: a broken regex finds no
offenders, and a broken derivation iterates nothing — an empty for-loop registers no
tests and reads as green.

Revert measured: restoring any one of the five fails the ratchet, naming the sweep
and the exact line.

Fusion-Task-Id: KB-SELF-HEALING-QUERIES
…ard (thirteenth sweep)

reconcileCompletedTask releases everything blocked on a task that just completed.
Three literal reads meant that on a renamed board it released NOTHING — every
dependent stayed blocked on work that had already finished.

The most visible form of this class: the board simply stops moving, with no error
and no log line saying why.

The dependency-satisfaction guard converts in the same change (complete/review/
archived), resolved PER DEPENDENCY — a dependency routinely belongs to a different
workflow than the card waiting on it, the answer main settled on in #2720.

Buckets come from the read that produced each row, not from task.column; the
twelfth sweep measured why re-deriving can only lose rows.

Also re-records the SQL column-literal baseline, which is stale on origin/main and
reds the gate for every open PR (team-analytics.ts 6 -> 3, never recorded).

Revert measured: with the three literal reads restored, the new case fails — the
dependent is never listed, so its blockedBy is never cleared.

Fusion-Task-Id: KB-SELF-HEALING-QUERIES
…arried two roles

#2883 review, two findings.

DEDUPE (fixed). `readDependentBucket` dedupes by id inside ONE role's read, so a custom column
carrying two queried roles is returned by two reads and the dependent was reconciled twice from the
same stale snapshot — the second pass deciding against `blockedBy` state the first had already
cleared. Same class as #2879 one sweep over. Order preserved, so `todoTaskIds` still classifies by the
read that found it first.

SATISFACTION BREADTH (recorded, not narrowed). The concern is that a mergeOrchestration-only lane
reads as satisfying a dependency. Measured against what this replaced: the legacy set was
done/in-review/archived, and `in-review` carries mergeOrchestration on every builtin — a dependency
parked in review has ALWAYS released its dependents, so including the review roles reproduces the
legacy contract exactly. Narrowing to terminal-only is a behaviour change in the DEADLOCK direction on
a sweep whose purpose is unblocking, and it is a scheduler-contract question about when a dependency
counts as done, not a vocabulary conversion.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…2879

Applying the review finding from #2879 to this sweep, which has the identical
concatenation. The three literal reads were disjoint BY CONSTRUCTION (one column
each), so a dependent could not appear twice. Resolved reads are not: a custom
workflow may put more than one queried role flag on one column.

Here the duplicate is a double WRITE — updateTask and logEntry both fire twice for
one dependent, so the operator sees the release logged twice and blockedByCleared
over-counts.

Deduped with an explicit `has` guard rather than new Map(entries): that constructor
keeps first insertion ORDER but the LAST value for a repeated key, so it reads as
first-bucket precedence while doing the opposite.

Revert measured: without the dedupe the new case fails with 2 clearing writes
instead of 1.

Fusion-Task-Id: KB-SELF-HEALING-QUERIES
…lver, don't re-derive it

#2883 review P1, "overbroad dependency satisfaction". Correct: my set unioned all
three review roles, so a mergeOrchestration-ONLY column counted as satisfied and
blockedBy was cleared while the dependency was still being merged.

resolveDependencySatisfactionColumns already answers this exact question for the
scheduler, and defines review as mergeBlocker ∪ humanReview — deliberately without
merge orchestration. Calling it beats matching it by hand: two readers of one fact
drifting apart is a failure this program has already had.

Its contract also covers the degraded case — an unresolvable dependency is left
UNMAPPED so the literal fallback answers, rather than reading as never-satisfied
and blocking its dependent forever.

The new test was vacuous first time and passed with the set widened: the card was
in the WIP lane, which takes a branch that clears blockedBy without consulting
unresolvedDeps at all. Only the hold branch re-points the card at its next unmet
dependency. Eighth vacuous assertion on this branch, eighth caught by the revert.

Revert measured: widening the satisfaction set to admit the merge lane fails the
new case — the dependent is released mid-merge instead of being re-pointed.

Fusion-Task-Id: KB-SELF-HEALING-QUERIES
…lly looks

CI Lint went red on this branch: the census scored the no-metadata dependency
fallback as three NEW column guards (self-healing.ts 86 -> 89).

The marker was there, but inline in the middle of a conditional expression. The
census reads a marker from the node's LEADING comments, so mid-expression it
attached to the wrong node and did nothing. Hoisted the fallback into a module-scope
helper with the marker as a leading block comment, mirroring
isLegacyDependencySatisfied in scheduler.ts, which had the same content and the
same marker in the right place all along.

Guards back to 86, with the three literals reclassified as deliberate rather than
counted as debt. Baseline re-recorded.

Worth recording: `pnpm lint` alone does NOT run the census — CI's Lint job does. I
had run lint locally and seen it pass. The census must be run explicitly.

Fusion-Task-Id: KB-SELF-HEALING-QUERIES
…ard (fourteenth sweep)

recoverForeignOnlyContaminatedInReviewTasks classifies a branch carrying ONLY
foreign commits and clears the contamination park nothing else clears. Two literal
reads meant that on a renamed board it classified nothing and the task stayed
parked indefinitely.

The two `task.column === …` checks inside its filters were REDUNDANT while the
query pinned the column. Under a resolved read they become the per-card verdict, so
they convert here rather than being deleted — deleting them would have widened the
sweep silently.

Deduped across the buckets (the P1 reviewed on #2879). It matters more here than
elsewhere: the two filters have DIFFERENT predicates, so a column carrying both a
review role and the wip role could match both and classify one branch twice.

Reverts measured, each run alone:
  - literal reads restored     -> fails, the card is never listed
  - review verdict back to `task.column === "in-review"` -> fails, the renamed
    review lane does not match and the card is filtered out

Fusion-Task-Id: KB-SELF-HEALING-QUERIES
…ard (sixteenth sweep)

reconcileStaleMergerStatus clears a `merging`/`merging-pr` status left on a card
that already reached a terminal lane. Two literal reads meant that on a renamed
board it was never cleared — and unlike the rest of this series the damage is not
confined to the stranded card: the stale status holds the MERGER QUEUE for every
task behind it.

ONE union read over TERMINAL_ROLES, not two buckets. Nothing here treats complete
and archived differently — the only filter is on `status` — so splitting them would
encode a distinction the code does not make. Deduped, since the two roles can share
a column (the P1 on #2879).

No per-card lane verdict: this sweep has no column comparison to convert, so adding
one would be inventing a gate rather than resolving an existing one.

Revert measured: with the literal reads restored the new case fails — the card is
never listed, so its stale status is never cleared.

Fusion-Task-Id: KB-SELF-HEALING-QUERIES
…seventeenth sweep)

recoverCompletedTasks rescues a task whose steps are ALL done but whose session
died before the executor could hand it to review. The literal read meant that on a
renamed board it was never found: finished implementation work sat in the wip lane
with no session and nothing to move it on — exactly the shape this sweep exists to
catch, made unreachable by the query above it.

The `t.column === "in-progress"` check was REDUNDANT while the query pinned the
column. Under a resolved read it becomes the per-card verdict, so it converts
rather than being deleted.

Carries the #2891 shape: NARROW WHEN THE CARD CAN ANSWER, BROAD WHEN IT CANNOT.
resolveWorkflowIrForTask SUBSTITUTES the built-in IR rather than failing, so an
unreadable selection would otherwise reject the very card the project query just
admitted from a renamed lane.

Reverts measured, each alone:
  - literal read restored -> fails, the card is never listed
  - verdict back to `t.column === "in-progress"` -> fails, the renamed wip lane
    does not match

Fusion-Task-Id: KB-SELF-HEALING-QUERIES
…eighteenth sweep)

recoverInProgressLimbo frees a card holding a wip slot with NO worktree, NO branch
and no step started — nothing is running and nothing will. The literal read meant
that on a renamed board it was never found, so the card kept its slot indefinitely
and the capacity it holds was denied to work that could actually run.

The `task.column !== "in-progress"` check was REDUNDANT while the query pinned the
column. Under a resolved read it becomes the per-card verdict, so it converts
rather than being deleted.

Carries the #2891 shape: NARROW WHEN THE CARD CAN ANSWER, BROAD WHEN IT CANNOT.

The companion case is the one worth keeping: a card with no worktree and no branch
is the NORMAL shape in a HOLD lane — that is exactly what a queued card looks like.
Without it, a read returning every column would make this sweep reclaim cards that
were never holding a slot.

Reverts measured, each alone:
  - literal read restored -> fails, the card is never listed
  - verdict back to `task.column !== "in-progress"` -> fails, the renamed wip lane
    is filtered out

Fusion-Task-Id: KB-SELF-HEALING-QUERIES
…amed board (nineteenth sweep)

WHAT THIS RESTORES IS VISIBILITY, NOT A REPAIR — worth stating, because the rest of
this series fixes stalls. recoverOrphanedExecutions takes no lifecycle action; it
emits task:orphan-detected-no-action so an operator can see a wip card with no live
session behind it. The literal read meant that on a renamed board the event was
never emitted and the one signal pointing at an orphaned execution was silently
absent.

The `t.column !== "in-progress"` check was REDUNDANT while the query pinned the
column. Under a resolved read it becomes the per-card verdict, so it converts
rather than being deleted.

Carries the #2891 shape: NARROW WHEN THE CARD CAN ANSWER, BROAD WHEN IT CANNOT.

Reverts measured, each alone:
  - literal read restored -> fails, the card is never listed
  - verdict back to `t.column !== "in-progress"` -> fails, the renamed wip lane is
    filtered out

Fusion-Task-Id: KB-SELF-HEALING-QUERIES
…board (twentieth sweep)

reattachOrphanedAssignedExecutions reattaches a DURABLE AGENT to a task it is still
assigned to but has stopped executing. The literal read meant that on a renamed
board the reattach never fired, so the card sat assigned-but-idle — worse than
unassigned, because the board says someone is on it and no other sweep will pick it
up while it carries an owner.

The `task.column !== "in-progress"` check was REDUNDANT while the query pinned the
column. Under a resolved read it becomes the per-card verdict, so it converts rather
than being deleted.

Carries the #2891 shape: NARROW WHEN THE CARD CAN ANSWER, BROAD WHEN IT CANNOT.

Reverts measured, each alone:
  - literal read restored -> fails, the card is never listed
  - verdict back to `task.column !== "in-progress"` -> fails, the renamed wip lane
    is skipped

Fusion-Task-Id: KB-SELF-HEALING-QUERIES
…wenty-second sweep)

recoverGhostReviewTasks finds a card parked in review past the stuck timeout with
NOBODY owning its merge lane. The literal read meant that on a renamed board it was
never found: no merger, no session, and no timeout ever firing against it.

The `task.column === "in-review"` check was REDUNDANT while the query pinned the
column. Under a resolved read it becomes the per-card verdict, so it converts rather
than being deleted.

DELIBERATELY UNCHANGED: the kick-back moveTask(task.id, "todo", { recoveryRehome:
true }). That is one of the 22 documented escapes moves.ts exempts so a card
stranded in an undeclared column stays rescuable; converting it removes the rescue
path.

Observable chosen carefully. isMergeLaneOwned runs once per surviving candidate,
AFTER the read and the verdict. Earlier on this branch I positioned this SAME spy
upstream of the guard under test and it passed with the fix reverted — that was one
of the vacuous assertions. Here it sits downstream of both.

Fixture note: taskStuckTimeoutMs must be set and columnMovedAt ancient, or the card
is filtered out by the timeout rather than by lane and the case would pass reverted
for the wrong reason.

Reverts measured, each alone:
  - literal read restored -> fails, the card is never listed
  - verdict back to `task.column === "in-review"` -> fails, the renamed review lane
    is filtered out

Fusion-Task-Id: KB-SELF-HEALING-QUERIES
…ard (twenty-third sweep)

recoverTransientMergeFailures refunds the retry budget for a merge that failed for a
TRANSIENT reason and burned all its retries. The literal read meant that on a
renamed board the refund never happened, so a card that failed on a network blip or
a provider fault stayed failed permanently — visibly failed to the operator, with no
visible cause.

TWO lane guards here, not one, and the test caught the second. I converted the read
and the slim-snapshot filter, and the new case still failed: a SECOND guard re-checks
`task.column !== "in-review"` on the full row after the re-read. Converting only the
first left it rejecting every renamed-board card the widened query had just found —
the "convert the pair or neither" rule, caught by running the test rather than by
reading the function.

Fixture note: the error string is a REAL transient signature ("ACP turn failed",
which classifyTransientMergeError matches), not invented prose. An unrecognised
string is filtered out one line later, and the case would then pass with the fix
reverted.

Reverts measured, each alone:
  - literal read + both guards restored -> fails, the card is never listed
  - the SECOND guard alone restored -> fails, the re-read row is rejected

Fusion-Task-Id: KB-SELF-HEALING-QUERIES
… board (twenty-fourth sweep)

recoverStaleIncompleteReviewTasks requeues a review card whose STEPS are not
finished — it reached review on a graph failure, not on completed work. The literal
read meant that on a renamed board it was never requeued, so the card sat in review
claiming to be done while its own steps said otherwise.

Checked for a second pair deliberately: the triple-proof here is NOT lane-gated, so
unlike #2916 (a second guard on a re-read row) and #2879 (five in the loop body)
there is nothing else to convert. The audit ratchet added in #2879 confirms it.

The requeue keeps its literal `todo` — recoveryRehome: true, one of the 22 documented
escapes.

Reverts measured, each alone:
  - literal read restored -> fails, the card is never listed
  - verdict back to `task.column === "in-review"` -> fails, the renamed review lane
    is filtered out

Fusion-Task-Id: KB-SELF-HEALING-QUERIES
…wenty-fifth sweep)

recoverMisclassifiedFailures clears a failure the executor parked for "without
calling fn_task_done" on a task whose steps are ALL actually done — the failure is a
misclassification, not real work left undone. The literal read meant that on a
renamed board it was never cleared, so finished work stayed visibly failed and never
entered normal review.

No second pair here; verified with the derived ratchet from #2879 rather than by eye.

Fixture note: the error string carries the REAL phrase isNoTaskDoneFailure matches
("without calling fn_task_done"). Invented prose is filtered out one line later and
the case would pass with the fix reverted.

Reverts measured, each alone:
  - literal read restored -> fails, the card is never listed
  - verdict back to `t.column === "in-review"` -> fails, the renamed review lane is
    filtered out

Fusion-Task-Id: KB-SELF-HEALING-QUERIES
…enty-sixth sweep)

recoverBranchMisboundInReviewTasks detects a review card whose BRANCH TIP is bound
to a different task's work. The literal read meant that on a renamed board the
misbinding was never detected, so the card would merge — or refuse to — against a
branch that is not its own.

No second pair; verified with the derived ratchet from #2879 rather than by eye.

Reverts measured, each alone:
  - literal read restored -> fails, the card is never listed
  - verdict back to `task.column === "in-review"` -> fails, the renamed review lane
    is filtered out

Fusion-Task-Id: KB-SELF-HEALING-QUERIES
…y-seventh sweep)

recoverMissingWorktreeReviewFailures requeues a review card failed because its
worktree was gone when the session tried to start.

THE NOTE AT THE SITE SAID THE QUERY WAS UNFIXABLE. It called the literal read
"unfixable without a project-level lane resolution before the read" —
resolveProjectColumnsForRoles is exactly that resolution, and it did not exist when
the note was written. So the careful per-candidate wiring below it only ever helped
boards whose review lane still happened to be named `in-review`. A note recording a
real constraint outlived the constraint.

SECOND BUG, SAME SEAM: the per-candidate set came from
resolveTaskLifecycleColumns().review — the FIRST column per role — while the three
classifiers take a MEMBERSHIP set. On a board declaring a separate merge lane beside
its human-review lane, a card in the second one read as not-in-review and was
skipped. Now unions columnsWithFlag across the three review roles. That is the arity
trap this program keeps hitting.

Fixture note: the error string uses a REAL prefix from
MISSING_WORKTREE_SESSION_PREFIXES; invented prose is rejected by
isMissingWorktreeSessionStartFailure and the case would pass reverted.

Reverts measured, each alone:
  - literal read restored -> both cases fail, the cards are never listed
  - set back to first-per-role -> only the two-review-column case fails, which is
    what makes them independent rather than one fix tested twice

Fusion-Task-Id: KB-SELF-HEALING-QUERIES
… (twenty-eighth sweep)

auditNoCommitsExpectedCandidates flags a card that finished every step and pushed NO
commits — either a legitimately commit-free task nobody declared as such, or work
that silently produced nothing.

HALF-BLIND, NOT DEAD, which is harder to notice. It also reads all failed tasks, and
that read is lane-independent, so on a renamed board the `no_commits` ERROR arm kept
working while the lane arm went silent: a card sitting quietly in a renamed review
lane with zero commits and no error was never flagged. A sweep that still reports
something is not obviously broken.

Reverts measured: with the literal read and verdict restored the new case fails —
the card contributes nothing, because it has no `no_commits` error for the other arm
to catch.

Fusion-Task-Id: KB-SELF-HEALING-QUERIES
…oard (twenty-ninth sweep)

recoverNoProgressNoTaskDoneFailures requeues a wip card the executor failed for "no
fn_task_done" that made NO step progress and left no git work — nothing to salvage,
so requeueing is safe. The literal read meant that on a renamed board it was never
requeued: a card that produced nothing sat failed while still holding its wip slot,
so the capacity was lost as well as the task.

No second pair; verified with the derived ratchet from #2879.

Fixture note: the error string carries the REAL phrase isNoTaskDoneFailure matches.

Reverts measured, each alone:
  - literal read restored -> fails, the card is never listed
  - verdict back to `task.column === "in-progress"` -> fails, the renamed wip lane
    is filtered out

Fusion-Task-Id: KB-SELF-HEALING-QUERIES
…board (thirtieth sweep)

recoverPartialProgressNoTaskDoneFailures retries a review card failed for "no
fn_task_done" that DID make step progress — real work exists, so the sweep spends a
retry rather than discarding it.

The literal read meant that on a renamed board the retry never fired: the work was
parked failed with its retry budget UNTOUCHED. That budget exists precisely to avoid
losing partial work, and it was never spent — the safeguard and the work were lost
together.

No second pair; verified with the derived ratchet from #2879.

Fixture note: one step done and another pending. All-done trips isTaskWorkComplete
and none-done trips hasStepProgress, either of which would filter the card out for a
reason unrelated to lanes.

Reverts measured, each alone:
  - literal read restored -> fails, the card is never listed
  - verdict back to `task.column === "in-review"` -> fails, the renamed review lane
    is filtered out

Fusion-Task-Id: KB-SELF-HEALING-QUERIES
…oard (thirty-first sweep)

recoverDoneTaskMergeMetadata repairs the merge metadata of a card that already
reached the COMPLETE lane — the commit sha an operator sees, and that later
reconcilers trust. The literal read meant that on a renamed board it was never
repaired, so a completed task could keep pointing at a commit that is not the one
that landed.

SCOPED TO `complete`, NOT the terminal union. An archived card is out of scope here,
and widening to TERMINAL_ROLES would start repairing metadata on rows nobody reads —
a behaviour change wearing a conversion's clothes. The sibling sweep
reconcileStaleMergerStatus DOES use the terminal union, because its filter genuinely
does not distinguish the two; this one does.

Reverts measured, each alone:
  - literal read restored -> fails, the card is never listed
  - verdict back to `task.column !== "done"` -> fails, the renamed complete lane is
    filtered out

Fusion-Task-Id: KB-SELF-HEALING-QUERIES
This was referenced Jul 31, 2026
@gsxdsm

gsxdsm commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator Author

Pre-flight: clean against main, and clean combined with #2941

Merged locally with origin/main (c6767cb258) and ran the full engine suite:

1 failed | 11140 passed | 14 skipped | 1 todo
  unwired-lane-parameter-guard > reports exactly the known-unwired declarations, and no new ones

That failure is not yours. It is red on main itself, and the diff is exactly the two pre-existing entries:

+ packages/core/src/task-store/async-audit.ts completeColumns
+ packages/core/src/task-store/async-audit.ts reviewColumns

I checked specifically whether this batch adds any new unwired declarations — it adds none, which was the thing worth verifying given how much surface it touches.

Combined with #2941 (which fixes those two):

tree result
#2944 + main 1 failed / 11140 passed — the inherited red only
#2944 + #2941 + main 11141 passed, 0 failed · 0 conflicts

So the two batches are compatible in either merge order, and the guard goes green once #2941 lands.

For reference, #2941's fix is not a baseline edit: those two parameters are genuinely wired (getInReviewDurationEventsImpl resolves both roles and passes them), but the caller typed the value inline and never named InReviewDurationLanes, and the guard keys an interface member to its owner symbol. Importing the type clears it with KNOWN_UNWIRED unchanged — which matters, because that list is explicitly one-directional and appending to it would have been the wrong fix.

@gsxdsm

gsxdsm commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator Author

Correcting something I put on record about the one query this PR leaves behind, because the reason I gave was wrong and would send the next person down the wrong path.

I deferred surfaceInReviewStalls saying it "needs a batched lane prefetch on a hot list-read path". That is not the blocker. The sweep has no per-card lane verdict at all — converting its read is the same one-line union as reconcileStaleMergerStatus, with nothing to prefetch.

The actual blocker is a missed pair one module away:

// packages/core/src/in-review-stall.ts:179
export function getInReviewStallReason(task, context = {}) {
  if (task.column !== "in-review" || task.paused === true) return undefined;

Widening the sweep's query alone would hand every renamed-board card to a shared classifier that drops it on a literal — the exact defect the ratchet in this PR exists to catch, and it would have looked like a clean one-line conversion.

What it actually needs: an optional reviewColumns on InReviewStallContext, with the literal kept as the no-metadata fallback — the same seam getTaskHardMergeBlocker already has — then wired from the sweep.

Why I am not doing it here: in-review-stall.ts is core, and the classifier has 8 live callers in packages/core/src/task-store/reads.ts decorating task lists. Wiring only the engine sweep leaves those 8 on the legacy fallback, so the dashboard's stall badge stays literal on a renamed board. That is a core/dashboard decision about a shared seam, not an engine conversion, and it should not land as a drive-by inside a freeze.

So: one query left in self-healing.ts, its blocker is now identified precisely, and the fix is a scoped core change with a named owner rather than the vague "prefetch" I claimed earlier.

@gsxdsm
gsxdsm merged commit 8b75a42 into main Jul 31, 2026
6 checks passed
@gsxdsm
gsxdsm deleted the batch-self-healing-renamed-boards branch July 31, 2026 02:41
gsxdsm added a commit that referenced this pull request Jul 31, 2026
Two things the rebase onto the merged #2944 surfaced.

SCOPE. The new engine-wide ratchet flagged self-healing.ts's log-dedup literal in
clearStaleBlockedBy — a site the SIBLING ratchet already owns and already allows by
name, with a derived sweep list that is strictly more precise. Adding a second
allowance here would have given one fact two owners free to drift apart, which is the
failure this program keeps hitting. The engine-wide scan now skips files that have a
dedicated ratchet instead.

BASELINE. executor.ts drops 8 -> 7 column guards from the resumeOrphaned fix;
re-recorded downward.

Revert re-measured after the narrowing: restoring the executor filter fails BOTH the
behavioural case and the ratchet.

Fusion-Task-Id: KB-SELF-HEALING-QUERIES
gsxdsm added a commit that referenced this pull request Jul 31, 2026
…med board (#2947)

`resumeOrphaned` is the only path that recovers tasks after a crash or
restart. On a board with renamed columns it recovered **nothing**.

## A missed pair, not an unconverted read

```ts
const tasks = await this.listWipLaneTasks();          // resolved by role — already converted
const inProgress = tasks.filter(
  (t) => t.column === "in-progress" && …,             // literal — discards everything the read found
);
```

The read was already resolved. The filter directly beneath it
re-asserted the literal on the rows that read returned, so the sweep
found the orphans and threw them all away.

**This is the worse half of the class, and it hid well:**

- the read *looks* converted, so scanning for `listTasks({ column: "…"
})` finds nothing;
- the census scores only the comparison, so the backlog number moves the
**wrong way** as you convert;
- a **structural test already existed** pinning "the read asks for
resolved lanes" — `executor-resume-query-lanes.test.ts` — and it was
green the entire time the sweep was dead. A test asserting the read
exists says nothing about the filter beneath it.

The failure only surfaces after a crash, when an operator is already
investigating the crash and has every reason to blame that instead.

## The ratchet, generalised

#2944 ratcheted this class inside `self-healing.ts` after review found
one instance and a follow-up audit found five more. This generalises it
to every engine source: a function that resolves lanes **and** compares
a column id in the same body is a pair.

Excluded, deliberately:
- the **fallback arm** of a resolved ternary (`lanes ? lanes.has(c) : c
=== "done"`) — the correct shape;
- four files whose literals are deliberate, each with the reason
recorded: `ephemeral-worker-manager` (unresolvable-workflow default),
`triage` (the U11 orphan case), `scheduler` and `replan-target` (sync
listeners on the inert sync IR reader, already pinned by
`sync-workflow-ir-is-always-default.pg.test.ts`);
- `self-healing.ts`, because it has a **dedicated** ratchet that is
strictly more precise. Two ratchets allowlisting the same site is one
fact with two owners, free to drift — the exact failure mode this
program keeps hitting. One file, one ratchet.

It carries a positive control: a wrong source path would make every case
pass by scanning nothing.

**I swept the rest of the engine with it and executor.ts was the only
genuine hit** — everything else is documented-deliberate or blocked on
the inert sync reader.

## Revert results

Each measured by restoring the literal filter and re-running:

| | reverted → |
| --- | --- |
| behavioural case | fails — the renamed card is dropped and the sweep
returns before touching it |
| the ratchet | fails, naming the site: `resumeOrphaned:
executor.ts:5974` |

A non-vacuous companion (card in the review lane → not resumed) rules
out a filter that matches everything: a card in review has no session to
resume, and re-dispatching it would restart finished work.

**Measured:** `executor.ts` column guards 8 → 7; baseline re-recorded
downward.

## Verification

`pnpm test:gate` 161 + 487 + 13 + 71; executor prompt/soft-delete/resume
suites plus the new ratchet, 357 passed; `tsc` engine clean; `pnpm
lint`, `check:changesets`, census `--strict` and
`check-sql-column-literals` clean, each run explicitly.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant