batch: self-healing sweeps were blind on renamed boards (26 sweeps, folds 23 PRs) - #2944
Conversation
…(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
Pre-flight: clean against main, and clean combined with #2941Merged locally with That failure is not yours. It is red on 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):
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 ( |
|
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 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 Why I am not doing it here: So: one query left in |
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
…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.
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
blockedBy/ completed-task releasePlus: 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
reviewProoftriple-proof — a renamed review card would have been moved backward with the safety check silently skipped. Column guards 86 → 81.Both now have ratchets.
self-healing-converted-sweeps-have-no-literal-lane-guards.test.tsderives its sweep list (a sweep counts as converted when its body callsresolveProjectColumnsForRoles), 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
moveTaskdestinations carryingrecoveryRehome: true—moves.tsexempts these so a card stranded in an undeclared column stays rescuable.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.tsandreplan-target.ts— built onresolveTaskWorkflowIrSync, 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 nestedalready-merged hard blockerdescribe 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:gate161 + 487 + 13 + 71. Scoped suites 592 passed (self-healing, the blindness suite at 68 cases, the ratchet, and the notification suite).tscengine clean;pnpm lint,check:changesets,lifecycle-column-census --strictandcheck-sql-column-literalsall 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.