Commit Graph

3811 Commits

Author SHA1 Message Date
gsxdsm
f94ff68391 docs(engine): record that agentParkedColumns is inert at this call site (passed-but-unread) (#3212)
Closes the last entry I could not pin on the #3115 coverage map — by
establishing **why** it cannot be pinned, rather than leaving it open or
forcing a green test around it.

## `agentParkedColumns` is inert at this call site

It influences exactly one output — `shouldPreserveParkedLink` — and
`recoverAgentsRunningOnInactiveTasks` **never reads it**. The gate is:

```ts
if (proof.hasFreshRun || proof.hasActiveExecution) continue;
```

Neither depends on the lane. So passing a resolved set changes nothing
today, and blinding it back to the legacy ids leaves every test green
**because there is no behaviour to observe**. That is not a coverage gap
— there is nothing there to cover.

## Kept, not deleted

- Removing it makes this call site read as **unwired** to the
lane-wiring ratchet, inviting the next worker to "fix" it by re-adding
exactly this.
- If the gate ever adopts `shouldPreserveParkedLink` — the
parked-specific semantics the sibling sweep uses
(`recoverDriftedAgentTaskLinks`, wired in #3208 an hour ago) — the
resolved set is already correct here.

## The shape worth naming

**Passed-but-unread** is the mirror of the **resolved-gate,
literal-branch** defect #3208 fixed. Both read as converted while
deciding nothing — and only one of them is a bug.

A ratchet that counts call sites cannot tell them apart: #3208's site
looked *unwired* and was a live defect; this one looks *wired* and is
dead code. That is why the distinction belongs in a comment at the site
rather than in a baseline number.

## Map status

**21 of 26 pinned**, 1 established as unpinnable-by-construction, 4
remaining with obstacles recorded:
- `reclaimHoldColumns` / `reclaimReviewColumns` — both audit paths emit
the same `branch:auto-reclaim` type, differing only by a `trigger`
string the branch-level scan also produces.
- `completedHoldColumns`, `wsDoneColumns`, `doneMetaColumns` and others
have since gone green from other workers' PRs.

## Verification

`pnpm test:gate` 13 + 161 + 499 + 71 · lint · census `--strict` ·
fnxc-dates (TZ=UTC) — green. Comment-only change.


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

## Summary by CodeRabbit

* **Documentation**
* Added an explanatory note clarifying parked-column handling and its
connection to future parked-link preservation behavior.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-31 11:05:17 -07:00
gsxdsm
c027a72d23 fix(engine): a live agent lost its task link on a renamed hold lane (found while testing, not converting) (#3208)
**A defect, not a coverage gap** — found while trying to pin
`agentParkedColumns` from the #3115 map.

`recoverDriftedAgentTaskLinks` enters its preservation branch on a
**resolved** question (`isPreWipColumn`) and then decided it on a
**literal** one: `evaluateParkedAgentTaskLink` was called without
`parkedColumns`, so parked-ness fell back to `todo`/`triage`. The
sibling sweep passes the resolved set; this call site did not.

On a renamed board: the card is pre-wip, `isParkedTaskColumn` says no,
`shouldPreserveParkedLink` is false, and **an agent with a fresh
heartbeat run has its task link cleared — while it is working.**

`task-agent-sync.ts` predicted this in writing when the parameter was
introduced:

> *"turning a stale-link bug into a dropped-link bug, since the card
would be treated as unparked and its live agent link cleared"*

That is what an unpassed optional lane parameter costs — the same
missed-pair shape as #2956, #2963 and #3186.

## The test needed two fixture corrections, both caught by failing

- the renamed IR had **no hold column**, so no card could be pre-wip at
all;
- the **per-task selection readers** were missing, so `isPreWipColumn`
resolved the default IR and the branch was never entered.

Either alone made the case pass while exercising nothing. Third time
today a fixture passed for a reason unrelated to the resolver — a
pattern, not an anecdote.

## Measured

15 pass; removing `parkedColumns` from the call fails exactly this case.

## Note on how it was found

I had discarded a probe at this sweep earlier for failing to
discriminate. Coming back with the obstacle understood — the fixture
must reach the branch the resolver gates — turned a coverage miss into a
defect find. The five discards this session were not wasted; three of
them named the obstacle that made a later attempt work.

## Verification

`self-healing-agent-link-drift` **15 passed** · `pnpm test:gate` 161 +
13 + 499 + 71 · lint · lane-wiring — green.
2026-07-31 10:49:57 -07:00
gsxdsm
8661b739ff fix(scheduler): a board with TWO complete columns left dependents waiting forever (#3210)
## The defect

On a board declaring more than one complete-trait column — a merged lane
and a shipped lane, say — a card landing in the **second** one was never
recognised as finished, so nothing unblocked its dependents. Silent: no
error, the dependent just waits.

Two problems, the same shape:

1. **`TaskMoveLanes` carried one id per role.** That is right for
*"where should this card go"* and wrong for *"is this column one of the
finished lanes"*, which is a **membership** question. The payload could
not express such a board at all.
2. **`mergeParkedColumns` rebuilt `terminal` as `new Set([complete,
archived])`** — discarding `base.terminal`, which the sync IR path had
already resolved correctly, and narrowing a membership set back to
first-match-per-role.

Point 2 contradicted the note sitting directly above it in the same
file:

> `terminal` is a MEMBERSHIP set, and it is not the same question as
`complete`/`archived`. […] A workflow may declare more than one
complete-trait column […] and `to === parked.complete` sees only the
first and silently skips the rest.

The reasoning was already written down. The overlay added later didn't
honour it.

## Fix

`TaskMoveLanes.terminal?: readonly string[]`, filled from
`columnsWithFlag(ir, "complete"|"archived")` rather than the first-match
`resolveLifecycleColumns`, and the merge is now a **union** of base,
payload, and the single lanes.

Optional, so all 12 emitters and every listener keep compiling — a
listener that ignores it is exactly as correct as before. Union is the
direction `scheduler.ts` already argues for at line ~422: a superset
costs one extra query; a subset **silently withholds work from a
finished card**.

`complete` deliberately stays first-match — a set would be the wrong
shape for a move *target*. Both questions now coexist rather than one
replacing the other.

## How it was found, and what it corrects

Supplying `task:moved` lanes fixed every *other* renamed-board case in
`scheduler-renamed-hold-events` — measured **10 passed / 1 failed** —
and left exactly this one broken. That same measurement is why I
narrowed my earlier claim on #3082: the other behaviours were never
broken in production, because all 12 emitters already carry lanes. This
is the residue that was genuinely broken.

## Tests — both with anti-vacuity controls

| control | result |
|---|---|
| revert `toTaskMoveLanes` | **2 of 4** core tests fail (the terminal
pair) |
| revert the scheduler union | the new engine test fails, **and only
it** (1 failed / 11 passed) |
| both restored | 4 passed, 12 passed |

The 2 core tests that pass either way are shape invariants asserted on
purpose (`complete` must stay first-match; a column-less IR returns
`undefined` rather than an invented lane) — flagging that so the control
isn't read as 4-of-4.

The pre-existing scheduler case emits **without** lanes, which no
production emitter does, so it exercises the sync fallback. The new one
emits `toTaskMoveLanes(ir)` — the shape that actually ships.

## Measured

| check | result |
|---|---|
| `@fusion/core` / `@fusion/engine` tsc | exit 0 / exit 0 |
| eslint | clean |
| `census --strict`, `check:fnxc-future-dates`, `check:changesets` |
exit 0 |
| every `TaskMoveLanes` consumer | 24 passed |
| `pnpm test:gate` | **exit 0 — 744 tests, up 12** |

## Census

No guard converted; this is a payload-shape fix. Backlog unchanged at
11, all deferred.
2026-07-31 10:47:15 -07:00
gsxdsm
215f09d88f fix(census): the bare command could not say the conversion queue is EMPTY — and a test fix for main (#3207)
## Why this exists

The fleet instruction is *"claim the largest unclaimed census file
cluster (`node scripts/lifecycle-column-census.mjs`)"*. That command
cannot answer it. The availability verdict lived **only** behind
`--claims`, which shells to `gh`:

```
line 342:  if (claims && !json) {
```

So a worker following the instruction literally sees per-file counts,
reads a nonzero backlog as a work queue, and picks a file whose guard is
already documented as deferred. Counts alone cannot separate *work left*
from *debt left*.

**Measured cost:** the queue reached **zero unexamined guards** while
dispatch continued. I re-audited the last three candidates —
`merge-queue-ops-2`, `lifecycle-ops`, `notification-service` — and all
three were already documented. Only one was reclassifiable, and by
**deletion** rather than conversion (#3205).

## What the bare command prints now

```
  COLUMN guards (the backlog):   11

  CONVERSION QUEUE EMPTY: all 11 remaining column guard(s) carry a documented deferral note.
  There is no unexamined guard to claim. A nonzero backlog above is DEBT, not a work queue.
  Re-read the note at a site before converting it; run --claims to also check open-PR ownership.
```

Or, when work does exist: `N unexamined guard(s) remain (no deferral
note) — run --triage to list them by file.`

**Local signals only**, so it is honest offline. It reports what it can
prove — no *unexamined* guard remains — and explicitly does **not**
claim the files are unclaimed, because only `--claims` sees open PRs. No
count, no exit code, `--strict`/`--json` untouched.

## Three commits, deliberately separated

1. **`refactor`** — move `FLAG_MARKERS` + the 40-line window into the
lib as `hasDeferralNote()`, verbatim. It was a private const plus an
inline `.slice()` in the CLI, so the rule deciding where the fleet is
sent had **no test in either direction**. Proven identical on the real
tree: `11 documented / 0 unexamined` before and after.
2. **`feat`** — the verdict + 6 tests.
3. **`fix`** — an unrelated pre-existing failure (below).

## The test fix — this one is turning main red

`attributes a remaining file to the open PR that touches it` asserted
over `out.slice(out.indexOf("UNCLAIMED:"))`, which runs to **end of
output** and so also covers the `SYNC-RESOLVED` section printed
afterward. That section legitimately lists `scheduler.ts`.

Latent until `topRemainingFile()` returned `scheduler.ts` — which
happened as the backlog shrank, **a state every conversion moves
toward**. Confirmed pre-existing: clean `origin/main` runs `42 passed /
1 failed` with the identical message.

## Evidence

| check | result |
|---|---|
| `hasDeferralNote` tests | both directions, boundary exact at 40 above
/ not below, 5 real phrasings |
| verdict control (by hand) | one tracked undocumented guard → **11 →
12**, verdict flips to `1 unexamined`; removed → restored |
| test-fix anti-vacuity | claim split broken → **FAILS**; restored →
passes |
| census file | **49 passed** (was 42 passed / 1 failed) |
| `census --strict` / `check:fnxc-future-dates` | exit 0 / exit 0 |
| `pnpm test:gate` | **exit 0** (732 tests) |

The verdict control was **invalid on the first attempt** — my probe file
was untracked and `git ls-files` never scanned it, so the verdict did
not flip and nothing was proven. Recording that because a control that
silently proves nothing is the exact failure this PR is about.

## Census before / after

No guard converted here; this is tooling. Backlog unchanged at 11, all
deferred.
2026-07-31 10:38:48 -07:00
gsxdsm
23403e1426 test(engine): pin the agent sweep's terminal skip (21st resolver, after a corrected fixture) (#3206)
`agentLinkTerminalColumns` was uncovered on the #3115 map. The existing
case uses `todo` and `in-progress`, so the terminal skip is never the
deciding branch.

## The corrected fixture is the lesson

An earlier attempt of mine put the card in a renamed **wip** lane and
stayed green when blinded — **correctly**. Such a card is caught by the
wip∪review set first, so the terminal resolver never decides anything.
The card has to rest in a renamed **complete** lane for this guard to be
the one that matters.

That is the same class as my two discards on the branch-conflict sweeps:
**the fixture has to reach the branch the resolver gates.** A test can
exercise the sweep, pass, and still never touch the line under test.

## What the literal costs

A finished task's agent is not skipped, so the sweep **unlinks an agent
from a task that completed normally** — churn on a row that needed no
repair, and a lost link if that agent was about to be reused.

## Observable

Asserts `syncExecutionTaskLink` — the action the guard prevents — rather
than a return value, per the rule from #3202.

## Measured

421 pass; blinding `agentLinkTerminalColumns` fails exactly this case.

**21 of 26 pinned** across 20 merged PRs.

## Still open, with the obstacle recorded

`reclaimHoldColumns` / `reclaimReviewColumns` resist: both audit paths
in that sweep emit the same `branch:auto-reclaim` type, differing only
by a `trigger` string the branch-level scan also produces, so no
observable I found isolates the bucket resolvers from the branch scan.
`agentParkedColumns` needs a fixture where parked-ness changes the
outcome — mine forced the proof true via a fresh run.

## Verification

`self-healing.test.ts` **421 passed** · `pnpm test:gate` full pass ·
lint — green.
2026-07-31 10:33:25 -07:00
gsxdsm
d6079970e8 fix(self-healing): 18 recovery rebounds hardcoded todo and THREW on a renamed board (#3150, first slice) (#3152)
First slice of #3150. `self-healing.ts` held **26** `moveTask` calls
with a legacy literal target; this converts the **18 `todo` rebounds**.

## Why this is worse than a guard, and documented already

`task-store/moves.ts` records it from a previous incident:

> `moveTaskInternal` **REJECTS** a target the workflow does not declare
(`TransitionRejectionError: unknown-column`) … completion handoff did
not silently no-op — it **THREW**.

Every one of these 18 is a **recovery**. On a renamed board they threw
instead of rebounding, so the strand each sweep exists to clear survived
*and* the sweep reported failure. The reliability layer meant to be the
backstop was the layer that broke.

## Why the census never saw it

It counts **comparisons** against legacy ids. A move target is an
**argument**. That is the third blind spot of the same instrument, and
all three have now produced real defects found by hand:

| blind spot | found this session |
|---|---|
| definitions | `GITHUB_TRACKING_EDITABLE_COLUMNS` — tracking
unreachable on renamed boards (#3149) |
| collections | swept: 30 sites, 29 already correct, 1 defect (the
above) |
| **targets** | **this** — 26 in one file, 31 tree-wide |

## Why 18 sites at once is safe

`resolveReboundTargetForTask` **degrades to `"todo"`** when no workflow
resolves, and `self-healing.ts` already used it at line 745. On every
board we ship, the resolved answer *is* `todo` — so default behaviour is
unchanged **by construction**, not by inspection. The control case pins
exactly that, and it is the reason this can land as one change rather
than eighteen.

## Scope, and what I deliberately did not touch

Converted: the 18 `todo` rebounds.

**Not** converted: the `done`, `archived` and `in-review` targets. They
need different helpers and genuine reasoning about which lane a
completion or an archive belongs in — converting them by analogy is
exactly the half-conversion this program keeps paying for. Sites with no
resolver in scope are unchanged.

The audit behind the split is in the commit: of 26 sites, 5 had resolved
lanes in scope, 4 had an IR, 17 had nothing — and `lanesOfReclaim`
returns **Sets**, which is the wrong arity for a target (a move takes
exactly one column, per the `moves.ts` note).

## Verification

| | result |
|---|---|
| engine `tsc` | **0 errors** |
| **all 43 self-healing suites** | **843 passed** |
| census `--strict` | exit 0, **unchanged** — invisible to it |
| `check-inert-sync-lanes` | exit 0 |
| differential | restoring the literal → **1 failed \| 1 passed**,
renamed case only |

The new test drives a **public entry point**
(`reconcileInReviewUnmetDependencies`, the FN-6793 contract) rather than
calling the helper directly, so it covers the producer path too.

One harness note worth keeping: the first version of the test failed
**upstream** of the target, because the sweep selects rows via
`resolveProjectColumnsForRoles` — a *project-level* resolver reading
`listWorkflowDefinitions`, not the task's own selection. Without that
mocked, the renamed card was never considered and the failure looked
like the fix not working. That distinction (project-level vocabulary vs
per-task IR) will bite the next slices too.

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

* **Bug Fixes**
* Tasks now move to workflow-specific rebound, completion, and archive
columns instead of fixed default destinations.
* Retrying and recovering tasks works correctly on boards with renamed
lifecycle columns.
* Added safe fallback behavior for workflows without custom lifecycle
settings.
* **Tests**
* Added coverage to prevent legacy hardcoded task destinations and
verify renamed-column recovery scenarios.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 10:24:22 -07:00
gsxdsm
97b945f980 docs(engine): the scheduler flag's reason went stale, and two deferrals read as unexamined (#3142)
My own flag on these two literals went stale, in exactly the way I have
spent this session cataloguing in other people's notes.

## What the note said, and why it is now wrong

It said these two stay because converting them would be **inert** — the
sync resolver answers with the default board. True when written.

#3128 then converted the rest of this listener by deferring each resolve
into a `void (async () => ...)` block, which reaches the **async**
resolver and is genuinely correct. So async resolution *is* available
here now, and my stated reason no longer explains why these two are
different.

## The real reason, which #3128 itself states

Three branches down, in its own note:

> The `planningTaskIds.delete` stays SYNCHRONOUS — it is the
edge-trigger bookkeeping, and deferring it would let a second update
re-enter this branch.

Both remaining literals are that case:

| literal | why it cannot move behind an await |
|---|---|
| `failedTaskIds.add` | edge-trigger bookkeeping raced against
`moveTask` clearing the failure metadata — its own comment says so.
Deferring the add can miss that window. |
| PR-monitoring guard | it gates `getTrackedPrs()` /
`startMonitoring()`, where `tracked.has(task.id)` **is** the re-entrance
guard. Move the lane answer behind an await and two updates for the same
task can both pass that check before either starts — **double-starting a
monitor**. |

## Why the distinction is worth a PR

"Blocked on a resolver" invites the next person to wait for the sync
reader. What these actually need is somewhere to put the answer that is
**not behind an await** — the emitter-carried `lanes` #3109 added to
`task:moved`, whose extension to `task:updated` is measured as expensive
rather than impossible (#3123: 26 emit sites against 7, on the hottest
write path).

Those are different tickets with different owners. Leaving the wrong one
written down is how a blocker outlives its cause — the failure I have
now found in five separate notes this session, including two of my own.

## Measured

- Comment-only.
- `src/__tests__/scheduler*` — **14 files / 144 tests pass**.
- `tsc --noEmit -p packages/engine` clean; `check-inert-sync-lanes` and
census `--strict` clean.
- `check-fnxc-future-dates` is red from `main`'s own #3128 stamps —
**#3139** fixes that; this branch inherits it and does not add to it.

## Census

No movement. Both literals stay counted, now with the correct reason
attached.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 10:18:30 -07:00
gsxdsm
aef88a2976 test(engine): pin the PR-conflict sweep's worktree-owner index (20th resolver, after two discarded attempts) (#3202)
`prConflictWipColumns` builds the worktree-owner index behind
`ownedByOtherInProgressTask` — the guard that stops this sweep
**deleting a worktree another live task is executing in**. Keyed on the
id, that index is empty on a renamed board, so every worktree reads as
unowned.

## Two discarded attempts, and why they matter more than the fix

**1. Asserted `result.outcome !== "reclaimed"`.** It failed *with the
fix in place* — `reclaimed` is reachable through a second path this
guard does not gate. **An outcome assertion cannot isolate a guard in a
sweep with several routes to the same outcome.** That also explains my
earlier discard on `reclaimSelfOwnedBranchConflicts`, which has the same
shape.

**2. Asserted `removeWorktree` was not called — but overrode the task's
branch while leaving its id.** The reclaim path also requires
`branchOwnerTaskId === taskIdUpper`, so the branch was never reachable
and the case passed **blinded**: vacuous for a reason that had nothing
to do with lanes.

The shipped version asserts `removeWorktree`, which runs **only** on the
guarded branch and is the irreversible part, and keeps the default
id/branch pair so that branch is genuinely reachable.

## Measured

16 pass; blinding `prConflictWipColumns` fails exactly this case.

**20 of 26 pinned** across 19 merged PRs.

## Generalisation

For sweeps with multiple paths to one outcome, the discriminating
observable is a **path-specific side effect** — `removeWorktree`, a
`task:reconcile-*` audit type, a specific `reason` string — not the
return value. Every case I landed today that stuck used one; both
discards asserted a return value.

## Verification

`self-healing-pr-conflict` **16 passed** · `pnpm test:gate` 13 + 161 +
487 + 71 · lint — green.

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

## Summary by CodeRabbit

* **Bug Fixes**
* Improved protection for active worktrees during pull request conflict
recovery, including tasks in renamed workflow lanes.

* **Tests**
* Added regression coverage to verify that worktrees owned by other
tasks are not removed incorrectly.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-31 10:15:21 -07:00
gsxdsm
9234ca2402 fix(triage): the startup sweep resolved its columns from a SENTINEL task id — a characterization test already pinned it (#3201)
Fourth and last convertible site in `triage.ts`. This one needed a
different fix from the other three, and the codebase already said so.

## The defect

```ts
const sweepLanes = resolvePlannerLanes(this.store, "");
const sweepColumns = [...new Set(["triage", "todo", sweepLanes.intake, sweepLanes.hold])];
```

There is no task `""`. No selection can be read for it, no board
resolved — the lanes come back as the **default** board's and the union
collapses to the legacy pair `{triage, todo}`. On a renamed board the
sweep queries columns the card is not in, so its stale `planning` status
survives and it **holds a planning admission slot indefinitely**.

## A characterization test already pinned this, and called the fix
correctly

`workflow-sweep-sentinel-task-id-live-e2e.pg.test.ts` documents it as a
third inert-conversion mechanism — *"inert by construction rather than
by environment"* — and its header says:

> making `resolvePlannerLanes` async would **NOT** repair this site,
because the defect is the argument, not the resolver

That is right, and it is why this fix differs from #3191 / #3193 /
#3195, which all used the async twin. Here the sweep has **no task to
resolve against** and wants every column playing these roles **anywhere
in the project** — so the correct resolver is
`resolveProjectColumnsForRoles(store, ["intake", "hold"])`, the same
helper `self-healing.ts` already uses for the same purpose.

The legacy pair stays in the union deliberately: the note at the site
explains that `triage` and `todo` must both be swept for pre-U11 and
Coding (Ideas) rows, and extra columns are free because the sweep only
**reads** and filters on `status === "planning"` first.

## The test is inverted, not deleted

It asserted `"planning"` survives — the bug. It now asserts the status
is cleared. Keeping the case with its original reasoning intact
preserves the file's record of what the defect *was*.

## Measured

| | result |
|---|---|
| broad suite (triage / planning / self-healing) | **76 files, 1302
tests passed** |
| differential | restoring the sentinel call → **1 failed \| 2 passed**
|
| `census --strict`, `check-fnxc-future-dates` | exit 0 |

**Inert count unchanged at 4 for `triage.ts`** — these lanes fed an
*array literal*, not a comparison, so the ratchet never counted them.
Third fix this session in that blind-spot class, stated so the number is
not read as the whole picture.

## What remains in this file

Two sites: the `task:moved` wake handler and the evacuation handler —
both **synchronous arrow callbacks** whose answers are consumed in-tick.
Genuinely blocked on the emitter-side work in #3082, with corroborating
evidence attached there.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 10:04:15 -07:00
gsxdsm
09edce2366 test(engine): cover #3112's executor lane conversion — three renamed-board cases main lacks (#3118)
**I flagged these four as unconvertible in #3104. #3109 landed and
dissolved both of my reasons, so the flag comes off.** Leaving a
"blocked" note standing behind a blocker that no longer exists is the
exact decay this program keeps paying for — I have now found three other
people's deferrals in that state this session, and I am not adding a
fourth of my own.

## Both blockers, and why they are gone

| my stated blocker | why it is gone |
|---|---|
| **A.** `trackTaskDisposal` writes `pendingTaskDisposals` in *this*
tick, and the wip branch reads that map to serialise a fast bounce
(FN-5256). Deferring branch selection to a microtask reopens that race.
| Reading `lanes` off the payload needs **no await**. The prologue stays
synchronous and the race stays closed. |
| **B.** It is an if / else-if **chain**, so the guards are entangled
and convert together or not at all. | They convert together here. |

#3109 made the **emitter** carry the resolved lanes, which is the one
route that removes the dilemma instead of trading one horn for the
other.

`lanes` is optional and fail-soft to `undefined` — *"unknown, never
legacy"* — so each guard keeps its literal as the fallback, following
the `mergeParkedColumns` convention #3109 established in `scheduler.ts`.
An emit path that cannot resolve is no worse than before.

## What it fixes

On a renamed board: execution never started on a move into the board's
own wip lane, terminal session release never ran on a move into its
archive lane, and neither `from` guard fired — so in-flight work was not
aborted when a card left implementation. Nothing errored; the engine
simply stopped reacting.

## Census

| | before | after |
|---|---|---|
| `executor.ts` | 4 | **0** |
| repo backlog | 45 | **41** |

## Measured

- 3 new cases added to the FN-7717 suite; file **13/13 pass**.
- **MUTATION**: restoring the `archived` literal fails the renamed case.
- **The paired negative is the load-bearing one.** `done`/`in-review`
deliberately keep their merge leases across the transition (FN-6736 /
Phase C–D). The renamed **complete** lane must therefore *not* release —
a conversion that released on every terminal-ish lane would satisfy the
positive case and quietly break the guarantee that file already exists
to protect.
- A **fail-soft** case pins that an emit carrying no `lanes` behaves
exactly as before.
- `src/__tests__/executor*` — **84 files / 853 tests pass**.
- `tsc --noEmit -p packages/engine` clean; census `--strict`,
`check-lane-wiring`, `check-inert-sync-lane-conversions`,
`check-fnxc-future-dates` clean.

## Note on #3104

That PR (merged) added the flag and the sharpened reasoning. This one
removes it. The reasoning there was correct at the time and is what made
it possible to check quickly whether #3109 actually addressed it — a
flag that states its blocker precisely is cheap to retire, which is the
argument for writing them that way.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 09:56:10 -07:00
gsxdsm
339d4451af test(engine): drop four redundant sync-reader stubs — they fed the broken reader the right answer (#3198)
Completes the audit filed as #3197.

## Why a stub here is not neutral

`resolveTaskWorkflowIrSync` answers with the **default board for every
task** in production — its selection reader returns `undefined`
unconditionally under PostgreSQL. A test that stubs it with a working IR
proves its call site's *logic* while being structurally unable to notice
that the real path resolves nothing. The suite stays green even if the
site goes inert, which is the failure this whole phase has been chasing.

## The audit, complete

Deleted each stub and checked whether the suite still discriminates:

| file | without the stub | verdict |
|---|---|---|
| `planner-lane-resolution` | 7 passed | redundant → **removed** |
| `triage-undeclared-column-rescue` | 7 passed | redundant → **removed**
|
| `recover-approved-intake-post-u11` | 6 passed | redundant →
**removed** |
| `workflow-scheduler-parked-columns-live-e2e.pg` | 2 passed | redundant
→ **removed** |
| `planner-lanes-async-resolution` | 1 failed | **legitimate** — the
stub is its subject |
| `scheduler-renamed-hold-events` | **3 failed** | **masking** — see
#3082 |
| `triage.test.ts`, `triage-release-renamed-hold` | — | resolved in
#3191 / #3193 / #3195 |

**Only the redundant four are touched.**

`planner-lanes-async-resolution` stubs the reader *deliberately*, to
contrast the two resolvers given the same store and task — removing it
would delete the point of the file. That is the case that makes this a
hand audit rather than a ratchet: a hit is not presumptively a defect.

`scheduler-renamed-hold-events` is left alone because its three failures
**are the finding, not the fix**. They correspond to the 13 inert guards
`check-inert-sync-lanes` counts in `scheduler.ts` — two independent
instruments agreeing that those handlers are green in tests and dead in
production on renamed boards. They live in synchronous `task:*`
listeners, so they need the emitter-side work in #3082, not a stub edit.

## Verification

- 4 files / **22 tests pass** without the stubs
- engine `tsc` 0 errors
- `census --strict`, `check-inert-sync-lanes`,
`check-fnxc-future-dates`: exit 0

The PG e2e was the one file I had marked unaudited when filing #3197; it
ran here and is included rather than left as an open question.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 09:50:01 -07:00
gsxdsm
1f5f296c11 test(engine): pin the workspace land-lease owner check (19th resolver) (#3199)
Nineteenth resolver from the coverage map on #3115. The terminal-owner
reclaim directly above it proves the behaviour with `column: "done"` —
**the id** — so blinding `leaseOwnerCompleteColumns` left all 20 tests
green.

## What the literal costs

That set is what `isWorkspaceOwnerLive` consults. Keyed on the id, an
owner resting in a renamed completion lane reads as **live**, so its
land lease is never reclaimed. The workspace repo stays leased by a task
that has finished, and **every later land against that repo waits behind
a phantom**.

## A note on how this resolver came to exist

`isWorkspaceOwnerLive` is one of the sites I flagged earlier today as
**unconvertible** — synchronous, no store handle, converting it would
mean a signature change I had excluded from that PR's scope.

Someone threaded the resolved set through its callers instead. That is
the better answer than either converting in place or leaving it, and
this test pins it — so the threading cannot be undone silently.

## Measured

21 pass; blinding `leaseOwnerCompleteColumns` fails exactly this case.

**19 of 26 pinned** across 18 merged PRs.

## Verification

`self-healing-workspace` **21 passed** · `pnpm test:gate` full pass ·
lint — green.
2026-07-31 09:47:08 -07:00
gsxdsm
73bff5f88c test(engine): pin the orphan-only sweep's project query — the harness could not see a filter bug (#3196)
Eighteenth resolver from the coverage map on #3115, and **why** it was
uncovered is the interesting part.

## A fake that ignores its own filter cannot see a filter bug

Every case in this file stubs `listTasks` to return the same task
**whatever column is asked for**:

```ts
(store.listTasks as ...).mockResolvedValue([failedReviewTask()]);
```

So the project query is never exercised. Blinding `orphanReviewColumns`
changes which column is *requested*, the fake answers identically, and
nothing fails. Eight passing tests, and the selection logic among them
was untested.

That is the same blindness the production sweep had — querying a column
that does not exist and finding nothing — reproduced in the harness that
was supposed to catch it.

## The case

`listTasks` honours the column, so a card resting in a renamed review
lane is found **only if the query asked for that lane**. Keyed on the
id, the sweep asked for `in-review`, got nothing, and a failed
orphan-only card **stayed failed forever**.

## Measured

9 pass; blinding `orphanReviewColumns` fails exactly this case.

**18 of 26 pinned** across 17 merged PRs.

## Generalisation worth checking elsewhere

Any sweep whose test stubs `listTasks` with a flat `mockResolvedValue`
has this hole. The fix is a store fake that filters on `options.column`
— the shape `self-healing-query-filter-blindness.test.ts` already uses.
I would look there first for the remaining map entries.

## Verification

`self-healing-orphan-only-scope` **9 passed** · `pnpm test:gate` full
pass · lint — green.
2026-07-31 09:38:54 -07:00
gsxdsm
a6d67844b8 fix(triage): the "unconvertible" site was convertible — the blocker was two test harnesses (#3191)
#3141 measured this site as unconvertible, and I twice reported the
cause as a production constraint. It was not. This is the instrumented
answer to the probe I recommended there and then ran myself.

## The isolation

| configuration | result |
|---|---|
| flag only, no conversion | **8 passed** → the orphan arm is *not* the
cause |
| flag + conversion | **5 failed** → the conversion is |
| same, with a realistic mock store | **8 passed** → the mock was the
cause |

`triage-stuck-requeue-preserve-draft.test.ts` defined neither
`getTaskWorkflowSelection` nor its async twin — exactly like
`triage.test.ts` did before #3189. Both made
`resolveWorkflowIrForTaskWithProvenance` **throw** and take its catch
branch: the *"could not ask"* shape, which a production store never
presents.

So the 5 failures I deferred as a possible semantics change were the
same harness gap in a second file — confirmed, not argued.

## What changes

**`selectionAbsent`** marks the determinate case: the store *answered*
"no selection", so the workflow is the default and its IR is in hand.
Added as a **separate field, not a third `source` value** — `source ===
"default"` is compared in **31 places** in `self-healing.ts` meaning "be
conservative", and a new enum value would silently stop matching every
one of them while still compiling and still passing on a default board.

**`recoverApprovedTask`** now accepts a legacy `triage` row *explicitly*
(its workflow does not declare that column) instead of depending on
`resolvePlannerLanes` **failing** and falling back to legacy ids.
Correctness resting on a resolver's failure mode is what this removes.

## Measured

| | result |
|---|---|
| broad suite (triage / self-healing / recovery / planning) | **77
files, 1302 tests passed** |
| the three directly affected suites, post-rebase | **245 passed** |
| the flag is load-bearing | conversion **without** it: **18 failed \|
221 passed** |
| `census --strict`, `check-fnxc-future-dates` | exit 0 |

**The inert-sync-lane count is unchanged at 7 for `triage.ts`.** This
site was never among the counted guards, so this is **not** a ratchet
reduction — stating that rather than letting a conversion imply one. It
removes a real inert dependency the ratchet cannot see, which is the
blind-spot class this phase has been mapping.

## Why this took four attempts

I described this blocker at four levels: merged intake/hold, orphan-arm
scoping, identity verification (filed as **#3187**, closed as wrong),
and finally the harness. **The two I instrumented held; the two I
reasoned to did not.** The fix here is the probe I wrote down for
someone else — which is where it should have started.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 09:36:03 -07:00
gsxdsm
c8268a6454 test(engine): pin the temp-merge sweep's terminal grace (17th resolver) (#3192)
Seventeenth resolver from the coverage map on #3115. The two cases
around this one use `done` and `archived` — **the ids** — so blinding
`mergeTempTerminalColumns` left all 21 tests green.

## What the literal costs

The terminal check selects the **shorter grace**: a finished task's temp
merge worktree is reaped after `DONE_TASK_TEMP_WORKTREE_GRACE_MS`
instead of the full stale window. Keyed on the ids, a card in a renamed
completion lane never qualified, so its worktree lingered for the long
window — **disk held by work that already finished**.

## The second cost, which is why this asserts on the audit reason

Without the resolver the sweep eventually acts, but records `reason:
"stale"` instead of `"done-task-stale"`. So its own trail
**misattributes why it acted**.

A sweep that does roughly the right thing under the wrong label is the
kind of defect nobody notices until they are reading audit events during
an incident — and then the record actively misleads. Asserting only on
the file being gone would have passed either way.

## Measured

22 pass; blinding `mergeTempTerminalColumns` fails exactly this case.

**17 of 26 pinned** across 16 merged PRs. Also re-measured this turn:
`wsDoneColumns` and `doneMetaColumns` have gone green independently, so
the map keeps drifting as the fleet adds coverage — re-run before
picking the next entry.

## Verification

`self-healing-tempdir-sweep` **22 passed** · `pnpm test:gate` full pass
· lint — green.

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

## Summary by CodeRabbit

* **Bug Fixes**
* Fixed stale-task handling for tasks in terminal columns of custom
workflows.
* These tasks now correctly follow the done-task grace period and record
the appropriate audit reason.

* **Tests**
  * Added regression coverage to verify the corrected behavior.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-31 09:24:35 -07:00
gsxdsm
f755f44734 test(engine): pin the completion fan-out's review dependent bucket (16th resolver) (#3190)
Sixteenth resolver from the coverage map on #3115.

`completedReviewColumns` reads the **dependents** resting in review when
a blocker completes. No case in this file put a dependent in a renamed
review lane, so blinding it left all 13 tests green.

## What the literal costs

A dependent sitting in review is never read, so its `blockedBy` is never
cleared when the blocker finishes. **It stays blocked by work that is
already done** — the most visible form of this class, because the board
simply stops moving.

## Measured

14 pass; blinding `completedReviewColumns` fails exactly this case.

## Note for anyone continuing the map

`completedHoldColumns` in this same sweep measured as **already
covered**, so only the review bucket was owed. Three buckets, three
resolvers, covered independently — the same per-resolver granularity
that found the missing halves in #3138 and #3186, where my own earlier
tests pinned one resolver of a pair and I had recorded the sweep as
done.

**16 of 26 pinned** across 15 merged PRs.

## Verification

`self-healing-completion-fanout` **14 passed** · `pnpm test:gate` full
pass · lint — green.

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

## Summary by CodeRabbit

* **Bug Fixes**
* Fixed task completion reconciliation for workflows with renamed lanes.
* Dependent tasks in review lanes are now correctly unblocked when their
blocker moves to a custom completion lane.

* **Tests**
  * Added regression coverage for custom workflow lane configurations.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-31 09:13:21 -07:00
gsxdsm
da10131d3f test(triage): the mock store could not be ASKED for a selection — 231 cases exercised a shape production cannot produce (#3189)
`createMockStore` in `triage.test.ts` defined **neither**
`getTaskWorkflowSelection` nor its async twin. So
`resolveWorkflowIrForTaskWithProvenance` **threw** calling them and took
its catch branch, reporting `source: "default"` in the sense of *"the
lookup failed"*. Production stores always expose both readers — every
case in this file was exercising a store shape that cannot exist.

Returning `undefined` models the real answer: the store **can** be asked
and says there is no selection row, which is what a pre-U11 card
actually presents.

## Why it mattered

`triage.ts`'s post-U11 intake recovery gates on that provenance. A
*failed* lookup correctly refuses to claim a workflow lacks `triage`, so
the orphan arm stayed off and the recovery depended on
`resolvePlannerLanes` **failing** and falling back to legacy ids —
correctness resting on a resolver's failure mode.

In #3141 I measured the async conversion of that site as failing 13
cases and **twice reported it as a production constraint**. It was this
harness. That is the concrete cost of a mock that cannot answer a
question production always can.

## Behaviour-preserving on its own

**380 passed across 26 triage/recovery suites.**

## What this deliberately does NOT do

It does not convert the site. I prototyped the full unblock — a
`selectionAbsent` flag on the determinate `!workflowId` branch, its
single consumer, and the async conversion — and it works: the
previously-failing suite goes **237 passed**.

But with a realistic store the orphan arm starts firing for no-selection
rows, which changes recovery flow in **5
`triage-stuck-requeue-preserve-draft` cases** that currently assert the
refusing behaviour. Whether accepting a legacy `triage` row there is
correct is a lifecycle-semantics decision about migration, not a harness
fix. So it is reverted and reported rather than bundled.

Findings and the measured branch table are on #3141.

## One correction carried from this work

I filed #3187 claiming provenance verifies resolution via `ir.id ===
workflowId`, which cannot pass for builtins. **That was wrong** — the
live code uses a symbol marker, and the text I quoted was historical
prose describing what was removed. Closed with the measurement:

```
store with NO selection readers    -> source: default    (catch: could not ask)
store answering builtin selection  -> source: selection  ✓
```

That is the same class of error this PR fixes — reasoning from what
something says rather than what it does.

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

## Summary by CodeRabbit

* **Tests**
* Improved workflow-resolution test coverage by supporting stores with
no selected workflow.
* Added synchronous and asynchronous test readers for workflow
selection.

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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 09:07:45 -07:00
gsxdsm
893b6421be test(engine): pin the contamination sweep's WIP bucket (15th resolver) (#3188)
Fifteenth resolver from the coverage map on #3115. Every case in this
file seeds the candidate in `in-review`, so only the review bucket was
exercised — blinding `contaminationWipColumns` left the file green.

## Why the WIP bucket matters

A card sent back for a fix **re-enters execution while its branch still
carries the foreign commits**, so contamination is discovered there as
often as in review. Keyed on the id, that bucket read nothing on a
renamed board and the card kept a branch built on someone else's work —
which is what this sweep exists to re-anchor.

## Two facts the fixture had to learn, both from failing first

- **This is an ACTION site and deliberately skips a card whose own board
cannot be read**, rather than guessing from the project union. A fake
with only `listWorkflowDefinitions` resolves the default IR, the card is
reported unclassifiable, and the case fails for a reason unrelated to
the resolver under test. The per-task selection readers are required.
- **The WIP bucket's predicate is not the review bucket's.** It
additionally requires `paused === true` with `pausedReason` of
`branch-cross-contamination` or `branch-conflict-unrecoverable`. A card
merely resting in the wip lane is not a candidate — the FN-5704
manual-review contract this sweep mirrors.

Neither is guessable from the resolver. Both came from the test failing
twice, and I would have shipped something that exercised nothing had the
first version passed.

## Measured

3 pass; blinding `contaminationWipColumns` fails exactly this case.

**15 of 26 pinned** across 14 merged PRs.

## Verification

`self-healing-foreign-only-contamination` **3 passed** · `pnpm
test:gate` full pass · lint — green.
2026-07-31 08:59:26 -07:00
gsxdsm
6bc90ccbe2 fix(core): allow-list the legacy workflow IR — it found a fourth bug my grep missed (#3185)
## The name is the defect

`BUILTIN_CODING_WORKFLOW_IR` reads like the default and **is** the
legacy workflow (`builtin:legacy-coding`). Post-U11 they differ by
exactly one column — `triage` — the one a caller most often wants
absent.

**Four bugs have come from reaching for it by name:**

1. two move-path resolvers disagreed on the no-selection default →
*"workflow move policy preflight is stale"* on every flag-on move
(recorded in `resolveDefaultWorkflowIr`'s own header)
2. the TUI board rendered a `triage` lane the default board lacks —
#3178
3. `deleteWorkflow` re-homed occupants into `triage` — #3183
4. **`board-workflows.ts`** described a *custom* workflow whose
definition failed to load using legacy columns — the #3178 symptom
through the dashboard route. **Fixed here.**

It type-checks, it is the obvious identifier, and on the five shared
columns it behaves correctly. The mistake only shows on the column that
differs.

## I said the sweep was complete last round. It wasn't.

My grep excluded paths and truncated at `head -10`; it missed two sites.
**The allow-list found both on its first run.**

That is the lesson the sibling sync-resolver ratchet already records —
*"FOUND BY THIS RATCHET, not by the grep that seeded the list"* — and I
had just quoted that file while repeating the mistake.

## One site is allow-listed rather than fixed, and I tried the fix first

`workflow-graph-executor.run()`'s default `ir` is unreachable in
production (both callers pass it explicitly). But
`workflow-graph-executor-parity.test.ts`, in the **engine-core gate
suite**, drives the method *without* the argument to assert the
historical seam sequence.

Switching it to the catalog default rewrites what "parity" means:
**measured, 6 gate tests fail** with `expected 'failure' to be
'success'`. Reverted, and recorded at the call site *and* in the
allow-list entry so nobody repeats the experiment.

That is what an allow-list is for: a legitimate narrow use next to a
plausible-looking wrong one.

## Guard construction

Follows the repo's existing call-site allow-lists (sync resolver, engine
blocking-shellout, detached-spawn script guard).

- **Comments stripped before scanning** — `activity-analytics.ts` and
`TaskContextMenu.tsx` name this constant in notes *about past bugs*
while correctly avoiding it. Counting prose would train readers to
allow-list mentions.
- **Anti-vacuity**: the scan still sees the catalog's own uses, so a
renamed constant or broken walker cannot make the guard pass by finding
nothing.
- **Stale-entry**: the list cannot rot into files that no longer touch
it — the decay every ledger in this repo has hit.

## Measured

- Guard **3/3**; `tsc --noEmit` clean in core, engine, dashboard.
- census `--strict`, `check-fnxc-future-dates` clean.

## Census

**No movement — that is the point.** This class has no column literal to
count, which is why the census never saw any of the four bugs.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 08:45:31 -07:00
gsxdsm
39a2e0481a test(engine): pin the merged-review sweep's HOLD bucket (14th resolver — the half my own test missed) (#3186)
Fourteenth resolver from the coverage map on #3115, and it is the other
half of a sweep **I converted and tested myself**.

The file already pinned `mergedReviewColumns`. Blinding
`mergedHoldColumns` back to `["todo"]` left all 71 tests green — no case
put a merge-confirmed card in a renamed hold lane.

## The lane is not hypothetical

A merge-confirmed card gets **rebounded to hold** by other recovery
paths — a failed post-merge step, a requeue. So *merged but sitting in
hold* is exactly the state this sweep's second bucket exists to
finalize. Keyed on the id, that bucket read nothing on a renamed board
and the card stayed unfinished **while its commit was already on the
base branch**.

## The lesson, repeated

This is #3138's finding again: a test that pins one resolver of a pair
reads as covering the sweep. I wrote the earlier case, recorded the
sweep as done, and it was half-done.

**Only blinding each resolver separately finds this.** A single passing
revert proves one guard — which is why the map is keyed by resolver, not
by sweep.

## Measured

72 pass; blinding `mergedHoldColumns` fails exactly this case.

**14 of 26 pinned** across 13 merged PRs.

## Verification

`self-healing-query-filter-blindness` **72 passed** · `pnpm test:gate`
full pass · lint — green.
2026-07-31 08:45:19 -07:00
gsxdsm
9c00699e61 test(engine): pin the stalled-card watchdog's terminal skip on a renamed board (13th resolver) (#3182)
Thirteenth resolver from the verified coverage map on #3115.

`sweepTerminalColumns` was uncovered: the case directly above it asserts
the terminal skip using `done` and `archived` — **the ids** — so
blinding the resolver left the file green.

## What the literal costs

The skip matched nothing on a renamed board, so **finished cards were
scanned as live**, and a card parked in a renamed completion lane could
be reported stalled.

A watchdog that cries about completed work is worse than a quiet one: it
trains operators to ignore the alert. That is the exact failure this
sweep's own dedup logic was built to avoid, reintroduced through the
lane vocabulary.

## The case

The renamed twin of the existing terminal-skip test — same assertion,
same shape, different vocabulary. That is the whole point: the original
passes either way, so it cannot see the conversion.

**Measured:** 10 pass; blinding `sweepTerminalColumns` fails exactly
this case.

## Map status

**13 of 26 pinned** across 12 merged PRs, plus `starvedWaitingColumns`
now covered by another worker independently. Re-measure before picking
the next one — the map drifts green as the fleet adds coverage, and I
have already caught it stale once today.

## Verification

`self-healing-stalled-card-watchdog` **10 passed** · `pnpm test:gate`
full pass · lint — green.
2026-07-31 08:34:16 -07:00
gsxdsm
5eec7dc73b test(engine): pin the completed-blocked park release on a renamed board (12th resolver) (#3180)
Twelfth resolver from the verified coverage map on #3115.

`completedBlockedHoldColumns` was uncovered: every case in this file
seeds the park in `todo`, where the literal is correct, so blinding the
resolver left all 21 tests green.

## What the literal costs

A completed-blocked park rests in the board's **hold** lane, which is
only called `todo` on the built-in workflow. Keyed on the id, the sweep
selects nothing on a renamed board — so **finished work stays parked
behind a blocker that has already cleared**, stranded exactly as FN-7926
describes. Silently: a sweep that selects no rows reports success.

## Two fixture facts, found by the test failing first

- **The completion-blocker gate resolves the *blocker's* own workflow**,
so the per-task selection readers are required too.
`listWorkflowDefinitions` alone leaves the renamed complete lane
unrecognised and the park is rejected for the wrong reason — a
green-for-the-wrong-reason test, which is the exact thing this effort
removes.
- **The blocker must rest in the renamed complete lane**, not the legacy
one, or the case proves nothing about the board it claims to test.

I only learned both because the first version failed. Had it passed, I
would have shipped a test that exercised none of this.

## Measured

21 pass; blinding `completedBlockedHoldColumns` fails exactly this case.

## Map status

**12 of 26 pinned.** Also re-measured six entries this turn:
`starvedWaitingColumns` is **now covered by another worker's test**
(#3128-era, peer-progress vocabulary), so the map is drifting green
underneath me as the fleet adds coverage too — worth re-running before
anyone picks the next entry.

## Verification

`execute-requeue-loop-guard` **21 passed** · `pnpm test:gate` full pass
· lint — green.
2026-07-31 08:19:52 -07:00
gsxdsm
f8fb9b1473 test(engine): pin the unmet-dependency rebound on a renamed board (11th resolver from the coverage map) (#3176)
Eleventh resolver from the verified coverage map on #3115.

`unmetDepReviewColumns` was uncovered: the existing FN-6778/FN-6779 case
uses `in-review`, where the literal is correct, so blinding the resolver
left the file green.

## What the literal costs

The sweep selects **no card**. A review card whose dependency is still
unmet is never rebounded — it sits in review, **eligible for merge,
ahead of the work it depends on**. That is precisely the ordering
violation this sweep exists to prevent, and it fails silently: no error,
no audit event, nothing to notice.

## Measured

3 pass; blinding `unmetDepReviewColumns` fails exactly the new case.

## Map status

**11 of 26 resolvers pinned** across 10 merged PRs. The remaining 15
need real harness work — I threw away two probes earlier today that
passed while proving nothing (`reconcileInReviewBranchRebind` never
entered its loop; `recoverAgentsRunningOnInactiveTasks` stayed green
under both blindings), and recorded them on #3164 rather than shipping
green decoration.

## Verification

`in-review-unmet-dependency-reconcile` **3 passed** · `pnpm test:gate`
full pass · lint — green.
2026-07-31 07:59:02 -07:00
gsxdsm
10a0c5848f fix(executor): planner-evacuation lanes come from the emitter — executor leaves the inert list (16 → 12) (#3137)
`executor.ts` was the last file besides `triage.ts` and `scheduler.ts`
on `check-inert-sync-lanes`, holding **4 guards that read as converted
and behave as literals**. Neither cause turned out to be "needs an async
resolver".

## 1. Two of the four were in code with no caller

`isPlannerColumnFor` is a **private method with zero production
callers**. `tsc` reports it unused; the only things reaching it were two
tests casting through `executor as unknown as { … }`, which is exactly
what let it look alive. Its doc comment described the
planning-evacuation branch — but that branch calls
`isBackwardMoveOutOfPlanning` and never called this.

Deleted, along with the two tests whose subject it was. Converting
guards in unreachable code would have "fixed" behaviour that cannot run
and left two more sites to maintain; a test whose subject has no caller
pins nothing.

## 2. The other two no longer need to resolve anything

`isBackwardMoveOutOfPlanning` resolved its own lanes via
`resolvePlannerLanes`, whose selection reader returns `undefined`
unconditionally under PostgreSQL — so it answered with the **default
board for every task**, and both its guards were inert.

Its comment justified the sync resolver by the synchronous `task:moved`
emitter. That was true and **is no longer binding**: the emitter now
resolves lanes once, asynchronously (`moves.ts` →
`resolveWorkflowIrForTask`), and hands them on the payload — which #3112
already reads in this same listener. Reading a parameter is as
synchronous as reading `from`, so nothing reorders and no listener
resolves.

`lanes` is **required, not optional**. An optional parameter that the
one production caller happens to pass is the seam-with-no-supplier shape
this program keeps finding; required means a future caller fails
typecheck instead of silently getting a default board. When the emitter
itself could not resolve, the legacy ids answer — exactly what
`resolvePlannerLanes` degraded to anyway.

## Measured

| | before | after |
|---|---|---|
| `check-inert-sync-lanes` | **16** guards, 3 files | **12** guards, 2
files |
| `executor.ts` on that list | 4 | **0 — off the list** |
| census | 18 | 18 (`--strict`: every file matches baseline exactly) |

**The census is deliberately unchanged.** This targets the inert
population, which the census cannot see by construction: those guards
already read as converted. That gap is the argument in #3082 — 12 guards
still behave as literals while the census shows them as done.

## The producer half, which I nearly shipped without

The predicate's own suite covers it thoroughly — and every case calls it
**directly**. Mutation testing exposed that this proves nothing about
the listener: replacing the listener's `lanes` argument with `undefined`
left `planning-evacuation` at **20/20 green**. That is the fifth failure
shape in this program's learnings verbatim — a converted consumer with
an unconverted producer passing every instrument.

So there is now a case driving the **real listener** on a board whose
planner lanes share no id with the legacy pair (`queued` holds,
`drafting` intakes), withdrawing a card to a non-lifecycle column — the
reported symptom (`todo -> Ideas`) in that board's vocabulary.

## Verification

- engine `tsc` — **0 errors**
- `executor-planner-lanes-resolved` — **12 passed**
- `executor-archive-releases-active-session` — **14 passed**; listener
passing `undefined` → **1 failed | 13 passed**
- `planning-evacuation` + `triage-planning-wake` + archive suite — **47
passed**
- `check-inert-flag-seams`, `check-fnxc-future-dates`, census `--strict`
— exit 0
- `eslint` on changed files — 0 errors

The predicate tests are also **stronger than before**, not merely
adapted: they now build lanes with `toTaskMoveLanes`, the same function
`moves.ts` uses for the payload. Previously they reached the predicate
through the store-backed sync reader, so renamed-lane assertions passed
in the harness while the real path could never see a renamed lane.

## Not done here

The inert baseline still reads 29 against a tree of 12 and the gate
advises re-recording. I left it: a stale allowance is a real hazard, but
re-recording is a one-line change that conflicts with every lane, and it
should land once rather than in each of our branches.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 07:30:43 -07:00
gsxdsm
4365a3b10b test(engine): pin the paused-scope-decay lane filter (plus two probes I threw away) (#3164)
Next entry from the verified coverage map on #3115.
`scopeDecayWipColumns` was uncovered — the existing case uses
`in-progress`, where the literal is correct, so blinding the resolver
left all 420 tests green.

## What the literal costs

A paused holder resting in a renamed wip lane is **never selected**. The
loop does not run, nothing is recorded, and its file scope decays with
nothing to rebound it — so its followers stay blocked behind a card that
is not coming back.

## The observable

The audit event. Reaching a no-action record proves the holder was
**selected by the lane filter**, which is the only thing this resolver
controls. Asserting on the rebound itself would have needed triple-proof
to succeed, dragging in state the resolver has nothing to do with.

**Measured:** 420 pass; blinding `scopeDecayWipColumns` fails exactly
this case.

## Two attempts thrown away first

Worth recording, because the remaining map entries are not uniform with
the ones already closed:

- **`reconcileInReviewBranchRebind`** — a git-free probe (workspace
task, rejected before any git runs) returned `{outcomes: [], repaired:
0}`. The loop never ran. I confirmed the `merge` trait does map to
`mergeOrchestration`, so the filter should have matched; something else
short-circuits and I could not establish what.
- **`recoverAgentsRunningOnInactiveTasks`** — the test passed, then
**both** resolvers stayed green when blinded. `agentLinkTerminalColumns`
never fires because the live card is caught by the wip∪review set first;
`agentParkedColumns` only feeds `evaluateParkedAgentTaskLink`, whose
result my fixture already forced true via a fresh run.

Both would have been green, plausible, and worthless. They were reverted
rather than adjusted until they passed — which is the failure this whole
effort exists to remove, and the one I committed myself in #3078.

## Map status

Closed: 9 resolvers across 7 PRs. **~18 remain.** The easy ones are
done; what is left needs real harness work — an `execAsync` git fixture,
and understanding how `evaluateParkedAgentTaskLink` weighs run-freshness
against lane. Budget for that rather than expecting the pattern that
closed the first nine.

## Verification

`self-healing.test.ts` **420 passed** · `pnpm test:gate` 161 + 13 + 487
+ 71 · lint — green.
2026-07-31 07:15:56 -07:00
gsxdsm
44a67df65d test(engine): pin both dependency-lease resolvers on a renamed board (one case covered only half the conversion) (#3138)
Next two entries from the verified coverage map on #3115.
`reconcileDependencyBlockingLeases` had **both** of its resolvers
uncovered — blinding either `leaseWipColumns` or `leaseHoldColumns` back
to its legacy id left all 825 self-healing tests green, because every
fixture in that block uses `in-progress` / `todo`, where the literals
happen to be correct.

## What the literals cost

The holder scan matches no card **and** the dependency scan matches no
card. A stale file-scope lease blocking a real dependency is never
rebounded, so the dependent stays `overlapBlockedBy` behind a holder
that is not coming back. That is the deadlock this sweep exists to break
— silently not broken, no error, no log.

## Two cases, because one did not cover both — measured, not assumed

My first case (holder in a renamed wip lane, dependency marked
`overlapBlockedBy`) pinned `leaseWipColumns`. I then blinded
`leaseHoldColumns` against it and **it stayed green**.

The reason is in the control flow: the `overlapBlockedBy === holder.id`
branch short-circuits and `break`s **before** the hold membership is
consulted. So that fixture can never reach the guard `leaseHoldColumns`
feeds.

The second case drops the marker, leaving an unmarked dependency resting
in a renamed hold lane, which falls through to the
overlapping-hold-dependency branch.

| blinded | result |
|---|---|
| `leaseWipColumns` | **1 failed** |
| `leaseHoldColumns` | **1 failed** |

Before the second case, that table read `1 failed` / `still green`.
Checking each resolver separately is the only reason I noticed — a
single "the suite fails when reverted" would have looked like proof and
covered half the conversion.

## Remaining

23 uncovered resolvers on the map. Next by risk:
`reclaimStaleActiveBranches` (deletes branches) and
`reconcileInReviewBranchRebind` (rebinds branches of live cards), both
needing a git-shelling harness.

## Verification

`self-healing.test.ts` **415 passed** · `pnpm test:gate` 13 + 161 + 487
+ 71 · lint — green.


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

## Summary by CodeRabbit

* **Bug Fixes**
* Improved recovery of stalled workflow tasks when dependency-blocking
leases become stale.
* Added support for workflows using customized task status lanes,
including marked and unmarked overlap blockers.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-31 06:58:27 -07:00
gsxdsm
7cdd3f3e87 test(engine): pin the last two worktree-metadata resolvers — completes the sweep with 3 of 3 uncovered (#3148)
Completes `reconcileTaskWorktreeMetadata` — the sweep with the most
uncovered resolvers on the #3115 map (3 of 3). #3132 pinned the wip
half; these are **terminal** and **review**.

Both were uncovered: blinding either back to its legacy ids left all 825
self-healing tests green, because no fixture in that block used a
renamed lane.

| resolver | what the literal cost |
|---|---|
| terminal | a finished card is not this sweep's business. Keyed on the
ids the skip never fired, so finished cards were reconciled on every
pass |
| review | the other half of the **FN-5256** liveness guard. Keyed on
the id it went silent — and this sweep nulls
`worktree`/`branch`/`sessionFile` on a live row |

## Blinded separately, not as a pair

Each resolver was blinded on its own and measured on its own. **#3138 is
exactly why**: there, one case pinned `leaseWipColumns` and left
`leaseHoldColumns` green, because the control flow short-circuited
before the second guard was ever reached.

A single revert that fails proves *one* resolver, not the conversion.
That is the finer-grained version of the lesson from #3078, where a
whole suite passing proved nothing at all.

| blinded | result |
|---|---|
| `worktreeReconcileTerminalColumns` | **1 failed** |
| `worktreeReconcileReviewColumns` | **1 failed** |

## Map progress

Closed: `archiveStaleDoneTasks` ×2,
`reconcileOrphanedPendingStepResults`, `recoverDriftedAgentTaskLinks`,
`reconcileDependencyBlockingLeases` ×2, `reclaimStaleActiveBranches`,
`reconcileTaskWorktreeMetadata` ×3. **20 uncovered resolvers remain** of
the original 26.

Next: `reconcileInReviewBranchRebind` (rebinds branches of live cards),
then `recoverAgentsRunningOnInactiveTasks` ×2.

## CI note

This will show red on Lint until **#3145** merges — main carries FNXC
stamps dated 2026-08-01 through 08-06 while UTC now is 07-31, so every
open PR inherits it. #3145 fixes it; this PR touches none of those
files.

## Verification

`self-healing.test.ts` **416 passed** · `pnpm test:gate` 161 + 487 + 13
+ 71 · lint — green locally.
2026-07-31 06:58:11 -07:00
gsxdsm
1136474a63 test(engine): pin the archive skip in branch reclaim — the first uncovered resolver whose failure deletes a branch (#3144)
Next entry from the verified coverage map on #3115 — and the first one
whose failure mode is **irreversible**.

## The gap

`reclaimArchivedColumns` was uncovered: blinding it back to the id
`archived` leaves all 825 self-healing tests green, because no fixture
in this suite puts a card in a renamed archive lane.

## Why it matters more than the other 22

That guard **skips** archived cards — their branches belong to archive
cleanup, not to branch reclaim. Keyed on the id, a card filed in a
renamed archive lane fails the skip, and this sweep reaches:

```
git branch -D "fusion/<id>"
```

Every other uncovered resolver I have pinned so far causes a wrong
lifecycle decision — a card not requeued, a lease not released, a
diagnostic not surfaced. All of those are recoverable from the task row.
**A deleted branch is not.**

## Measured

415 pass. Blinding `reclaimArchivedColumns` fails exactly this case, and
the assertion that fails is the one checking `git branch -D` was never
called — so the failure *is* the branch being deleted, not a proxy for
it.

## Progress on the map

Closed so far: `archiveStaleDoneTasks` ×2 (#3115),
`reconcileOrphanedPendingStepResults` (#3090),
`recoverDriftedAgentTaskLinks` (#3102), `reconcileTaskWorktreeMetadata`
wip (#3132), `reconcileDependencyBlockingLeases` ×2 (#3138), and this
one. **22 uncovered resolvers remain.**

Every sweep probed so far has been uncovered, and one
(`reconcileDependencyBlockingLeases`) was only half-covered by its own
first test — the branch short-circuited before the second resolver was
ever consulted. That is why I now blind each resolver separately rather
than trusting a single revert.

Next: `reconcileInReviewBranchRebind` (rebinds branches of live cards)
and the two remaining `reconcileTaskWorktreeMetadata` resolvers
(terminal, review).

## Verification

`self-healing.test.ts` **415 passed** · `pnpm test:gate` 13 + 161 + 487
+ 71 · lint — green.
2026-07-31 06:55:15 -07:00
Phil Larson
920d68e10f fix(dashboard): expose column roles to browser bundle (#3151)
## Summary
- export the browser-safe `@fusion/core/column-roles` subpath
- keep Vite/Vitest aliases ahead of broad `@fusion/core` aliases
- restore production dashboard builds after task undo classification
adopted shared column-role helpers

## Test plan
- `node scripts/check-no-node-only-core-imports-in-dashboard.mjs`
- `FUSION_DASHBOARD_DEEP=1 pnpm --filter @fusion/dashboard exec vitest
run app/utils/__tests__/taskRevert.test.ts --pool=threads
--maxWorkers=1`
- `pnpm --filter @fusion/core typecheck`
- `pnpm --filter @fusion/dashboard typecheck`
- `CI=true pnpm check:changesets`
- `pnpm build`


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

* **Bug Fixes**
  * Fixed dashboard build compatibility for browser-based environments.
* Improved reliability when importing column role functionality across
supported application components.

* **Refactor**
* Made column role utilities available through a dedicated browser-safe
entry point.

* **Chores**
* Updated development and test configurations to consistently resolve
the new entry point.
* Documented the browser-safe module classification and recorded the
release patch.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-31 06:46:19 -07:00
gsxdsm
ada62a7c4a census: --claims shows which remaining files an open PR already holds (two duplicate claims today) (#3124)
The census says **where** the work is but not **who has it**, and
duplicate claims are now the dominant coordination cost of this phase.
This adds an opt-in `--claims` report mapping each remaining file to the
open PRs already touching it.

## The problem is measured, not suspected

- **`self-healing.ts` took three overlapping conversions** from
different lanes while one branch was open (#3049, #3075, #3078). Each
forced a full rebuild of #3094, and every conflict was the same shape:
*same guard, two spellings, different variable names*. That PR's body
asks, in as many words, for one lane to own the file.
- **`executor.ts` took two independent conversions today** — #3112 and
#3118 — same four literals, same payload-lanes fix, two branches. Two
workers each read the census, saw the top cluster, and started. Neither
could see the other; I only caught it because both appeared in one `gh
pr list`.

The census is what sends everyone to the same file, so the claim signal
belongs here rather than in a side channel nobody reads. `--triage`
(#3097) already measured the underlying fact — 53 of 88 guards sat
inside an open PR — one step short of being actionable.

## Measured on current main (29 guards)

```
  CLAIMED by an open PR: 6 files holding 15 guards
       6  packages/engine/src/self-healing.ts  ← #3121 #3116
       4  packages/engine/src/executor.ts  ← #3118 #3112
       2  packages/engine/src/auto-merge-finalization.ts  ← #3107
       1  packages/core/src/task-store/task-artifacts-ops.ts  ← #3120 #3119 #3091
       …
  UNCLAIMED: 12 files holding 14 guards — start here
       2  packages/dashboard/app/utils/taskRevert.ts
       2  packages/engine/src/scheduler.ts
       …
```

It independently reproduces **both** collisions I found by hand today,
which is the strongest evidence I can offer that it works: `executor.ts
← #3118 #3112` and `self-healing.ts ← #3121 #3116`.

It also answers the standing fleet instruction empirically. "Claim the
largest unclaimed cluster" currently resolves to **12 files holding 14
guards, none larger than 2** — and one of those two (`scheduler.ts`) is
in the SYNC-RESOLVED list, where conversion is inert. That is a
materially different picture from the headline `29`.

## Design decisions

**Report-only and fail-soft**, on the same terms as `--triage`: opt-in,
printed beside the totals, changes no count and no exit code. It shells
to `gh`, so it is unavailable offline, in CI without a token, and in
sandboxes — all of which print a notice and continue. A gate must not
depend on network state; this is a work-selection aid, not a gate.

**The fail-soft path is loud on purpose**, and it is the case I care
most about. A claim report that silently degrades to "nothing is
claimed" is *worse than no report*, because it actively sends the reader
into work another lane holds — the exact failure the flag exists to
prevent. So when `gh` cannot answer it prints `POSSIBLY CLAIMED` and
suppresses the start-here list entirely rather than rendering it empty.

**Heuristic, and says so.** A PR touching a file is not proof it
converts *that file's* guards — it may edit an unrelated function. It
over-reports rather than misses, which is the safe direction: a false
claim costs one comment asking, a missed one costs a rebuilt branch.

**One bulk `gh pr list` call**, not a request per PR — the per-PR shape
was too slow to become habitual, and a report nobody runs is not a fix.

## Verification

- `lifecycle-column-census.test.ts` — **42 passed** (was 40)
- Differential: disabling the flag gives **2 failed | 40 passed**. Both
new tests fail on the defect they were written for.
- `--strict` and `check-fnxc-future-dates` — exit 0
- Tests stub `gh` on PATH, so no network call and no dependency on the
live PR list. The fixture reads the census's **own current top file**
rather than a hardcoded path, so it cannot rot as the backlog shrinks
(same self-maintaining discipline as #3106).

## What this does not do

It does not reserve anything — there is no lock, and two workers who
both run it can still collide if they start simultaneously. It reports
what is already visible in the PR list, which is enough to catch the
every-case-so-far pattern of *starting work on a file someone has held
for hours*. A real reservation would need shared mutable state, and I
would not add that without an owner asking for it.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 05:50:13 -07:00
gsxdsm
d09a856941 test(engine): pin the FN-5256 liveness guard on a renamed board (the sweep that clears a live task's worktree) (#3132)
Top item from the verified coverage map on #3115.
`reconcileTaskWorktreeMetadata` had **three** uncovered resolvers — the
most of any sweep in the file — and it is the one that nulls
`worktree`/`branch`/`sessionFile` on a live row.

## Why this sweep first

Its own header names FN-5256: the incident where clearing worktree
metadata yanked a checkout out from under a running shell. The guard
that prevents it is `scopeOverrideMergeActiveSafe`, and that guard is
exactly what the wip/review resolvers feed.

The existing guard test uses `column: "in-progress"` — **the literal**.
So blinding `worktreeReconcileWipColumns` back to `["in-progress"]`
leaves all 825 self-healing tests green. The guard is converted; nothing
in the suite could tell.

On a renamed board the pre-conversion form matched nothing,
`scopeOverrideMergeActiveSafe` became true for a card an executor was
actively running, and the sweep cleared its metadata.

## The case

The renamed twin of the existing FN-5256 test: a `scopeOverride` task
live in a **renamed wip lane** keeps its metadata. Same shape, same
assertions, different vocabulary — which is the whole point, since the
original passes either way.

**Measured:** 414 pass; blinding `worktreeReconcileWipColumns` to the
legacy id fails **exactly this test**.

## Remaining from the map

25 uncovered resolvers left. Next by risk: `reclaimStaleActiveBranches`
(deletes branches) and `reconcileInReviewBranchRebind` (rebinds branches
of live cards) — both need a git-shelling harness, so they are slower to
pin than this one was. Then the two `reconcileDependencyBlockingLeases`
resolvers.

I will keep working down that list. The map is on #3115 with verified
names; anyone can pick an entry and check it the same way — blind one
resolver, run `vitest run src/__tests__/self-healing`, and if it stays
green that conversion has nothing behind it.

## Verification

`self-healing.test.ts` **414 passed** · `pnpm test:gate` 13 + 161 + 487
+ 71 · lint — green.
2026-07-31 05:49:59 -07:00
gsxdsm
ce84aa48d0 test(self-healing): cover the renamed-board starved-refinement wake that main's conversion lacked (#3116)
**Rebased onto current `main`, and it shrank to one test.** Was
"self-healing consolidated (45 → 39)".

## What happened

**Every code change in this PR landed independently from other workers**
while it was open, and in each case theirs is equal or better. I took
theirs and dropped mine:

| My change | Landed on `main` as |
|---|---|
| pre-execution worktree seizure | `preExecLiveColumns` — same
"dangerous direction" reasoning |
| FN-5256 liveness cluster | `worktreeReconcileWipColumns` /
`worktreeReconcileReviewColumns` |
| agent-link membership | `agentLinkLiveColumns` /
`agentLinkTerminalColumns` |
| starved-refinement peer progress | `starvedWaitingColumns` — a project
union covering both duplicated sites |

Resolving the rebase by taking `main` left two orphaned declarations
(`activeOrQueuedColumns`, `holdPeerIds`) that nothing referenced. `tsc`
doesn't flag unused locals here, so I checked references by hand and
removed them rather than ship dead code that reads as converted.

## What's worth landing

**Their starved-refinement conversion has no renamed-board test — the
suite had zero.** This adds one.

A candidate resting in a renamed **intake** lane, with its peers in a
renamed **hold** lane, must still escalate. The two are deliberately
distinct columns so a wrong role set resolves no peers and escalates
nothing; a fixture where they coincide would pass either way.

The fake needed `listWorkflowDefinitions` — `starvedWaitingColumns` is a
**project union**, so per-task selection readers alone leave it
resolving nothing and the test would pass for the wrong reason. That
mismatch is how I found the gap: my original test failed against their
implementation.

## Verification

- Green against **their** code
- **Revert-proof against theirs:** restoring the literal fails it — 0
escalations against 1 expected
- 8 tests in the suite green

## Note for the fleet

This is the second PR of mine to shrink to a test on rebase (#3096 was
the first). Both times the duplicated work was real and mine was the
later arrival. The pattern is worth acting on at the coordination level,
not by me working faster.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 05:41:32 -07:00
gsxdsm
6483f9ce2b fix(scheduler): resolve task:updated / task:deleted lanes asynchronously (scheduler inert 5 → 0) (#3128)
The last inert guards in `scheduler.ts`. Independent of my other
branches.

## Inert-guard ratchet

| Scope | Before | After |
|---|---:|---:|
| `scheduler.ts` | 5 | **0** |
| total | 12 | **7** (triage.ts 8 → other worker; executor.ts 4 → #3112)
|

## The live bug

These read `resolveTaskParkedColumnsSync`, which answers with the
**default** workflow in production. On a renamed board the scheduler
**never woke** on unpause or planning-finish, and a **deleted blocker
never unblocked its dependents** — the card sat behind a task that no
longer existed.

## The criterion, restated because I got it wrong before

**What blocks a guard is whether its answer is consumed synchronously —
not whether the enclosing listener is declared sync.** I assumed the
latter earlier in this program and reverted for it.

All three fail that test: two only gate `schedule()`, which is itself
`async`, fire-and-forget and re-entrance-guarded; the third already sits
below an `await getSettings()`. The edge-trigger bookkeeping
(`planningTaskIds.delete`) **stays synchronous** on purpose — deferring
*that* would let a second update re-enter the branch.

## The union is load-bearing, not defensive

Post-U11 the default lineage has no `triage` column, so a **resolved**
answer returns `intake: "todo"` where the inert path fell back to
`"triage"`. Converting without unioning the legacy ids silently
**narrowed** the wake set and stopped waking cards in a legacy-named
lane — caught by *"schedules when planning clears in triage"*.

**A resolved conversion must be a superset of what it replaces, or it is
a behaviour change wearing a vocabulary change's clothes.** That's the
reusable lesson here.

## Tests

- Drained with the repo's existing **`flushAsyncHandlers`** helper —
written for exactly this fire-and-forget shape — rather than loosening
any assertion.
- **The characterization test flipped, as designed.**
`workflow-scheduler-parked-columns-live-e2e.pg.test.ts` asserted *"a
dependent in a RENAMED hold column is NEVER unblocked"*, with its author
noting: *"expected to flip to null the moment the resolver is fixed —
and that flip is the whole point of writing it down."* It flipped.
Inverted to a REGRESSION case so the assertion holds the fix rather than
the defect; it now matches its own CONTROL arm, which still guards
against a vacuous pass.

## Verification

- 21 scheduler suites — **361 green**, including the live PostgreSQL e2e
- **`pnpm test:gate` green**; eslint and `tsc` clean
- Changeset added; `check:changesets` passes

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 05:41:16 -07:00
gsxdsm
ad5172afd5 fix(engine): main is red on check:inert-sync-lanes — #3114's triage conversion is inert, revert the arm (#3126)
## `main` is red on `check:inert-sync-lanes` right now

```
inert-sync-lane: NEW inert conversions — a lane guard now reads a sync resolver
that always answers with the DEFAULT board.
  packages/engine/src/triage.ts: 7 -> 8
```

Verified on a clean `origin/main` checkout, not on my branch. #3114
converted this guard's third arm to `disposeLanes.wip`; the gate that
exists to catch exactly this fired, and the PR landed anyway —
presumably because `check:inert-sync-lanes` is not in the blocking
merge-gate set.

## The change did not change behaviour

`disposeLanes` comes from `resolvePlannerLanes`, which resolves through
`resolveTaskWorkflowIrSync` — inert under PostgreSQL for two independent
reasons (#3103). So `disposeLanes.wip` evaluates to `in-progress`: **the
same value as the literal it replaced.**

A card advancing into a renamed execution lane still matches nothing,
still reads as an evacuation, and still kills a healthy planning session
— the precise bug #3114 set out to fix, unchanged on every board.

So the arm goes back to the literal. The gate's own failure text rules
out the alternative:

> Do NOT re-record the baseline to clear this — that is the same false
green one layer up.

## #3114's analysis is kept — only the code reverts

Its behavioural description is **correct** and is the clearest statement
of this bug anywhere in the file. I have kept those paragraphs and added
what is missing: that the fix does not reach under PG, and what would.

Whoever supplies a lane answer that is not sync-resolved should make
this line read `disposeLanes.wip` and delete the note. The specification
is sitting right there for them.

## It also reconciles two contradictory notes, one of them mine

My #3108 flag said converting the third arm this way adds an inert
comparison and removes a census entry that is telling the truth. #3114
then converted it and added a note saying it fixes the bug. **Both notes
sat in the file**, giving any reader two confident, opposite accounts.
They are now one account with the evidence attached.

## Read this file's census count carefully

#3114 took it to **0** while the inert count went to **8**. The census's
own `--triage` output warns about exactly this shape:

> for a sync-resolved file, a count of 0 is the WORST case, not the best
— the file reads as fully converted

Reverting restores it to 1, which is the honest signal.

## Census

| | before | after |
|---|---|---|
| `triage.ts` | 0 | **1** |
| repo backlog | 26 | **27** |

**The number going up is the point.** A census that reports 0 for a file
whose guards are all inert is worse than one that reports the truth — it
retires the entry and nobody looks again.

## Measured

- `check-inert-sync-lane-conversions`: **exits 1 on `main`, 0 here** (8
→ 7).
- `src/__tests__/triage*` — **25 files / 374 tests pass**.
- `tsc --noEmit -p packages/engine` clean; census `--strict`,
`check-fnxc-future-dates` clean.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 05:35:28 -07:00
gsxdsm
4b61170a51 fix(executor): read task:moved lanes from the payload (executor.ts 4 → 0) (#3112)
**Stacked on #3109** — merge that first; this is its first consumer.

## Census

| Metric | Before | After |
|---|---:|---:|
| COLUMN guards (backlog) | 47 | **43** |
| `executor.ts` | 4 | **0** |

`executor.ts` is off the census top-files list.

## Why these four could not be converted in place

This listener is synchronous and its branches **start execution**,
dispose worktrees and release sessions. An await ahead of them defers
the `execute()` dispatch itself. The sync IR resolver isn't an option
either — it answers with the default workflow under PostgreSQL, so a
guard written through it is inert.

Reading the lanes the emitter already resolved costs nothing and leaves
the prologue synchronous. This listener is the reason #3109 has the
shape it does.

## The archive branch is the one with teeth

`to === "archived"` matched nothing on a board with a renamed terminal
lane, so **archiving never released the task's active-session registry
entry** — and that entry is what blocks a **successor** task from
acquiring the same path. Not cosmetic: the next task wanting that path
fails to register.

## Verification

- **Revert-proof:** the new case drives a `shipped` terminal lane
(matching no legacy id) and asserts the release. Reverting the branch to
the literal leaves the entry held — `expected [Array(1)] to have a
length of 0`.
- 43 executor suites — **483 green**
- **`pnpm test:gate` green**; eslint clean

## Note on shape

Lanes are read as **single ids, not sets**, because each branch here is
a lane-identity test on one column — exactly what the literals were.
Widening to membership would change behaviour, not just vocabulary.
Fail-soft to the legacy ids when the emit path could not resolve,
matching every other consumer of this payload.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 05:15:38 -07:00
gsxdsm
218086bea2 fleet(engine): self-healing 6 → 1 — the board-stall counter, the last guard that needed a sync answer (#3121)
The last fan-out guard, and the one I explicitly said needed a
synchronous answer. #3109 made that answer available without an await,
so the flag comes off.

## Why this one was last

The other two guards in this listener gated work the listener **already
`void`s**, so they moved onto the async resolver in #3094. This one
increments in-memory state **in the handler's own tick**, so it
genuinely needed a synchronous answer.

The sync IR path was never that answer: `resolveTaskWorkflowIrSync`
cannot resolve a **custom** workflow at all — two independent blockers,
#3103 — which is why I wrote that conversion, measured it, and withdrew
it.

#3109's emitter-carried `lanes` removes the dilemma rather than trading
one horn for the other: reading them needs **no await**, so the
increment stays in the same tick *and* the guard becomes correct.

## What it fixes

On a renamed board this counter read **zero**. The board-stall watchdog
was blind to a board whose cards were moving out of implementation the
whole time — the signal it exists to raise was never raised.

## Census

| | before | after |
|---|---|---|
| `self-healing.ts` | 6 | **1** |
| repo backlog | 29 | **24** |

The remaining 1 is the log-dedup closure — a pre-existing flag whose
degraded answer costs a duplicate log line, not a lifecycle decision.

## Measured

- 3 new cases; `self-healing-completion-fanout.test.ts` **13/13 pass**.
- **MUTATION**: restoring the literal pair fails the renamed case.
- **The paired negative is the load-bearing one.** The guard means
*"left implementation for somewhere that is not implementation"*, so a
move **between two non-wip lanes** must not count. Without that case, a
conversion that counted every move would pass the positive and inflate
the watchdog's denominator — breaking it in the opposite direction,
which is harder to notice than a zero.
- A **fail-soft** case pins that an emit carrying no `lanes` still
counts on the legacy ids.
- **Asserted through the counter itself**, not a downstream alert. The
increment *is* what this guard decides; routing the assertion through
the watchdog would let an unrelated threshold change mask a regression
here.
- `src/__tests__/self-healing*` + `task-agent*` — **42 files / 848 tests
pass**.
- `tsc --noEmit -p packages/engine` clean; census `--strict`,
`check-lane-wiring`, `check-inert-sync-lane-conversions`,
`check-fnxc-future-dates` clean.

## On the withdrawal this reverses

#3094 withdrew a sync-IR conversion of this listener and recorded why,
precisely. That record is what made this cheap: I could tell in one read
that #3109 addressed the *specific* obstacle rather than a general
"async is hard". A flag that names its blocker exactly is a flag that
can be retired the day the blocker goes.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 05:15:14 -07:00
gsxdsm
56b5cdfeed test(notifications): cover the wedge-episode renamed-lane clear that main's conversion lacked (#3096)
**Rebased onto current `main`, and it shrank to a test.** Was "close the
wedge-episode race, then resolve its lanes."

## What happened

Another worker landed **both halves of this PR independently** while it
was open. Rebasing showed their versions are better, so I took theirs
and dropped mine:

- **The serialisation** — theirs is `enqueueWedgeHandling(taskId, run)`,
a general callback; mine was wedge-specific.
- **The conversion** — theirs is **project-union membership** over the
four roles; mine was first-match-per-role via `resolveLifecycleColumns`.
Membership is correct: more than one lane can fill a role on a renamed
board, and first-match silently ignores the rest.

My rebased branch initially compiled to a **duplicate
`wedgeHandlingChains` field and duplicate method** — caught by `tsc`,
removed. Nothing of my implementation survives, and it shouldn't.

## What's left is worth landing

Their conversion has **no renamed-board test**. This adds one.

A card recovering into a renamed hold lane must **clear** its episode.
Asserted through the *second* notification, because a stale active
episode also **refuses the next genuine wedge its claim** — so the
visible symptom is a real wedge going unannounced, not merely a stale
alert.

The fixture needed `listWorkflowDefinitions`:
`resolveProjectColumnsForRoles` unions across the project's workflows,
so the per-task selection readers alone leave it resolving nothing and
the test would pass for the wrong reason. That's how I found the
mismatch — my original test failed against their implementation.

## Verification

- Green as written against **their** implementation
- **Revert-proof against theirs:** restoring the four literals fails it
— 1 delivered, 2 expected
- 7 notification suites — **80 green**; `tsc` clean

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

## Summary by CodeRabbit

* **Bug Fixes**
* Fixed wedge notifications so they can trigger again after a task
recovers into a renamed workflow’s hold lane.

* **Tests**
* Added regression coverage confirming that recovered tasks correctly
clear their wedge state and support subsequent notifications.

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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 05:09:36 -07:00
gsxdsm
6050d6eb83 chore(engine): mark the auto-merge-finalization reviewed literals DELIBERATE (census 47→45) (#3107)
Fleet phase. `packages/engine/src/auto-merge-finalization.ts` was the
last census file with no branch, worktree, or open PR against it. Claim
published by pushing the branch before starting.

## Census before / after

| | total | this file |
|---|---|---|
| before | **47** | 2 |
| after | **45** | 0 |

`--strict` exits 0, baseline re-recorded. **Reclassification, not
conversion** — both lines are unchanged.

## Both sites were already reasoned, in a note that calls them
non-defects

- **Line 30** is the resolver's **degraded fallback arm**, inside
`catch`. The live arm two lines up calls `columnHasFlag(ir, columnId,
"complete")`. The literal is reached only when IR resolution throws,
where the legacy id is the only answer left — removing it would make a
failed resolve return nothing.
- **Line 99** picks an **error string**. The note above it works through
threading `isCompleteColumn` in and concludes the signature widening
costs more than the sharper diagnostic buys.

I did not revisit either judgement. The gap was mechanical: prose the
census cannot read, so both stayed in `byFile` as apparent debt for the
next pass to re-derive.

## This is the fourth, and it closes the set

With #3056, #3060, and #3063, **every census file that was unclaimed
during this phase has now been examined, and not one needed a
conversion.** Each site was a three-state fallback arm, or a site a
prior pass had already reviewed and kept.

The corollary is the finding I would most want carried forward: the
remaining count is not a work queue. A worker told to "claim the largest
cluster" reads the number, finds most of it already reasoned, and
reaches for whatever moves it — which is how three PRs converted guards
to a synchronous resolver that is inert under PostgreSQL.

One exception worth preserving: **`taskRevert.ts` should stay counted.**
I claimed, inspected, and released it without marking. Converting it
would classify a *neighbour* row using the modal task's flags — wrong on
data, not merely stale on vocabulary — and its note correctly calls the
entry **accurate debt** blocked on a per-neighbour flag map. Fallback
arms and dead paths → mark. Placeholders awaiting a capability → leave
counted.

## Verification

- `census --strict` exit 0; `tsc --noEmit` (engine) **0 errors**
- No dedicated test file for this module (`vitest` reports none), so no
suite to run — comment-only diff, no behaviour change

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 05:09:23 -07:00
gsxdsm
89b21e2906 fleet: triage's planning-evacuation check uses the resolved wip lane (census 45 → 44) (#3114)
## Census

| | column guards |
|---|---|
| before | **45** |
| after | **44** |

## What changed

```ts
if (task.column === disposeLanes.hold || task.column === disposeLanes.intake
    || task.column === "in-progress") return;
```

Two role questions and one id question on the same line.
`resolvePlannerLanes` is **already called immediately above**, and its
result carries `wip` — so this needs no new resolution and no new await.
The literal just stops being the odd one out among its neighbours.

## What it cost on a renamed board

This handler aborts a planning session when a card leaves the planner
lanes. `in-progress` is excluded because *a card advancing into
execution is not an evacuation* — that's stated in the note directly
above it.

Against the literal, that exclusion **never matched** on a board whose
execution lane is renamed. So a legitimate advance into execution read
as an evacuation and **killed a healthy planning session** — precisely
the case the comment says must not abort.

`wip` is optional by design (PR #2628: a missing role stays `undefined`
so callers refuse rather than invent a column). Undefined here means the
board declares no execution lane, so there's no advance-into-execution
to exclude and the comparison is correctly false.

## Not addressed, and pre-existing

This line resolves through `resolvePlannerLanes` — the **sync** twin,
which returns the default workflow's lanes under PostgreSQL. That
affects all three lanes on the line equally and predates this change:
the handler is `(task: Task) => {}` with no await available, so fixing
it needs the same emitter-side change as #3082.

Making the third lane consistent with the other two doesn't deepen that,
and it leaves **one** shape to fix there rather than two.

## Measured

| check | result |
|---|---|
| triage / evacuation / planner-lane suites | **453 tests green** |
| four gates + strict census | green |
| engine `tsc` | clean |

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 05:06:08 -07:00
gsxdsm
6eeeb43d4b test(engine): pin #3047's archive-sweep conversion — measured uncovered (825 tests passed against the reverted fix) (#3115)
Not a conversion — the fleet's conversions are landing faster than their
coverage, and this is the audit that shows which ones actually have any.

## Method

For each of today's fleet commits to `self-healing.ts`: revert that
single commit, re-run the file's suites, see whether anything fails. If
nothing fails, the conversion has no regression protection and the "N
tests passed" cited on its PR was measuring something else.

| commit | reverted | verdict |
|---|---|---|
| #3075 pause-abort recovery | suites **fail** | covered |
| **#3047 archiveStaleDoneTasks** | **825 tests all pass** |
**uncovered** |
| #3078 (mine) | 204 tests all passed | was uncovered — closed by #3090,
#3102 |

Every fixture in the `archiveStaleDoneTasks` describe block uses the id
`done`, where the literal is correct, so none of them could see the
conversion at all.

## What the literal cost

The sweep's dependent scan skips tasks in terminal lanes. On a renamed
board **nothing matched `done`/`archived`, so every task read as
active** — which means every archive candidate looked like it had active
dependents, and the sweep archived **nothing**. The board quietly stops
auto-archiving: no error, no log line, no failing test.

## The case covers both halves of #3047

- a stale card in a **renamed complete lane** is archived — the
`complete` role
- a card whose dependent is still live in the **renamed wip lane** is
**not** archived, and a dependent already in the **renamed archive
lane** does not count as live — the `terminal` role

That second assertion is the one that matters: it stops the fix from
degenerating into "archive everything", which is the failure mode a
one-sided test would miss.

## Measured

**413 pass** on current main; reverting #3047 fails **exactly this
test**.

## Remaining audit

I have now audited 3 of ~13 fleet conversions to this file this way. The
method is cheap (one revert, one 17s suite run) and I will keep working
through the rest unless someone else picks it up. #3049 could not be
auto-reverted — later commits overlap its hunks — so it needs a manual
read rather than a mechanical revert.

## Verification

`self-healing.test.ts` **413 passed** · `pnpm test:gate` 13 + 161 + 487
+ 71 · lint — green.
2026-07-31 04:59:44 -07:00
gsxdsm
ec2921b958 fleet(engine): self-healing 23 → 6 — async-reachable guards, plus 3 of 4 fan-out guards the sync path could not serve (#3094)
**Replaces #3093, which I am closing.** Third rebuild of this work.

## A coordination note first, because it is costing more than the code

`self-healing.ts` has had **three** overlapping conversions land from
other lanes while my branch was open — #3049, #3075, #3078. Every time,
replaying my commits produced conflicts that were all the same shape:
*same guard, two spellings, different variable names*. Each rebuild is a
full cycle spent on merge mechanics rather than on lanes.

I have rebuilt against `main`'s own census each time rather than argue
about whose spelling wins, and this PR contains only what `main` (23)
does not have. But if this file is going to keep receiving concurrent
fleet passes, one lane should own it — otherwise the next PR pays the
same tax again.

## Converted

| site | note |
|---|---|
| `isPhantomExecutorBinding` | caller resolved
`lanesOfReclaim(task.id).wip` **three lines above the call**, then
passed a task whose column the predicate compared against `in-progress`
|
| `isWorkspaceOwnerLive` | required `completeColumns` |
| `recoverPausedAbortFailures` **body** | #3075 converted this sweep's
*router* and left three body guards comparing ids |
| `reconcilePreExecutionWorktrees` | a four-id literal in a sweep that
**removes worktrees** |
| `recoverStarvedRefinementTriageTasks` | 2 peer counts that read zero,
so escalation never fired |
| `evaluateParkedAgentTaskLink` | the omitted `parkedColumns` argument |

All through the **async** `resolveProjectColumnsForRoles`, whose only
store read is `listWorkflowDefinitions()` — answerable under PostgreSQL.
That is what separates these from the inert kind.

**The half-converted sweep is the important one.** A router that
resolves correctly feeding a body that compares ids is worse than
converting neither: the route now fires on a renamed board and the body
then acts on the wrong lane. The `moveTask` **target** is the sharp end
— an undeclared target is rejected *except* under `recoveryRehome` with
a legacy id (`moves.ts:570`, the #1411 escape hatch), so a converted
route feeding the literal `"todo"` rehomes the card into a column its
workflow does not declare, which is the state other reconcilers exist to
repair.

**A real dropped-behaviour bug**: `evaluateParkedAgentTaskLink` was
called without `parkedColumns`, falling back to `LEGACY_PARKED_COLUMNS`.
A live durable agent linked to a card resting in a renamed hold lane
read as not-parked, so the safeguard preserving its task link never
applied.

## Withdrawn: the `task:moved` fan-out

I wrote the sync-IR conversion, measured it, removed it.
`getTaskWorkflowSelectionImpl` returns `undefined` **unconditionally**
under PostgreSQL, so `resolveTaskWorkflowIrSync` always answers with the
default builtin IR and `columnsWithFlag` on it yields exactly the legacy
ids — inert on every board.

Worse than the literal, because **the literal is counted**. My own test
passed only because its store mock supplied a renamed IR: it pinned the
helper's shape, not production behaviour. The refutation is recorded in
place, and `check-inert-sync-lane-conversions` exits 0 on this branch.

## One question, one answer

An earlier pass of this work mapped the notification-attach guard onto a
wider `activeWork` set, and `self-healing-paused-abort-recovery >
"rehomes an in-progress pause-abort park back to todo"` caught it — an
in-progress park attached a transition notification it should not have.

The fix is not a narrower set. Both guards ask **one** question — *"is
the card already at the requeue target?"* — which the literal happened
to spell twice as `=== "todo"`. The target now resolves once, before the
write, and both read it. Deriving one question two ways is exactly how a
converted guard and an unconverted target drift apart.

## Census

| | before | after |
|---|---|---|
| `self-healing.ts` | 23 | **11** |
| repo backlog | 53 | **41** |

## Measured

- `src/__tests__/self-healing*` + `task-agent*` — **42 files / 836 tests
pass**
- `tsc --noEmit -p packages/engine` clean;
**`check-inert-sync-lane-conversions` exits 0**; census `--strict`,
`check-lane-wiring`, `check-fnxc-future-dates` clean

## The remaining 11, flagged not guessed

- **4** — the fan-out, withdrawn above; blocked on a sync-capable
selection reader.
- **1** — the log-dedup closure: pre-existing flag; it sits before the
lane prefetch it needs, and the degraded answer costs a duplicate log
line, not a lifecycle decision.
- **1** — the synthetic `{ column: "todo" }` for a *missing* task:
deliberate, and now correct rather than unconverted, because
`parkedColumns` is legacy-seeded.
- The rest are status/deliberate classifications the census counts but
that are not lane guards.

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

## Summary by CodeRabbit

* **Bug Fixes**
* Improved workflow automation for boards using renamed or customized
workflow lanes.
* Fixed task completion fan-out, branch rebinding, recovery, and
stalled-task detection across custom lifecycle columns.
* Prevented completion actions from triggering when tasks move back to
the work-in-progress lane.
* Improved cleanup and pause recovery behavior for customized workflows.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 04:56:49 -07:00
gsxdsm
41cdcc741e fix(events): carry resolved lanes on task:moved so listener guards stop being inert (#3109)
Removes the **inert-guard class at its source** instead of one call site
at a time. Independent of my other branches.

## The problem

`task:moved` listeners run synchronously, so a listener needing a lane
answer had to resolve one synchronously — and
`resolveTaskWorkflowIrSync` returns the **default** workflow under
PostgreSQL, the shipped backend. Every such guard behaved exactly as the
literal it replaced, while the census scored it as converted.

**Resolving asynchronously inside the listener is not available**, and
that is measured rather than assumed. The scheduler's
`snapshotManager.invalidate` is asserted to run in the listener's
**synchronous prologue**; putting an await ahead of it produced **3
failures across 21 scheduler suites**.

## The fix

The emitter carries the answer, which removes the dilemma rather than
trading one horn for the other. `moves.ts` is already async and already
post-commit, so it resolves the moving task's lanes **once** and hands
them to every listener. The guard becomes correct **and** the prologue
stays synchronous.

This is the file's own recorded preferred fix — *"having the emitter
carry the resolved lanes on the event payload so no listener resolves at
all"* — now that the audit it was waiting on is done and came back as
**one** prologue-dependent consumer, not a class.

## Design choices

- **`lanes` is optional and fail-soft to `undefined`** — "unknown",
never "legacy". Some emit paths fire from sync contexts or a cached row
mid-teardown. Listeners keep their existing fallback, so those paths are
no better than before but **no worse**, and they become the exception
rather than the rule.
- **`mergeParkedColumns` overlays only fields the emitter actually
resolved**, so a partial payload cannot blank a lane back to a wrong
answer.
- **The sync resolver stays** as that fallback. Deleting it would strand
the emit paths that cannot resolve.

## Verification

- **Revert-proof and it pins the prologue:** the new case asserts
invalidation on a **renamed** hold lane with **no `waitFor`**. Ignoring
the payload gives **0 calls**.
- 21 scheduler suites — **361 green**
- self-healing + notification suites — **491 green**
- core moves + the `sync-workflow-ir-callsite-allowlist` ratchet — green
- **`pnpm test:gate` green** (71)
- Changeset added; `check:changesets` passes

## What it unblocks

`scheduler.ts`'s 10 allow-listed guards now resolve correctly for every
move that goes through `moves.ts` — the path real moves take. Those were
already absent from the backlog, so **the census number does not move**;
what changes is that they now do what the number claimed.
`executor.ts`'s 4 remaining sites can follow the same pattern in a
separate PR.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 04:47:09 -07:00
gsxdsm
15a664a8f5 docs(engine): flag executor's four task:moved literals — the obvious conversion is provably inert (#3104)
The largest unclaimed census cluster. **Nothing in this file said why
the sync-lane pass skipped it**, and that silence is the hazard: the
obvious next move is to convert these the way `scheduler.ts`'s ten were
converted, which would make them **inert rather than fixed**.

## The literals are genuinely wrong — this is not a "non-issue" flag

All four sit in one synchronous `task:moved` listener, and on a renamed
board:

- execution **never starts** on a move into the board's own wip lane;
- terminal session release **never runs** on a move into its archive
lane;
- both `from` guards never fire, so **in-flight work is not aborted**
when a card leaves implementation.

Nothing errors. The engine simply stops reacting.

## Why the obvious fix is inert — proved, not argued

`task:moved` is emitted synchronously, so an `await` here reorders this
handler against every other subscriber. That points at the sync IR path,
which cannot answer for a renamed board for **two independent reasons**
(`sync-workflow-ir-second-blocker.test.ts`, #3103):

1. `getTaskWorkflowSelectionImpl` returns `undefined` unconditionally
under PostgreSQL, so `resolveTaskWorkflowIrSync` always takes its
`!workflowId` branch.
2. Even **with** a selection, the custom-workflow branch loads its IR
through `store.db`, whose implementation is an **unconditional throw** —
so it falls into the catch and returns the default IR anyway.

**A renamed lane is a custom workflow, so (2) alone is decisive.** The
sync path can never serve this listener's case, whatever the selection
reader is fixed to do. That is the part the existing notes across this
repo miss, and it is why flagging beats attempting here.

`check-inert-sync-lane-conversions` already baselines **twenty** guards
in exactly that state in `scheduler.ts`. These four must not join them.

## Census

**Unchanged at 4, deliberately.**

Marking them DELIBERATE-LITERAL would buy a smaller number by asserting
the code is *fine*. It is not fine — it is *blocked*. Those are
different claims with different expiries, and the census should keep
pointing here until the block is lifted. An unconverted literal is
visible; an inert conversion leaves the backlog and takes the evidence
with it.

## Measured

- Comment-only change.
- `src/__tests__/executor*` — **84 files / 853 tests pass**.
- `tsc --noEmit -p packages/engine` clean; census `--strict`,
`check-inert-sync-lane-conversions`, `check-fnxc-future-dates` clean.

## Unblocking, for whoever takes it

Either an async listener contract — a behaviour change to handler
ordering, not a column conversion — or a sync reader that answers for
**custom** workflows *and* survives a writer on another node. All three
constraints are written up in `sync-workflow-ir-second-blocker.test.ts`
(#3103).

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 04:40:42 -07:00
gsxdsm
f5926d3b54 docs(engine): flag triage's evacuation guard — it looks two-thirds converted and is fully literal (#3108)
Completes the sync-listener audit across the three files holding the
remaining blocked guards — `executor.ts` (#3104), `scheduler.ts`
(#3100), and this one. Triage is the most misleading of the three.

## The shape lies

The guard reads as **two resolved arms and one literal**:

> `task.column === disposeLanes.hold || task.column ===
disposeLanes.intake || task.column === "in-progress"`

So the obvious next move is to convert the third arm with the same
helper. That is wrong twice:

**1. The two "resolved" arms are not resolved.** `resolvePlannerLanes`
goes through `resolveTaskWorkflowIrSync`, which cannot answer for a
**custom** workflow — the sync selection reader returns `undefined`
unconditionally, *and* the custom-workflow IR read goes through
`store.db`, whose implementation is an unconditional throw (#3103). So
`disposeLanes.hold` / `.intake` are `todo` / `triage` on every board.
**All three arms are literal in effect.** Converting the third the same
way adds a third inert comparison and retires a census entry that is
currently telling the truth.

**2. The guard's answer is consumed synchronously** — the criterion I
had to correct in #3104. Below it, `pauseAborted.add`,
`session.dispose()` and `activeSessions.delete` mutate in-memory state
in this tick, and other paths read those maps. Contrast
`self-healing.ts`'s fan-out, where three of four guards only gated work
the listener already `void`s and so *were* convertible via the async
resolver (#3094).

## What it costs, and the obvious reading is backwards

An evacuation **into** a renamed destination still falls through and
disposes correctly — no bug there.

The failure is the other direction: on a board whose **hold or intake**
lane is renamed, arms 1 and 2 stop matching, so a card **sitting still
in its own planning lane** is treated as evacuated and its live triage
session is aborted mid-run.

I state it that way because "renamed board → guard misses → nothing
happens" is the pattern everywhere else in this program, and here it
inverts.

## Census

**Unchanged at 1**, deliberately. Blocked, and now documented as *fully
literal* rather than part-converted — which is the fact a future pass
needs in order not to make it worse.

## Measured

- Comment-only.
- `src/__tests__/triage*` — **25 files / 374 tests pass**.
- `tsc --noEmit -p packages/engine` clean; `check-fnxc-future-dates`
clean.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 04:40:21 -07:00
gsxdsm
0e4a559a0a test(census): make the tighten fixture self-maintaining instead of pinned to committed state (#3106)
## What broke, and why it will break again

The two cases in this block assert the CLI tightens an inflated
allowance **by exactly the inflation**. That arithmetic only held while
the *committed* baseline matched the tree — so it broke the moment a
fleet PR took `self-healing.ts` from 26 to 22 without re-recording. The
CLI correctly tightened to 22 while the fixture expected 26, and both
cases went red for a reason that had nothing to do with the code under
test.

#3101 fixed that instance by committing the number. **This fixes the
class.**

## Why it recurs

The census **exits 0 on a drop** — deliberately, so one worker's merge
can't redden the gate for everyone else. The cost is that the committed
baseline goes stale *silently*: every run rewrites the file, prints
`COMMIT IT`, and exits 0. This fixture is what eventually trips over it.

With a fleet actively converting the largest file (eight open PRs
against `self-healing.ts` as I write this), that's a recurring red, not
a one-off.

## The change

The fixture syncs its temp copy to the tree with `--strict
--update-baseline` **before** inflating. The assertion is then about the
CLI's behaviour rather than about what happens to be recorded on disk.

## Differential proof, both directions

Against an artificially staled baseline (22 → 26):

| | result |
|---|---|
| with this change | **40 passed** |
| without it | **2 failed / 38 passed** |

So the fixture now tolerates drift it previously broke on — and still
fails if the CLI stops tightening, which is the property it was written
to guard. That second half matters: a fixture made tolerant of
everything would be worse than the flake.

The CLI invocation is extracted to a `runCli` helper so the sync run and
the assertion run share one path. No behaviour rides on that extraction.

## Measured

| check | result |
|---|---|
| census suite, clean tree | 40/40 |
| census suite, staled baseline | 40/40 |
| `--strict` | exits 0, no residual drift |

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 04:33:37 -07:00
gsxdsm
0da19f7963 fix(core): a renamed archive lane was recorded as done in the eval corpus; flag the scheduler's two honest literals (#3100)
Two pieces, both about the same distinction: which literals are worth
**converting** and which are worth **naming**.

## Converted — the eval corpus was mislabelling renamed archive lanes

`collectDeterministicSignals` writes `column` as a two-value eval-record
field. Against the `archived` literal, a card resting in a renamed
archive lane was recorded as `"done"`.

No crash, no lifecycle decision — a **mislabelled row in the eval
corpus**, which is a dataset every later comparison reads. That is the
expensive kind of quiet: nothing fails, the numbers just drift.

The collector is sync and pure (no store, no workflow), so the lane
answer arrives as an optional parameter.
`HybridEvaluatorService.evaluateTask` is async and already holds an
optional store, which is where the resolution is paid; a store-less
evaluator degrades to the legacy literal rather than failing.

**Only the archived arm was ever wrong.** A renamed *complete* lane was,
and remains, recorded as `"done"` — which is correct. So only that
answer is resolved, and a third case pins that the widening did not turn
every renamed lane into `"archived"`.

## Flagged, not converted — the scheduler's two honest literals

These are the two `scheduler.ts` literals the sync-lane pass did not
take, and **nothing in the file said why**. That silence is the problem:
the obvious next move is to "finish the job" the way the other ten were
converted, and that would make them **inert, not fixed**.

`getTaskWorkflowSelectionImpl` returns `undefined` unconditionally under
PostgreSQL, so `resolveTaskWorkflowIrSync` always answers with the
default builtin IR — proved in
`postgres/sync-workflow-ir-is-always-default.pg.test.ts`, and
`check-inert-sync-lane-conversions` already baselines **twenty** guards
in that state in this same file.

They stay literal and **counted**, which is the honest state. An
unconverted literal is visible to the census; an inert conversion leaves
the backlog and takes the evidence with it. The note names the real
blocker — a sync-capable workflow-selection reader — so the next pass
does not spend a cycle discovering this the way I did.

## Measured

- 3 new cases in `eval-signal-collector.test.ts` — file **5/5 pass**.
- **MUTATION**: restoring the `archived` literal fails the renamed case
and leaves **both** the legacy control and the renamed-complete negative
green. The negative matters here: the fix must not turn every renamed
lane into `"archived"`.
- core eval suites — **4 files / 20 tests**; engine scheduler +
evaluator — **14 files / 143 tests**.
- `tsc --noEmit` clean in both packages; census `--strict`,
`check-lane-wiring`, `check-inert-sync-lane-conversions`,
`check-fnxc-future-dates` clean.

## Census

Both files keep their counts, deliberately:

- `eval-signal-collector.ts` — the remaining entry is the new
parameter's documented default, which is the fallback doing its job.
- `scheduler.ts` — the two literals this PR deliberately leaves visible.

A census that fell here would mean the flags had been marked exempt,
which would assert the code is fine. It is not fine; it is blocked, and
those are different claims with different expiries.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 04:29:21 -07:00
gsxdsm
f1e96f7a17 test(engine): pin the agent-link-drift terminal check on a renamed board (an agent stayed linked to finished work) (#3102)
Second of the two uncovered sweeps I flagged when #3078 merged. Not a
conversion — the conversion is already on main
(`driftedTerminalColumns`, landed by another fleet PR). This is the
coverage it shipped without.

## What was unprotected

Every existing case in this file uses `done` or `archived`, where the
literal is correct. So the terminal check had **no renamed-board case at
all**, and the same measurement that caught #3078 applies: a green file
proves nothing about a conversion whose fixtures can't express the
failure.

What the literal cost on a renamed board: **a durable agent stayed
linked to a finished task forever.** A linked agent is not free to pick
up new work, so the drift this sweep exists to clear is exactly the
drift it stopped clearing.

## Measured, both directions

| case | result |
|---|---|
| agent linked to a task in a RENAMED complete lane is cleared | **fails
on revert** — `taskId` still `"FN-9"`, agent pinned to finished work |
| agent linked to a task still in a RENAMED wip lane keeps its link |
passes either way — the sweep must narrow, not widen |

14 pass on current main; reverting the terminal check to the id pair
fails exactly one.

## Note on the fleet

This sweep was converted by someone else's PR while I was writing the
test for it — I found out because my revert probe hit
`driftedTerminalColumns`, a name I did not write. That is the collision
pattern working in a *useful* direction for once: their conversion, my
coverage, no duplicated code.

It also means the two of us independently chose the same sweep from a
7-PR pileup on this file. Assigning files from the census list would
still be cheaper than discovering the overlap in a test harness.

## Verification

`self-healing-agent-link-drift` **14 passed** · `pnpm test:gate` 13 +
161 + 487 + 71 · lint — green.
2026-07-31 04:25:59 -07:00
gsxdsm
d448ab6951 fix(engine): the merge-refusal reason was classified by a column id, and it lands in run-audit (#3098)
Claimed `auto-merge-finalization.ts` — and this one is a **reversal of
an earlier audit in the same file**, which is the interesting part.

## The earlier note said "diagnostic only". It was wrong about the
consequence

`validateWorkflowDoneMergeProof` picks between two refusal reasons with
`task.column === "done"`. Both arms return `{ ok: false }`, so this
never changed which branch ran — and on that basis a prior pass recorded
it as *"REAL but DIAGNOSTIC-ONLY"* and declined it, reasoning that
widening a signature to improve an error string is a poor trade.

**The reason is not an error string.** It is written to run-audit
metadata alongside `previousColumn` — `merger-merge-lifecycle.test.ts`
asserts exactly that — and that row is what an operator reads to find
out why a merge was refused.

So on a board whose complete lane is not called `done`, a card resting
in that lane was refused with the generic `missing-merge-confirmation`:
the classification for a card that is **not in the complete lane at
all**. The audit trail recorded the opposite of what happened. A wrong
record is worse than a vague one, because it gets acted on.

## The trade was also cheaper than the note claimed

The function is **already async** and **already takes an options bag**.
`resolveFinalizationColumns`, two functions up in the same file,
**already builds this exact predicate** for its own guard.

Nothing new is resolved. The answer that existed is handed down instead
of being re-asked with an id — the half-conversion shape this program
keeps finding, here inside a single file, one line apart: the caller
guards on the resolved `isCompleteColumn(latest.column)`, then calls a
validator that re-asked the same question with the literal.

`isCompleteColumn` is **optional with the legacy literal as its
default** — the same default-to-legacy contract the lane-parameter
vocabulary uses elsewhere — and `check-lane-wiring` watches the
parameter, so the two call sites cannot silently stop passing it.

## Measured

- New `merge-proof-reason-renamed-complete-lane.test.ts` — **2 pass**.
- **MUTATION**: dropping the parameter fails the renamed case and leaves
the legacy **control** green. The control earns its place: a failure now
means *"renamed board"*, not *"the refusal stopped working"*.
- **Driven through `finalizeProvenAutoMergeTask`**, not by calling the
validator with the new argument. The contract under test is the
**wiring** — a test that passed the argument directly would assert my
own parameter works and prove nothing about the seam that was broken.
- **The audit row is asserted, not just the return value.** The return
value alone is not the contract that failed here.
- merger / auto-merge suites — **5 files / 159 tests pass**.
- `tsc --noEmit -p packages/engine` clean; census `--strict`,
`check-lane-wiring`, `check-inert-sync-lane-conversions`,
`check-fnxc-future-dates` clean.

## Census

`auto-merge-finalization.ts` stays at **2**, deliberately. Both
remaining entries are now documented **degraded-fallback arms** — the
resolver's `catch` and this parameter's default — which is the right
kind of literal rather than a missed conversion. Converting a fallback
to a resolution would defeat its purpose.

## A note on the FNXC gate

My first stamps were dated `2026-08-01` while local today is
`2026-07-31`. `check-fnxc-future-dates` caught it and I re-stamped.
Worth mentioning because it is the second time this session that a
date-only local-calendar comparison has caught a stamp written near
midnight — the gate is doing real work, not ceremony.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 04:22:33 -07:00
gsxdsm
a7b2a757fa fix(engine): serialise wedge handling per task, then convert the lane guards it was blocking (5 → 1) (#3087)
The largest unclaimed census cluster, and the one two earlier fleet
passes explicitly declined.

## The standing blocker, taken on

Both passes converted these four ids and reverted, each time after the
same test went red:

```
task-wedge-notification.test.ts > sends one actionable push and mailbox message per active terminal episode
  expected 2 calls, got 1
```

Their diagnosis was right and I have kept it: this branch **resolves** a
wedge episode, `handleTaskUpdated` starts it fire-and-forget from a
synchronous `(task) => void` listener, and **any** await introduced
before the resolve lets a re-wedge arriving close behind reach `claim`
while the previous episode is still active — `claimed: false`, second
operator notification silently dropped. Column resolution needs an
await, so the conversion could not be made safe from inside the branch.

Both notes named the fix and left it for "whoever owns the wedge episode
contract": *serialise wedge handling per task*. This PR does that, then
takes the conversion.

## 1. Serialisation

`enqueueWedgeHandling` chains handling per task id, so
resolve-then-claim keeps its order however many awaits either branch
acquires. Details that matter:

- **Keyed by task, not global** — different tasks stay concurrent, so
this is not a throughput regression on a busy board.
- **The map entry is dropped when its chain drains**, and only if no
later link was appended while it ran, so it does not grow with the task
table.
- **Links never reject.** `maybeNotifyTaskWedge` already owns its error
handling; a rejected link would poison every later notification for that
task.

## 2. The conversion it was blocking

The four ids are an enumeration of *"every lane except review"* — the
lanes whose occupancy proves a wedged card's lifecycle has visibly
resumed. On a renamed board none of them matched, so a recovered card's
episode never resolved. Two consequences, and the second is worse than
the first:

1. the operator keeps an open "needs operator action" alert for work
that has moved on;
2. an active episode **suppresses re-claim**, so the *next* genuine
wedge on that task is never delivered.

Membership over the four roles, legacy-seeded, so an unconverted board
resolves exactly the four ids it used to compare.

## Measured

**The acceptance test the earlier notes named is the gate on both
halves.** With the conversion and *without* the serialisation, "sends
one actionable push and mailbox message per active terminal episode"
fails exactly as they reported. With the serialisation, green. I
reproduced their finding rather than taking it on trust — it is the
evidence that the serialisation is load-bearing and not incidental
refactoring.

| | result |
|---|---|
| `task-wedge-notification.test.ts` | **15/15** (2 new) |
| notification suites | **11 files / 234 tests pass** |
| `tsc --noEmit -p packages/engine` | clean |
| census `--strict`, `check-lane-wiring`,
`check-inert-sync-lane-conversions`, `check-fnxc-future-dates` | clean |

**MUTATION**: restoring the four literals fails the renamed-recovery
case and leaves its paired negative green.

**A vacuity I caught and fixed, worth stating plainly.** My first
version of the renamed case recovered the card with `status: "queued"`.
`hasProgressed` is an OR whose other arm is *"status is a non-failed
string"* — so that arm answered true and the column comparison never
ran. The mutation did not fail it. The case now clears `status` and
`error` together, which makes column membership the only thing that can
resolve the episode, and the paired negative uses the identical shape so
only the lane differs.

## Census

| | before | after |
|---|---|---|
| `notification-service.ts` | 5 | **1** |
| repo backlog | 71 | **67** |

## The remaining 1, flagged not guessed

`isManualMergeHold` (`task.column !== "in-review"`) is sync, and so is
its only caller `classifyWorkflowTransitionNotification`, reached from
the same `handleTaskUpdated` listener. Converting it means making that
whole chain async — a change to notification *classification ordering*
against every other `task:updated` handler, which is a different
contract from the episode one this PR owns. The serialisation added here
does not cover it: it wraps wedge handling, not transition
classification. Threading a pre-resolved `LifecycleColumns` in as a
parameter is the likely fix, and it wants the same gate-placement
judgement applied deliberately rather than swept in behind this.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 04:10:03 -07:00
gsxdsm
701677a2e5 test(engine): pin #3078's executor-owned skip — it merged without coverage (204 tests passed against the reverted fix) (#3090)
#3078 merged its conversion of the orphaned-pending-step-results sweep
**before this test landed**, so that sweep is on main with no coverage.
This closes the gap.

## The gap was measured, not assumed

With all three of #3078's conversions reverted, **all 204 self-healing
tests still passed**. I had cited that number as verification when I
opened it. It was meaningless for that change: every existing test in
the file uses `in-review` / `in-progress`, where the literal is correct,
so none of them could see the defect.

This is the same "a green suite is not coverage" failure I flagged in
other PRs today — in my own work, twice. The only reason I caught it is
that I finally ran the revert check on myself.

## Two cases

- **An executor-owned card in a renamed wip lane is SKIPPED.** Against
the pre-#3078 sweep this fails: the sweep reaches a card an executor is
actively running and rewrites its `pending` step results to `failed` —
the one thing that file's header says it must never do. The liveness
triple does not cover it; those legs prove an *in-process* session, and
an executor on another node or between session handles is exactly what
the column skip is for.
- **A genuine orphan on that same renamed board is still recovered** —
the skip must narrow, not disable. Passes either way, deliberately.

## What it pins, precisely

The **invariant**, not a line. Reverting either single guard still
passes, because the page-snapshot check and the fresh-row re-read
protect independently. What fails is reverting the sweep's column
handling as a whole — which is the condition worth pinning, and matches
the project's "fix the invariant, not the repro" rule.

## Still uncovered, said plainly

#3078's other two sweeps — worktree-metadata liveness and agent-link
drift — have no dedicated case. The orphaned-step-results sweep got the
test first because it is the one that can corrupt a live executor's
state. The other two remain honest debt rather than implied coverage.

## Verification

`self-healing-orphaned-pending-step-results` **10 passed** on current
main · full self-healing suites 204 · `pnpm test:gate` 161 + 13 + 487 +
71 · lint — green.
2026-07-31 04:09:48 -07:00