Commit Graph

11174 Commits

Author SHA1 Message Date
gsxdsm
bebbdf9083 fix(tests): main red — archive restore returns to the ARCHIVED lane now (#2832) (#2847)
## Red on main

```
store-archive-reads > TaskStore archived read parity (PostgreSQL)
  > rebuilds a missing live row before consuming its archive snapshot
AssertionError: expected 'done' to be 'todo'
```

## The product change is a real fix

**#2832** found that `preArchiveColumn` has no database column — it
exists on the `Task` type and in the archive snapshot and nowhere else —
so the old code fell through to a literal and decided the destination
the same way for **every unarchive that has ever run**. On a custom
board that meant a card archived mid-implementation came back marked
*finished*.

That PR flipped its own characterization cases. This one, in a different
file, was missed. The fixture creates the card in `done`, so `done` is
the answer now.

## I measured a second sample before encoding a rule

The behaviour is **narrower than "restores to the lane it came from"**.
With the fixture changed to `in-progress`, restore returns **`todo`** —
not `in-progress`:

| archived from | restored to |
|---|---|
| `done` | `done` |
| `in-progress` | **`todo`** |

A terminal lane is preserved; a WIP lane is re-queued. That is plausible
product behaviour — a card cannot resume mid-execution after a restore —
but it is **not what #2832's summary describes**, so I have flagged it
there rather than encoding it here. If re-queueing WIP is deliberate it
deserves its own case; if it is not, this snapshot-rebuild path still
carries the defect #2832 fixed elsewhere.

## An honest limitation, recorded in the test

This assertion is **weaker than it looks and cannot be strengthened
here**: `done` is also the complete lane, which is exactly what the
pre-#2832 *"no usable history"* branch returned. A card archived from
`done` therefore reads identically under both the fixed and the broken
implementation.

My first attempt "strengthened" it by moving the fixture to
`in-progress` — that is what surfaced the second behaviour above, and
shipping it would have encoded a rule inferred from two samples.
Reverted; the limitation is documented instead.

## Scope

Only the line-205 case is touched. Line 218 asserts `todo` for a card
genuinely archived from `todo` and still passes — the two are not the
same claim.

Core **4813 passed / 0 failed** · gate **732 green** · lint clean.
Test-only.

## Pre-flight results for the current queue

Merged-with-main, engine + core on each:

| PR | result |
|---|---|
| #2822, #2819, #2823, #2818 | only the 2 inherited
`workflow-ir-resolver` failures (fixed in #2836, now merged) |
| #2805 | clean |
| #2828 | inherited only, once this and #2836 land |
| #2830 | inherited only |
| #2820, #2803, #2808 | **conflict** with main — census baseline; told
the owners to regenerate rather than hand-merge |

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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 14:53:32 -07:00
gsxdsm
51934931e1 fix(board): the awaitingPlanning badge only ever worked on a lane named "todo" (#2845)
Converts the one site in `register-task-workflow-routes.ts` that a
previous pass **deliberately deferred**, and does it in the shape that
note asked for.

## What the deferral said

```
FNXC:WorkflowResolvedColumns 2026-07-29-00:00 (U12 — R8, DELIBERATELY NOT CONVERTED):
… I converted it and then REVERTED: resolving each task's hold column needs a
per-task workflow read, and this is the board-load path whose own comment above
exists because unbounded reads here "turn a board load into thousands of reads".

Converting it properly needs the hold column resolved per WORKFLOW from data the
board payload already carries, not per task from the store.
```

That was the right call and the right diagnosis.
`resolveProjectColumnsForRoles(store, ["hold"])` is exactly the
project-scoped shape it names: **one** `listWorkflowDefinitions()` read
per board load, flat in task count. The expensive part — a PROMPT.md
read per row — is untouched and still bounded by
`AWAITING_PLANNING_ENRICH_LIMIT`.

The test asserts the flatness directly (`listWorkflowDefinitions` called
exactly once), so a future per-task regression fails here rather than
being discovered as board latency.

## What was broken

The filter named `todo`, so on a board whose waiting lane is called
anything else **no row was enriched at all** — no error, no log line,
just a silent fall back to the client's `steps.length === 0` heuristic.
That heuristic is precisely what this enrichment was added to correct,
so the card most likely to be mislabelled — real spec, zero parsed
steps, already a scheduler dispatch candidate — sat on "Queued to plan"
indefinitely.

Over-inclusion is the safe direction and is chosen deliberately: a card
in some other workflow's hold lane gets annotated as waiting, which is
what a waiting card in a waiting lane should show.

## Revert proof (measured)

Restore `task.column === "todo"`:

```
FAIL  register-task-workflow-routes.awaiting-planning.test.ts
  > enriches a card in a RENAMED hold lane, not only one literally named todo
  expected undefined to be false
```

The other 8 cases in the file stay green — their harness store declares
no `listWorkflowDefinitions`, so they run the degraded legacy-`todo`
path. That compatibility is half the contract, which is why the new case
brings its own store rather than widening the shared harness.

## Census

| file | before | after |
|---|---|---|
| `packages/dashboard/src/routes/register-task-workflow-routes.ts` | 3 |
2 |

The 2 remaining in that file are documented trait-fallback branches, not
unconverted debt. The baseline also picks up
`packages/core/src/task-store/moves.ts` 2 -> 0, already true on main and
not from this diff.

## Verification

- `pnpm test:gate` — 161 / 487 / 13 / 71 passed
- `pnpm lint` — clean
- `tsc --noEmit -p tsconfig.json` (`@fusion/dashboard`) — clean
- `node scripts/lifecycle-column-census.mjs --strict` — exit 0
- targeted: `register-task-workflow-routes.awaiting-planning.test.ts`
9/9

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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 14:48:00 -07:00
gsxdsm
cea9637dfc feat(census): split the query class into read-shaped and write — most of the remainder must not be converted (#2837)
Splits the census's query class into **read-shaped** (convertible) and
**write** (must not be converted), reported beside the existing total.

On current `main`:

```
  QUERY filters (column: "<legacy>"): 63
    of those: 48 read-shaped (convertible), 5 writes (do NOT convert), 10 other
```

## Why the single number misleads

`column:` sits in an options-shaped object for both a source query and a
write, so the existing definition-vs-query rule cannot separate them.
The result reads as "dead reads to convert" — and after #2818 landed,
**48 of the 63 are `self-healing.ts` and the rest are largely not
convertible at all.**

Converting a write in this class is **harmful, not merely pointless**.
`async-persistence.ts` soft-deletes with `.set({ column: "archived",
deletedAt, … })`, and `getLiveTaskColumn` returns `"archived"` as a
**sentinel** for any soft-deleted row — the write and the sentinel have
to agree. A sweep that "finished the query class" by converting all 63
would break live-column resolution for every deleted task.

That is the same shape #2808 flagged for `recoveryRehome` moves. **Two
of the census-invisible classes now have members that must not be
fixed**, and in both cases the count alone cannot tell you which.

## Reported, not ratcheted

`properties.query` and `queryByFile` are byte-identical, so the pinned
baseline does not move and no open PR's Lint changes. The split is one
extra line of output.

**Changing what a ratchet enforces is the owner's call; improving what
it says is not.** Same line I drew when making marker-only failures
legible without loosening them.

## Honest limit

Read-shaped is a better filter than the raw count and **still not a
verdict**. `auto-merge-finalization.ts:242` is classified read-shaped
and must NOT be converted — its own comment records that it is
`getTaskHardMergeBlocker`'s review-eligible sentinel, deliberately not
re-keyed. Nothing mechanical would catch that; only the comment beside
it does. The split narrows a haystack to a readable list; it does not
decide the list.

## Verification

4 new cases — a `listTasks` filter counts read, a `.set()` tombstone
counts write, IR node definitions stay excluded from the class entirely
(the pre-existing rule must keep working), and the pinned total is
unchanged by the split. Revert proof: dropping the write branch fails
the tombstone case.

Census's own suites **87 passed**, gate **161 / 13 / 487 / 71**, lint
clean, `--strict` exits 0.

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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 14:29:01 -07:00
gsxdsm
f53c9dbd39 fix(core): merge re-enqueue threw on every board with a renamed review column (#2819)
The single most consequential finding from the u12 seam-gate work,
picked up because batch-core (#2783) merged without it and it is now
unowned.

## The defect

`enqueueMergeQueueInTransaction` gates on the task's column being a
review column, and takes the board's review columns as an optional
trailing argument.

- `moves.ts:487` and `moves.ts:1153` — the automatic handoff-to-review
path — resolve and pass them.
- The public `enqueueMergeQueue` wrapper
(`async-merge-coordination.ts:246`), reached through
`store.enqueueMergeQueue`, **did not**, so it fell back to `new
Set(["in-review"])`.

This is not the quiet legacy-id degradation most of these seams produce.
The reject branch records `mergeQueue:enqueue-rejected` and **throws**
`MergeQueueInvalidColumnError`. Its production callers are
`merger.ts:7251` and `self-healing.ts:10329` — so on any board whose
review lane is renamed, the merge and recovery re-enqueue paths failed
outright while the handoff path kept working.

## Measured, not asserted

With the fix reverted, the renamed case fails with the exact predicted
error and the controls stay green:

```
× renamed vocabulary: a task in the RENAMED review lane enqueues for merge
  MergeQueueInvalidColumnError: Task KB-001 is in column 'checking', not 'in-review'; cannot enqueue
✓ default vocabulary: a task in the review lane enqueues for merge
✓ renamed vocabulary: a task in the WIP lane is still REJECTED
✓ default vocabulary: a task in the WIP lane is still REJECTED
  Tests  1 failed | 3 passed (4)
```

With it: `Tests 4 passed (4)`.

## About the suite

**Differential.** The fixture is the builtin coding workflow with only
its column ids renamed, so the sole difference between the two runs is
vocabulary — a hand-built graph would test the fixture's own transition
table as much as the code. It asserts the rename actually landed
(`checking` present, `in-review` absent), so a surviving literal cannot
pass by luck, and it walks the graph rather than jumping, because moves
are transition-validated.

**Both negatives included.** A WIP-lane task must still be REJECTED
under each vocabulary. Supplying the real columns must not degrade into
"every column is a review column", which would let work merge straight
out of the WIP lane — the failure mode a careless version of this fix
would introduce.

## Why nothing caught it

Partial supply. Two of three call sites passed the argument, so a check
asking "does SOME caller supply this?" reported the seam as satisfied,
and the lifecycle-column census counted the conversion as done. Closing
that one-supplier floor in `scripts/check-inert-flag-seams.mjs` (on
#2772) is what surfaced it.

## Verification

- `pnpm test:gate` green
- new suite 4/4; neighbouring merge-queue suites (`taskstore-lifecycle`,
`store-in-review-stall`, `runtime-lifecycle-async`) 29/29
- `tsc -p packages/core` 0, lint 0
- changeset included (`@runfusion/fusion` patch)

Note: #2772 still carries a TEMPORARY per-call-site exemption for this
seam. Once this lands, that exemption's staleness check will fail and I
will remove it there — it cannot outlive the fix.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 14:26:11 -07:00
gsxdsm
4c5080c1cf fix(tests): main red — the IR fallback is a BRANDED COPY, so identity can't hold (#2836)
## Red on main

```
workflow-ir-resolver > resolveWorkflowIrForTask > falls back to the built-in default when the definition is missing
workflow-ir-resolver > resolveWorkflowIrById  > falls back to the canonical IR for an unknown built-in id

AssertionError: expected { version: 'v2', …(6), …(1) } to be { version: 'v2', …(6) }
  Received: serializes to the same string
  Compared values have no visual difference.
```

That message is the signature of an **identity-only** break, and that is
exactly what it is.

## Why identity can never hold again

Both asserted `toBe` against the exported builtin constant. **#2815**
added `markFellBack`:

```ts
function markFellBack(ir: WorkflowIr): WorkflowIr {
  const copy = { ...ir } as WorkflowIr;
  Object.defineProperty(copy, FELL_BACK_TO_DEFAULT, { value: true, enumerable: false, configurable: true });
  return copy;
}
```

It brands the fallback so a caller can tell a **resolved** workflow from
a **guessed** one — the provenance contract #2618 introduced. Copying
*is* the mechanism, so these two paths cannot return the shared object.

Worth noting: the sibling `toBe` assertions in the same file **still
pass**. The no-selection and throwing-selection paths return the
constant unbranded, so only the two `markFellBack` paths changed — which
is why this presents as two failures rather than five, and why it is a
genuine contract change rather than a blanket refactor.

## Not just loosening to `toEqual`

Swapping `toBe` → `toEqual` alone would delete a real assertion. The
**brand is asserted too**, via the public provenance API rather than by
reaching for the private symbol:

```ts
expect(ir).toEqual(BUILTIN_STEPWISE_FINAL_REVIEW_CODING_WORKFLOW_IR);
const provenance = await resolveWorkflowIrForTaskWithProvenance(store, "t1");
expect(provenance.source).toBe("default");
```

This is **stronger** than the identity check it replaces:

| mutation | result |
|---|---|
| fallback no longer branded | **fails** — `expected 'selection' to be
'default'` |
| fallback returns a different IR entirely | **fails** structurally |

The first is the case the old `toBe` could not articulate: an unbranded
fallback still equals the constant structurally, so a caller asking
*"was this actually resolved?"* would get **yes for a guess** —
precisely the lie #2815 exists to prevent.

Core **4792 passed / 0 failed** (was 2 failed) · gate **732 green** ·
lint clean. Test-only; the resolver is restored clean after the
mutations.

## How it was found

Pre-flighting **#2822, #2819, #2823 and #2818** merged-with-main. All
four reported the *same two* failures — the signature of an inherited
red rather than four independent regressions. Confirmed directly on
`origin/main`. Each of those four is otherwise green (engine 11003
passed on all of them); I have noted that on the PRs.

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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 14:20:18 -07:00
gsxdsm
2c24966d0c fleet: the app-side remainder 18 → 0 — Archive/Revert and diff stats were silently absent on a renamed board (#2731)
Three **genuinely free** clusters in one layer and one idiom —
`Column.tsx` (7), `ListView.tsx` (6), `useTaskDiffStats.ts` (5). I built
the claimed-file set from every open PR's diff before starting, having
duplicated a claimed cluster last round.

## Census

| file | before | after |
|---|---:|---:|
| `Column.tsx` | 7 | **0** |
| `ListView.tsx` | 6 | **0** |
| `useTaskDiffStats.ts` | 5 | **0** |

**16 converted; 2 reclassified with a reason** — the two are accounted
for separately below so the numbers stay honest.

## Three silent failures, not three style nits

- **`ListView` Archive and Revert** were gated on `task.column ===
"done"` / `=== "archived"`, so on a board with renamed terminal lanes
**they did not render at all**. No error, no log — the operator simply
cannot archive or revert from the list.
- **`useTaskDiffStats`** compared a bare `column: string` to
`done`/`in-progress`/`in-review`, so on a renamed board it **fetched
nothing** and the row showed no changes.
- **`ListView` progress display** had the same shape for the WIP lane.

## The `?? {}` is the whole subtlety

Every `Column.tsx` site was `workflowMode ? <trait> : column ===
"<id>"`. One adapter now feeds the shared helpers:

```ts
const columnRoleFlags = workflowMode ? (columnFlags ?? {}) : undefined;
```

`workflowMode` means **traits are the only authority**, so a
workflow-mode column with no resolved flags must answer `false` — which
`Boolean(columnFlags?.archived)` did. Passing `undefined` to a role
helper instead selects its **legacy id fallback**, so a flagless
workflow-mode column would start matching on its id. An empty object
keeps the helper on its trait branch. Legacy mode passes `undefined`
deliberately: there the id fallback *is* the answer, and routing it
through the helpers is the point.

## Two things I deliberately did not do

**`isTodoLikeColumn` keeps its own trait arm.** Adopting
`isPreImplementationColumnRole` would widen its fallback from `todo`
alone to `{todo, triage}`, handing a legacy `triage` column a bulk
replan affordance it does not have today — a behaviour change hiding
inside a de-duplication. Only its *fallback* is routed through a helper.

**The `mode === "done"` pair is reclassified, not converted.** It is the
hook's own `"done" | "active"` discriminant, assigned three lines from
`shouldFetchDoneTask` — not a column id, with no trait to resolve. The
census counts it because the receiver is compared to the string `done`,
which is a classifier limit. Marked deliberate and **recorded in
`deliberateByFile`**, so that file's `byFile` drop is 5 while its
conversion count is 3.

One genuine simplification fell out: `workflowMode ? isReviewColumn :
column === "in-review"`, where `isReviewColumn` is *itself* that same
ternary. Both arms already agreed with it — collapsing is
behaviour-identical.

## Revert proof

Restoring the id comparisons on the ListView row menu fails the new
renamed-lane case with `Unable to find an accessible element with the
role "menuitem" and name "Archive"`.

Driven through the **real `fetchBoardWorkflows` seam** with a renamed
vocabulary — payload → `listColumns` → `columnFlagsById` → row menu —
rather than by injecting flags, so the assertion covers the path the
component actually uses. The DEFAULT-vocabulary path passes either way,
which is exactly why the renamed case has to exist.

## Verification

`pnpm test:gate` **GREEN** (158 + 10 + 487 + 71) · **375 passed** across
Column / ListView / useTaskDiffStats / role-invariance / columnRoles ·
dashboard `tsc -p tsconfig.app.json` clean · `pnpm lint` clean · census
`--strict` exits 0.

`TaskCard.tsx` is touched only to pass the new optional `columnFlags`
through; its own census count is unchanged at 3. The 2 `TaskCard` reds
in that suite are the known pre-existing CSS-var geometry assertions.

🤖 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**
* Improved workflow lane handling when columns are renamed or assigned
roles through workflow settings.
* Archive and Revert actions now remain available for completed and
archived tasks in renamed lanes.
* Corrected task progress and diff-stat behavior across active, review,
completed, and archived lanes.
* Updated bulk actions, sorting controls, and auto-merge controls to
respond consistently to workflow roles.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 14:12:14 -07:00
gsxdsm
7bf3df9477 chore(core): delete the dead merge-blocker guard, and catch exemptions for deleted seams (#2830)
Closes the last of the five findings I reported to batch-core (#2783),
which merged without them. This one I held back twice on purpose; the
reason is now resolved.

## The dead code

`evaluateMergeBlockerGuard` had **exactly one reference in the repo: its
own declaration.** Never called, never re-exported from
`index.ts`/`index.gate.ts`, never registered as a trait hook. Its
`lifecycleColumns` conversion was applied to dead code, and the census
counted it as progress.

## Why I would not delete it earlier, and what changed

I twice declined this, because no production `"guard"` trait hook is
registered anywhere in the codebase — the only
`registerTraitHookImpl(..., "guard", ...)` is in a test. If a
registration had been dropped, that would be a real product bug and this
function would be its evidence, so deleting it would have destroyed the
breadcrumb.

**It is not missing.** Merge blocking is enforced inline in
`task-store/moves.ts` (~645 and ~821) via `getTaskMergeBlocker`, gated
on the **resolved trait flags** (`toFacts.flags.complete` +
`fromFacts.flags.mergeBlocker`) rather than on column ids — a better
implementation than the one being deleted. The logic moved; this
function, and a file-header line crediting a never-existent
`evaluateDefaultWorkflowGuards` reader, were left behind.

The header now records where the guard actually lives, so the next
person does not repeat the investigation I just did.

Also removed: `GuardVerdict` (its return type, used nowhere else) and
the orphaned `getTaskMergeBlocker` import — the latter caught by lint,
not by me.

## The gap this exposed

The allow-list staleness check only fired for a seam the scan still
**finds** — it asks *"is this site supplied now?"*. Delete the
declaration and the name is never iterated, so its entry sits in the
list forever, exempting nothing and misreporting what is tolerated.

I found this by deleting the function and watching the gate stay
**silent** about its leftover entry.

**Measured:** an `ALLOWED` entry naming a non-existent seam now fails
with `no such seam declared any more; remove its ALLOWED entry`;
removing the probe returns exit 0. The failure header is reworded, since
"the sites are supplied now" no longer covers both reasons.

This is the fourth blind spot closed in this check, and like the others
it was found by exercising the gate rather than reading it.

## Verification

`pnpm test:gate` green · `tsc -p packages/core` 0 · lint 0 · gate now
reports **20** seams (was 21 — the drop is this deletion).

No changeset: deleting unreachable internal code with no exported
surface is behaviour-preserving.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 14:09:10 -07:00
gsxdsm
3603da731f fix(core): duplicate markers were never cleared on a board with renamed terminal columns (#2823)
Third unowned finding picked up after batch-core (#2783) merged without
addressing its reports. Same class as #2819, opposite failure direction.

## The defect

`clearNearDuplicateReferencesTo` runs on every complete/archive/delete
of a canonical task and clears the `nearDuplicateOf` markers pointing at
it. It first asks `isNearDuplicateCanonicalInactive` whether the
canonical really is finished — a safety check, so a live canonical's
markers are not cleared out from under an operator.

That call omitted the canonical's resolved column flags, so it fell back
to the legacy `done`/`archived` ids. On a renamed board a just-completed
canonical (`shipped`, `filed`) read as **still active**, the guard
early-returned, and the markers were **never cleared**. The flagged
duplicates stayed parked behind a user decision that could never arrive
— the exact stranding the predicate's own FNXC note says it was written
to prevent.

## I had the direction backwards, and it matters

My first report of this seam described it as *markers cleared against a
live canonical*. That is wrong. The legacy fallback errs toward "still
active", so the failure is the opposite: markers that never clear at
all. Same seam, same missing argument, entirely different symptom to
look for — which is why the direction is worth pinning in a test rather
than reasoning about.

## Measured

With the fix reverted, exactly one case flips:

```
✓ default vocabulary: completing the canonical clears the duplicate's marker
× renamed vocabulary: completing the canonical clears the duplicate's marker
✓ renamed vocabulary: a canonical still in the WIP lane does NOT clear the marker
✓ default vocabulary: a canonical still in the WIP lane does NOT clear the marker
✓ a soft-deleted canonical clears the marker under a renamed board
  Tests  1 failed | 4 passed (5)
```

With it: `Tests 5 passed (5)`.

## Both negatives included

A canonical still in the WIP lane must **not** clear its duplicates'
markers, under each vocabulary. Resolving the real flags must not
degrade into "every column is terminal", which would clear markers out
from under an operator who has not made the duplicate decision yet. The
soft-deleted path is covered too, since that branch never consults
column flags at all.

## A fixture trap worth keeping

`sourceMetadata` is `jsonb` and must be seeded as an **object**. Seeding
a stringified value reads back fine through `getTask` — it parses either
shape — while the production query's
`source_metadata->>'nearDuplicateOf'` matches nothing. The fixture looks
correctly seeded and the code under test can never find the row. My
first version had this, and the self-check in the seed helper is what
caught it.

## Scope

Five of this predicate's six production call sites already resolved
flags. This was the sixth, and the one that runs on every
archive/complete transition.

Not touched here: the same function's SQL predicate excludes duplicates
by literal `ne(column, "archived")` / `ne(column, "done")`. That is a
per-row question across many rows in one statement, not a one-line
supply, so it is flagged rather than guessed at.

The five **engine** call sites of this predicate are separately reported
on #2785 and surfaced only via #2822.

## Verification

`pnpm test:gate` green, new suite 5/5, `tsc -p packages/core` 0, lint 0,
changeset included.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 14:03:29 -07:00
gsxdsm
a6af3188df fix(core): startup recovery deadlocked on its own per-task lock (#2809)
## The bug

`recoverStaleTransitionPendingImpl` runs its whole per-task body inside
`store.withTaskLock(id, …)`. On the PostgreSQL arm it then read the task
with `store.getTask(id)` — and `getTaskImpl` opens with
`store.withTaskLock(id, …)` too.

**The per-task lock is non-reentrant.** This codebase states that
invariant in prose in two other files:

> "nesting inside `withTaskLock` would deadlock since the lock is
non-reentrant" — `branch-and-pr-entities.ts:561`
> "because the per-task lock is non-reentrant" — `workflow-ops.ts:464`

So the sweep waited forever on a lock its own frame was holding.

**PostgreSQL-only — which is every production install.** The SQLite arm
on the very next line reads through `readTaskFromDb`, a lock-free row
read. The backend-mode port swapped only the PostgreSQL arm to
`getTask`. The fix restores a lock-free read (`readTaskRow`) on that
arm; nothing else changes.

## Why it survived until now

The branch is entered **only** when a stale marker names a plugin hook
the trait registry still knows (`hasSurvivingPluginHook`). Three nearby
cases all miss it:

| marker | path |
|---|---|
| none | the row is never scanned |
| only `default-workflow:postCommit` | `hasSurvivingPluginHook` false —
marker just cleared |
| names an **uninstalled** plugin hook | reconciled away as degraded;
nothing survives to re-run |
| names a **registered** plugin hook | **reaches the in-lock read →
deadlock** |

Those first three are what the existing tests cover. The fourth is
precisely the state a crash mid-hook leaves behind. All four are
asserted in the new suite so the path cannot be re-narrowed and called
covered.

## Impact

This sweep runs at **startup**. A task left with such a marker deadlocks
startup recovery — and because it deadlocks *while holding the task's
lock*, that task is also left permanently unlockable.

## How it was found, including a correction

By **bisection**, not by reading. An earlier attempt of mine to drive
this recovery reported that "the sweep never returns". That was wrong in
a way worth recording: the sweep returns fine in three of the four
cases, and generalising the one hang to the whole function is what hid
the actual trigger across several sessions. Narrowing case by case —
empty store, plain task, default-only marker, unknown-hook marker,
registered-hook marker — put the fault on one line.

## Verification

- **Mutation-verified against the real defect.** With the fix reverted,
the regression case fails by name — `recoverStaleTransitionPendingImpl
did not settle within 8000ms — deadlock` — while the other three stay
green. That is the actual pre-fix behaviour, not a simulation of it.
- Every case is **timeboxed** on purpose: a deadlock otherwise surfaces
as a suite-level timeout naming no case, which is useless for locating
the fault. The deadline is not a flake knob — the fixed code settles in
~150 ms and the broken code never settles, so there is no value in
between to tune.
- A **vacuity guard** (no markers → scans nothing) so a change that
stopped listing marked rows can't leave the other cases green.
- `pnpm test:gate` — **exit 0**
- full live-PG E2E surface — **152/152**
- `pnpm lint` — clean

Changeset included (`patch`, category `fix`).

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 13:58:02 -07:00
gsxdsm
109204c590 fix: the query class — three sweeps that never ran on a renamed board (#2818)
Three sweeps that **never ran at all** on a renamed board, plus the
shared answer the rest of the class needs. Consolidated from three
handoff branches so the helper appears once. #2811 merged, so this is my
only open PR.

`#2800` measured this class and shipped evidence deliberately without
conversions: `listTasks({ column: "<literal>" })` filters in the store,
so on a renamed board the read returns an **empty array** and the sweep
it feeds does nothing. The census scores the comparison *inside* the
loop, never the query above it.

## What was broken

| file | census count | what actually happened on a renamed board |
|---|---|---|
| `backlog-pressure-reporter.ts` | **0** | both reads empty, ratio
computed as 0/0 — **the alert never fired**, on a board that may be
under exactly the pressure it reports |
| `stale-task-reporter.ts` | **0** | both reads empty — **no stale-task
signal ever raised**, where work is most likely sitting unnoticed |
| `restart-recovery-coordinator.ts` | flagged | sweep never ran — **an
engine restart left interrupted tasks stuck with no requeue** |

Two of the three have a census count of **zero**. They contain no
lifecycle comparison at all, so they have never appeared in the backlog,
in a per-file list, or in any "N → 0" claim — and were completely inert.
**A file at zero is not evidence of anything.**

## The shared answer, and what it is not

Every existing resolver answers a **per-task** question. A query has no
task in hand, so it needs the project-level one: every column any
workflow declares for a role, unioned with the legacy ids so a board
mid-rename still finds rows under the old ones. The set is never empty,
so a caller cannot accidentally query nothing.

The header states what it is **not**: answering a per-card question from
the union would mark a card as review because some *other* workflow
calls its column review — the flat-set mistake this program has made
four times.

## The finding that generalises: the query is rarely the whole defect

`stale-task-reporter` **still reported zero after the query was fixed**
— `getTaskAgeStalenessSignal` defaults to the legacy pair, so a card the
query now returned was refused inside the signal. Converting only the
query would have looked like a fix and changed nothing.

That is a caveat on #2800's approach, offered as refinement rather than
correction: **asserting the query ARGUMENT is right when pinning a known
defect** (the outcome is 0 either way) **and insufficient when proving a
fix**, because the outcome is the only thing that distinguishes a real
conversion from a deeper one. All three conversions here assert
outcomes.

`restart-recovery` had three layers — query, a redundant re-assertion
(deleted; a test pins the `paused` guard it did contribute), and a move
destination that was **already** resolved but whose warning comment was
stale. A stale warning is its own hazard: it told the next reader a
defect existed where none did.

## Verification

- helper **8 passed** · three reporter/coordinator suites **29 passed**
- `pnpm test:gate` **161 / 13 / 487 / 71** · lint clean · `--strict`
exits 0 · four `tsc` targets clean
- each conversion revert-proven independently; the failing case is named
in each test header

## Two mistakes worth recording

**The helper's own test caught a bug in it.** My first draft wrapped the
definition loop in one `try`, and `parseWorkflowIr` **validates** rather
than parses — one malformed row would have returned legacy-only lanes
for *every* workflow, indistinguishable from the bug it exists to fix.
Now isolated per definition.

**I clobbered the core barrel** by taking `index.ts` wholesale from a
handoff branch, dropping two exports `main` had added since; three
packages stopped compiling. Taking a file from another branch takes its
whole contents, including what is now stale — for a barrel that is
nearly always wrong. Re-applied as a single edit on top of `main`.

## Not included

`self-healing.ts`'s 49 — actively owned and mid-conversion; an outside
refactor there produces conflicting halves of one sweep.
`project-engine.ts` (7) and `executor.ts` (2) need their own read of
what each sweep does with the rows, which these three are the argument
for.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 13:55:10 -07:00
gsxdsm
1824c04584 fix(core): restore an archived card to the lane it came from (#2832)
## What

Fixes the defect #2824 measured. That PR's characterization cases —
merged and asserting the wrong-but-real behaviour — are flipped here to
the correct lanes, which is what they were written to do.

## The bug

```ts
// archive-lifecycle-2.ts
const preArchiveColumn = task.preArchiveColumn ?? "todo";
```

**`preArchiveColumn` has no database column.** It exists on the `Task`
type and in the archive snapshot, and nowhere else — so the in-place
restore cannot carry it, `store.getTask(id)` reads a live row that never
had it, and the literal decided the destination for **every unarchive
that has ever run**.

| board | what happened |
|---|---|
| **default** | `todo` is declared, so the resolver returned it.
Restores landed in the queue and **looked right**. |
| **custom** | `todo` is declared nowhere, so the resolver took its "no
usable history" branch and returned the **complete** lane. A card
archived mid-implementation came back marked **finished**. |

That coincidence is why this survived **three** separate fixes to
`resolveUnarchiveTargetColumnImpl` — a `?? "done"` that invented a
column, an `isColumn` legacy-enum gate, and the same gate one function
over. Every one was correcting how the resolver interprets a value that
never arrived.

## The fix is two halves, and either alone does nothing

1. **Capture** — `taskToArchiveEntryImpl` records `task.column` into the
snapshot. That is the last place the original is still in hand, since
the entry's own `column` is set to `"archived"` on the line above.
2. **Read** — `unarchiveTaskImpl` reads the **snapshot it already
loaded**, not the restored row.

I shipped half of this first and watched the destination stay wrong,
which is how I found that the field has no row to live on. Mutation
matrix:

| state | result |
|---|---|
| both halves | **5/5 pass** |
| capture only (read reverted) | **3 fail** |
| read only (capture reverted) | **3 fail** |

I also tried carrying it through `restoreTaskFromArchive`'s row update —
that fails to typecheck, which is the proof that no such column exists
and the snapshot is the only source.

## Behaviour changes, deliberately

- **Custom boards** — a card returns to the lane it was archived from
instead of appearing finished.
- **Default board** — a card archived from `done` restored to `todo`
under the literal and now restores to `done`. Returning finished work to
the queue was the fallback showing through, not a rule anyone chose; the
resolver's own branches say a card archived from a declared column goes
back to it.

## One expectation of mine was wrong, and the resolver was right

I expected a card archived from the review lane to return to **hold**.
It returns to the review lane, and that is correct: `.review` is derived
from the `mergeOrchestration` flag, **not** from `human-review`. The
fixture's review column declares `human-review` + `merge-blocker` only,
so it is not a `.review` lane to the resolver — just a declared column
with usable history. The case now asserts that with the reasoning
attached, so the next reader does not "fix" it back to hold.

## Verification

- unarchive suite — **5/5**, mutation matrix above
- `pnpm test:gate` — **exit 0**
- full live-PG E2E surface — **164/164**
- `pnpm lint` — clean

Changeset included (`patch`, category `fix`).

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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 13:54:53 -07:00
gsxdsm
9f61180dfc test(dashboard): restore 8 of 10 red App tests — the fixture went stale at the U12 flag cutover (#2833)
Fixes most of #2829. `App.test.tsx` has been red on `main` since
2026-07-25 — deterministically, on every commit since, identically with
and without any batch branch applied.

## Root cause

`ListView` early-returns a workflow skeleton when the board has no
workflows:

```ts
if (boardWorkflows === null || boardWorkflows.workflows.length === 0) {
  return renderListWorkflowSkeleton(boardWorkflows !== null);
}
```

That skeleton carries the **same `list-view` class** as the real body.
So every test that waits on `.list-view` and then reaches for a control
inside it passed its `waitFor` and failed on the control — presenting a
DOM that looks like a perfectly healthy list. That is why this read as
"the board renders nothing" and then as an i18n problem; both were
wrong.

The probe that settled it:

```
cluster: false | listview: true | buttons: []
```

The fixture returned `workflows: []` with `flagEnabled: false`. That
**used to be correct** — the flag-off path rendered legacy columns and
needed no workflow. **U12 deleted that path**, so an empty `workflows`
array now means "this board has no lanes". The fixture went stale, not
the product.

The mock now mirrors the builtin coding lanes with the trait flags the
board actually reads.

## Measured

| | before | after |
|---|---|---|
| App.test.tsx | 10 failed / 131 passed | **2 failed / 139 passed**
(141) |

`tsc` 0 · lint 0 · rest of the `dashboard-app-quality-app` project
unaffected.

## Two remain — different root cause, deliberately not chased here

- **`opens the NewTaskModal from the list view new-task button`** now
gets *past* the button (that was the skeleton bug) and fails on
`role="heading"` name `"New Task"`. The heading exists as
`<h3>{t("newTaskModal.title", "New Task")}</h3>`, so the modal is not
rendering at all — plausibly FN-8620's FloatingWindow rework,
**unverified**.
- **`keeps the 'subtask' background-session route unchanged`** —
uncharacterised.

Two of my earlier root-cause guesses on this file were wrong, so I am
not offering a third. #2829 stays open for these two with the evidence
attached.

## Not quarantined

`test-quarantine.json` is for flakes. This failed deterministically, and
quarantining would have started a 14-day deletion clock on 141 tests
covering deep-link handling, view switching, board branch filters, and
the FN-5817 mobile auto-merge shell — while hiding a fixture that had
silently stopped exercising the list view at all.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 13:42:46 -07:00
gsxdsm
654d28c104 test(engine): live-PG coverage for the timing role — and it is correct (#2828)
## What

One new live-PostgreSQL E2E suite, 2 tests. **No production file is
touched** — evidence, per the E2E worker's remit.


`packages/engine/src/__tests__/workflow-timing-trait-live-e2e.pg.test.ts`

## This one is a clean bill of health

Coverage for the last lifecycle role that had none — and the seam turns
out to be **correct**. Recorded deliberately: a working conversion with
no test is one refactor away from a silent regression, and this program
should be able to say which seams are right, not only which are broken.

`cumulativeActiveMs` accrues while a card sits in the WIP lane, and both
halves of the segment boundary resolve that lane by role:

```ts
const isWip = (column) =>
  inRole(column, ctx.lifecycleColumnSets?.wip, ctx.lifecycleColumns?.wip, "in-progress");
```

Note the literal at the end. That is the **same optional-parameter
shape** this series found broken at four other seams —
`shouldHoldActiveFileScopeLease` (#2795), `evaluateParkedAgentTaskLink`
(#2798), `resolvePlanningContinuationCandidate` (#2799),
`hasAutoHealableVerificationBufferFailure` (#2802) — where a caller
failed to supply the resolved answer and the literal silently took over.
Here the move path **does** supply it. Measured on a live store:

```
default: after exit cumulativeActiveMs=84
renamed: after exit cumulativeActiveMs=75      <- accrues on `building`, not just `in-progress`
```

## What it guards

If the move path ever stops populating the resolved columns, the literal
takes over and a renamed board's cards accrue **exactly zero** active
time — silently, because zero is a legitimate value for a card that has
not run. The operator sees no error, just wrong numbers on every custom
board.

**Mutation-verified against precisely that regression:** blinding the
resolved answer (`inRole(column, undefined, undefined, "in-progress")`)
fails the renamed case and leaves the control green. This is a guard
with demonstrated power, not a test that happens to pass.

## Both boundaries are asserted

`cumulativeActiveMs` is `0` while the card is still in WIP and only
accrues when it leaves; `firstExecutionAt` is stamped on entry. A test
that checked only the final number would pass against an implementation
that accrued at the wrong boundary, so both are pinned.

## Verification

- new suite — **2/2 passed**, mutation-verified
- full live-PG E2E surface — **161/161**
- `pnpm lint` — clean

Lane: `.pg.test.ts`, skipped via `pgDescribe` when no PostgreSQL is
reachable, so the merge gate is unaffected. Throwaway per-file database;
never port 4040.

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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 13:36:22 -07:00
gsxdsm
d56c635c77 test(dashboard): cover the three lane resolvers nobody was testing (#2826)
## Why this exists

#2821's review found a bug that lived entirely in a lane **builder**
while every test drove the **guard** that consumed it. That is a
structural blind spot, not a one-off: injecting a resolved value into a
synchronous guard makes the guard testable and the resolver invisible.

So I audited every lane helper I added this session. Three had **no
direct coverage at all** — `archivedColumnsForTask`,
`wipColumnsForTask`, `preWipColumnsForTask`. Their callers were tested;
the functions were not.

## They shared the defect that review named

Each read `resolved.length > 0 ? resolved : legacyId`, which conflates
two different boards:

- a **v1 upgrade** — `synthesizeDefaultColumns` emits `traits: []` on
every column, so the legacy id is the only vocabulary that exists, and
falling back is correct;
- a **v2 board that expresses traits** and declares no lane of that role
— where the legacy id names a column the board may still *have* and
deliberately did not give the role. Falling back there widens the guard
onto a role the board explicitly withheld.

`declaresAnyLifecycleTrait` separates them, matching the shape #2821's
review established for `resolveNodeOverrideLanes`.

## The fixture trap, which is the part worth reading

**My first fixture could not see the bug.** It traited the role under
test — and where the role *is* traited, the two shapes agree: both
return the traited lane. Mutating a helper back to the old shape left
all 15 cases green.

The shapes diverge only when the resolved set is **empty while traits
are expressed**. Each helper now has that case explicitly, with a
fixture that traits something *other* than the role under test.

**Mutation-verified per helper:** all three reverted independently now
fail. Before the extra case, none did.

This is the second time this session a fixture built with the production
path normalised away the very thing under test. Worth stating as a rule:
a renamed-lane fixture proves the resolver reads traits; only a
*traits-expressed-but-role-absent* fixture proves what it does when the
answer is legitimately nothing.

## Verification

- `task-lifecycle-lanes.test.ts` → 18 passed (was 15, none covering
these three)
- consumer suites (`github-issue-comment`, `planning-board-tools`,
`register-git-github.review-lanes`) → 64 passed together
- `pnpm test:gate` → 161 + 487 + 13 + 71
- `--strict` → 0; `tsc --noEmit` and `pnpm lint` → 0 errors

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 13:18:08 -07:00
gsxdsm
e5c9ea3870 fix(core): resolve the task's own terminal node in the node-override guard (#2812)
## What

Fixes the defect **#2793 measured** but deliberately did not fix. That
PR's two characterization tests are flipped here to assert the correct
behaviour — which is what they were written to do.

## The bug

`updateTask({ nodeId })` passes through `validateNodeOverrideChange`
**twice**, and both calls were wrong in different ways:

| call | how it answered "is this terminal?" |
|---|---|
| `branch-and-pr-entities.ts:568` | `resolveTaskWorkflowIrSync` — the
**default** workflow for every task under PostgreSQL |
| `task-update.ts:53` | no options at all → `defaultIsTerminalNodeId`,
the bare literal `nodeId === "end"` |

On a board whose terminal node is not named `end`, the FN-7641 guard
inverted in both directions:

- an override to a **non-terminal** node that happens to be named `end`
was **rejected** with a merge-proof error about finalizing a card the
operator was not finalizing;
- an override to the board's **real** terminal node was **written
verbatim**, no error, card unadvanced — the silent no-op FN-7641 exists
to prevent.

## The fix

A new `isTaskTerminalNodeIdAsync` resolves the task's own workflow, with
the **identical** literal fail-soft for an unresolvable one. Both call
sites use it — pre-resolved, because `validateNodeOverrideChange`'s
callback is synchronous and it asks the question at most once.

**Nothing forced the sync call at either site**: both frames are already
`async` and already awaiting. That is the same finding as #2809's
review, one file over.

The sync helper is **deleted, not kept as a fallback**. Keeping both
would re-create the half-conversion this program keeps finding — one
caller resolved, one not, and no way to tell from a call site which it
got. `branch-and-pr-entities.ts` also leaves the sync-resolver call-site
allow-list (ratchet green, 3/3), the **second** of the six allow-listed
sites to close.

## Why both halves were needed — and how that is proven

#2793's mutation matrix showed the rejected-`end` case is
**over-determined**: both guards independently called it terminal, so
correcting either one alone changed nothing an operator could see. That
is why fixing only the allow-listed sync site would have looked like
progress and delivered none.

Re-measured here, on the fixed tree:

| state | result |
|---|---|
| both guards fixed | **3/3 pass** |
| inner guard reverted to no-options | **1 fails** |
| outer guard reverted to the literal | **1 fails** |

## Tests

#2793's two cases now assert the fixed behaviour and keep their
reasoning:

- the non-terminal `end` override is **written**, and the card stays in
review — a routing change, not a finalize;
- the real terminal `finish` override is **refused** without merge
proof, **and** the field is not written on the way to refusing.

The fixture-integrity case (`finish` is the end node, `end` is not) is
unchanged — it is what stops both assertions passing for the wrong
reason.

## Verification

- terminal-node suite — **3/3**, both-halves matrix above
- sync-resolver call-site allow-list ratchet — **3/3**
- `pnpm test:gate` — **exit 0**
- full live-PG E2E surface — **151/151**
- `pnpm lint` — clean

Changeset included (`patch`, category `fix`).

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 13:15:04 -07:00
gsxdsm
7c30b54255 test(engine): live-PG evidence that restore lands every custom-board card in DONE (#2824)
## What

One new live-PostgreSQL E2E suite, 5 tests. **No production file is
touched** — evidence, per the E2E worker's remit.


`packages/engine/src/__tests__/workflow-unarchive-target-live-e2e.pg.test.ts`

## The finding

I went looking for coverage of the one lifecycle role that had none —
`archived` — in the function with the worst track record in this
program. `resolveUnarchiveTargetColumnImpl`'s own comments record
**three** separate defects fixed on those few lines: a `?? "done"` that
invented an undeclared column, an `isColumn` legacy-enum gate that
rejected every renamed id, and the same gate one function over that
dropped a renamed board's stored history on read. All three were
reasoned from source. **None had a live-store test.**

With one, the path is still broken — and the cause is upstream of
everything those fixes touched:

```ts
// archive-lifecycle-2.ts:441
const preArchiveColumn = task.preArchiveColumn ?? "todo";
```

**`preArchiveColumn` is never written.** Across `packages/core`, every
occurrence *reads* it or copies it through — into the archive entry,
back out of it, through serialization. Nothing ever sets it from
`task.column` when a card is archived. Measured on both boards:

```
default: after archive column=archived preArchiveColumn=undefined  -> resolver target=todo
renamed: after archive column=archived preArchiveColumn=undefined  -> resolver target=shipped
```

So the fallback fires for **every restore that has ever happened**, and
the two boards diverge on what `"todo"` means to each:

| board | what happens |
|---|---|
| **default** | `todo` is declared, so the resolver returns it. Every
restore lands in the queue — right for a card archived
mid-implementation, wrong for one archived from `done`, and it **looks**
right. |
| **renamed** | `todo` is declared nowhere, so the resolver takes its
"no usable history" branch and returns the **complete** lane. Archive a
card mid-implementation, restore it, and it comes back marked
**finished**. |

That coincidence on the default board is why this survived three rounds
of fixes to the resolver: they were correcting how it interprets a value
that never arrives.

## Evidence discipline

- **Observed state** — the persisted `column` after a real `archiveTask`
+ `unarchiveTask` against a real store and real stored workflows.
- **Characterization**: the cases assert today's wrong-but-real
behaviour so the defect is executable rather than argued, and they are
written to flip when the write is added.
- **The default-board control** is what shows the coincidence, not just
the failure.
- **Mutation-verified**: changing the hardcoded fallback from `"todo"`
to the renamed hold id fails **all five** cases — every assertion binds
to that literal, which is the claim.

## The fix, proposed rather than included

One line: persist the card's column when archiving, so
`preArchiveColumn` carries real history and the resolver — already
correct — can use it.

I did not include it because **it also changes default-board
behaviour**: a card archived from `done` currently restores to `todo`
(the fallback), and with real history it would restore to `done`. That
is the better outcome, but it is a visible placement change for every
existing install, and that decision belongs to whoever owns archive
semantics rather than to an evidence PR. The five cases above are the
acceptance criteria for it — the three renamed expectations become the
lane the card was actually in.

## Verification

- new suite — **5/5 passed**, mutation-verified
- full live-PG E2E surface — **159/159**
- `pnpm lint` — clean

Lane: `.pg.test.ts`, skipped via `pgDescribe` when no PostgreSQL is
reachable, so the merge gate is unaffected. Throwaway per-file database;
never port 4040.

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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 13:14:47 -07:00
gsxdsm
9a5155c71a fix(tests): main red — a census source guard that a COMMENT could invert (#2825)
## Red on main

```
lifecycle-column-census > the baseline can always be re-recorded
  > writes the baseline BEFORE the rise check can exit
AssertionError: expected 19345 to be greater than 26374
```

Read literally, that says the CLI now runs its rise check *before* the
`--update-baseline` write — which would break the one command whose
entire job is re-recording, and would be a genuine bug worth stopping
for.

**It does not.** The order in code is correct and unchanged:

| | line |
|---|---|
| `if (updateBaseline) { … writeBaseline() … process.exit(0)` |
`scripts/lifecycle-column-census.mjs:487` |
| `"column-guard count ROSE"` + `process.exit(1)` | `:510` |

## What actually moved was a comment

Line **359** explains this exact failure mode and quotes the marker
verbatim:

> …`"column-guard count ROSE"`, which is the opposite of what happened
and sends the reader looking for…

So `cli.indexOf("column-guard count ROSE")` found the **prose**, 7000
characters before the branch it was meant to locate.

A guard that a comment can invert is not measuring control flow. And the
honest-looking fix — reword the comment — silently re-arms the same trap
for whoever explains this next.

`cliSource()` now strips comments before indexing. The same defence is
already used by `archived-column-gate-parity.test.ts`, for the same
reason: notes documenting *why* a literal is dangerous have to mention
the literal.

## Kept, not deleted

The end-to-end block below these does cover the contract — it drives the
real CLI and asserts exit code, baseline content and printed output, and
its own comment names the ordering bug. It would have been defensible to
delete the two source-text cases as redundant.

I kept them because two guards at different levels is the point: **e2e
proves the behaviour, these locate the branch that provides it.** They
only needed to stop being defeated by prose.

## Evidence

The real ordering bug — make a rise exit before the update branch writes
— fires **all three**:

| guard | failure |
|---|---|
| source order | `expected 9454 to be greater than 9505` |
| slice / uniqueness | `expected 10433 to be -1` |
| end-to-end | `expected 1 to be +0` (exit code) |

**My first mutation attempt was invalid** and I nearly reported it as
evidence: it moved the block by line range, mangled the file, and both
markers disappeared — the resulting `-1`s look like a firing guard but
prove nothing. A mutation that corrupts its target is not evidence that
a guard works.

Engine **10991 passed / 0 failed** · gate **732 green** · lint clean.
Test-only; the CLI is restored clean.

## Note on duplicated effort

#2811 and my #2814 both re-recorded the census baseline for #2783's
rise, concurrently. No harm done — but this file is now a fleet-wide
contention point, and the per-file baseline shape exists precisely to
avoid that. Worth one owner for census/ratchet fixes rather than whoever
notices first.

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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 13:14:36 -07:00
gsxdsm
ed83fd6ec3 batch-core: node-override guards let a running task be re-routed on a renamed board (45 → 43) (#2821)
## The defect

Two guards in `node-override-guard.ts` answered a **role** question with
a **column name**:

- **`task.column === "in-progress"`** refuses changing a task's node
override mid-flight. On a renamed board it never matched, so an operator
could re-route a **running** task — precisely what the guard exists to
prevent, and the failure is silent because the guard simply returns
`allowed: true`.
- **`task.column !== "done"`** gates overriding *to* the terminal node.
On a renamed board it never matched either, so the override was refused
for exactly the tasks that had legitimately reached the end node.

Both fail in the direction that looks like normal behaviour rather than
an error.

## Why the lanes are injected rather than resolved in place

`validateNodeOverrideChange` is **synchronous by design**, and its
existing `isTerminalNodeId` option already establishes the pattern:
callers with cheap IR access inject, callers without keep a documented
literal fallback.

**Both production callers now supply the lanes** —
`branch-and-pr-entities.ts:594` (which already injected
`isTerminalNodeId`) and `task-update.ts:53`. That was the deciding
factor: an optional parameter that only tests fill is the
inert-injection shape this program keeps finding, where a guard reads as
converted, its test passes because the test injects the value, and
production keeps the literal. I checked both call sites had a store in
scope *before* adding the option.

`resolveNodeOverrideLanes` lives beside the guard rather than in the
callers, so the two cannot drift about what "executing" and "completed"
mean.

## Fallbacks

A workflow expressing **no trait on any column** is a v1 upgrade —
`synthesizeDefaultColumns` emits `traits: []` everywhere — not a board
without these roles, so it keeps the legacy ids. Same for an
unresolvable workflow. Both preserve exactly the behaviour the literals
already had.

## Verification

- **Mutation-verified per guard:** restoring `task.column ===
"in-progress"` fails a case; restoring `task.column !== "done"` fails a
different one.
- The suite also pins the paired negative — resolving lanes must not
turn the guard into a blanket refusal for a task outside every WIP lane.
- `node-override-guard.test.ts` → 27 passed
- `pnpm test:gate` → 161 + 487 + 13 + 71
- `--strict` → exit 0; `tsc --noEmit` and `pnpm lint` → 0 errors

Census: batch-core scope **45 → 43**; repo total **255**.

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

## Summary by CodeRabbit

- **Bug Fixes**
- Node overrides now correctly recognize workflow-defined in-progress
and completed lanes, including renamed columns.
- Override validation falls back safely for legacy or unresolved
workflows.
- Prevented validation from using stale task-column information during
updates.

- **Tests**
- Added coverage for workflow lane resolution, legacy fallbacks, renamed
lanes, and override eligibility.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 13:08:25 -07:00
gsxdsm
8c79da364c fix(engine): FN-8356's duplicate-marker cleanup was inert on a renamed board (5 call sites) (#2827)
Fourth unowned finding picked up after both batch PRs (#2783 core, #2785
engine) merged without addressing their reports. Completes the
`isNearDuplicateCanonicalInactive` seam alongside #2823, which fixed the
sixth (core) site.

## The defect

All five engine call sites — `self-healing.ts` ×2, `triage.ts` ×3 —
called the predicate without the canonical's resolved column flags, so
it fell back to the legacy `done`/`archived` ids. A canonical resting in
a renamed complete column (`shipped`) read as **still active**, so every
*"the canonical is inactive, so clear the marker"* branch failed to
fire.

The user-visible result is precisely the stranding FN-8356 was written
to remove: a card keeps its **"Needs your decision"** duplicate badge
pointing at work that shipped days ago, and no decision can resolve it —
the detail banner deliberately offers none for an inactive canonical.

## Measured

With the fix reverted, exactly one case flips:

```
✓ clears the FN-8353-shaped hidden decision for every inactive canonical state
× renamed vocabulary: clears the decision for a canonical resting in a RENAMED complete column
✓ renamed vocabulary: leaves the decision alone while the canonical is still in the WIP lane
✓ leaves active canonical decisions, user pauses, unrelated reasons, non-marker sources untouched
  Tests  1 failed | 3 passed (4)
```

With it: `Tests 4 passed (4)`.

## Wiring is proven separately from behaviour

A behaviour test on one call site says nothing about the other four —
that is the failure this whole lane keeps re-finding, so I did not rely
on it.

With #2822's barrel-import fix applied locally, the seam gate's
staleness check **fails both engine allow-list entries as "now
supplied"** — it can no longer find an omitting call site in either
file. That is the proof for all five.

Consequence worth flagging: **when the second of #2822 / this PR lands,
the two engine entries in #2822 must be deleted.** CI fails until they
are, by design — the exemption cannot outlive its fix.

## Both negatives included

A canonical still in the WIP lane must **not** have its decision
cleared, under each vocabulary. Resolving real flags must not degrade
into "every column is terminal", which would dismiss a duplicate
decision the operator has not made yet.

## Design note

The helper is module-private in each file rather than shared.
`findColumn` is already duplicated exactly this way in
`hold-release.ts`, `merge-trait.ts`, and `workflow-capacity.ts` —
following the established shape beat adding a cross-module abstraction
for a bug fix.

## Verification

`pnpm test:gate` green · 27 triage suites / 387 tests green · engine
`tsc` 0 · lint 0 · changeset included.

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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 13:04:58 -07:00
gsxdsm
bcc8bf17c1 fix(core): brand the unregistered built-in workflow fallback (#2815)
> **Corrected after opening.** The first version of this PR claimed the
id cross-check reported `"default"` for *every* authored workflow and
stranded cards in triage recovery forever. That was wrong, and the
correction is below in full. The reachable defect is the branding hole;
removing the id check is correctness, not a live bug fix.

## The bug (reachable)

`resolveWorkflowIrById` has four ways to substitute the default coding
IR. Three brand the result via `markFellBack`. The fourth did not:

```ts
if (isBuiltinWorkflowId(workflowId)) {
  const builtin = getBuiltinWorkflow(workflowId);
  const ir = builtin?.ir ?? defaultCodingWorkflowIr();   // <- unmarked substitution
```

An id that *looks* built-in but is not registered — a workflow removed
between releases, a typo'd selection — lands there and silently gets the
default coding IR. `resolveWorkflowIrForTaskWithProvenance` then reports
**`source: "selection"`** for it, handing a caller the default board's
graph under the selected workflow's name. That is precisely the lying
signal the API exists to prevent.

Found by review on this PR (thanks — see the thread), not by me.

## The correction to my own claim

I also deleted an id cross-check that ran after the marker check and
reported `"default"` when the resolved IR's `id` differed from the
requested workflow id. I justified that by saying it misfired for every
authored workflow, because `createWorkflowDefinition` stores an IR
verbatim while minting `WF-NNN` separately:

```
store workflow id = WF-001   stored ir.id = custom:prov
PROVENANCE source = default  resolved ir.id = custom:prov      <- the CORRECT IR, called a guess
```

**That measurement is real but it came from this suite's own fixture.**
Neither `WorkflowIrV1` nor `WorkflowIrV2` declares an `id` field. An
editor-authored workflow carries none, so `resolvedId` is `undefined`
and the check passed it as `"selection"` — the misfire never reached
`triage.ts`'s post-U11 intake recovery, the one production consumer. My
"declined forever, not deferred" claim does not hold.

Removing the check is still right: it is **unreliable** (it interrogates
a property the IR types do not declare, and when one is present it is
the author's id, unrelated to the store-minted row id) and **redundant**
(all four substitutions are now branded, and the marker is checked
first). But it is a cleanup, not a fix — and saying otherwise is how a
narrow change gets backported as a critical one.

## Tests

| case | asserts |
|---|---|
| unregistered **built-in** id | **`source: "default"`** — the reachable
defect |
| missing definition | still `"default"` |
| no selection | still `"default"` |
| IR carrying its own id | `"selection"`, with the id mismatch asserted
explicitly so it can't pass for the wrong reason |
| the resolved IR is the task's own board | contains the renamed hold
column, not a default-board id |

**Mutation-verified:** removing only the branding fails the
unregistered-builtin case and nothing else — which shows it covers that
specific hole rather than overlapping the others. Restoring the id check
fails only the carries-its-own-id case, leaving both fallback cases
green, which shows the deletion did not widen trust.

## Blast radius

`triage.ts:1211` is the only production consumer of the provenance
result across core, engine, dashboard and CLI. Every other `.source ===`
hit is an unrelated field on an unrelated type; the two index hits are
re-exports.

## Verification

- new suite — **5/5**, mutation-verified in both directions
- `pnpm test:gate` — **exit 0**
- `pnpm lint` — clean

Changeset included (`patch`, category `fix`), rewritten to describe the
branding fix rather than the overstated one.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 13:01:54 -07:00
gsxdsm
d2f47acedd fix(tests): the core half of #2783's bookkeeping — archived-gate inventory (#2817)
## The 6th red from #2783

#2814 cleared the 5 **engine** reds #2783 left on `main`. This is the
sixth, in **core** — I found it after #2814 was already open, and it
merged before I could fold this in.

```
archived-column-gate-parity > all three encodings of the archived gate stay in lockstep
                              with the audited inventory
AssertionError: TypeScript encoding changed.
```

## Same cause, same shape as #2786

#2783 converted three more sites off raw `column === "archived"`
comparisons and did not update the inventory in the same commit — which
the guard's own failure text explicitly asks for.

| file | before → after |
|---|---|
| `async-mission-store.ts` | 2 → 0 |
| `task-store/symbol-locks.ts` | 1 → 0 |
| `task-store/archive-lifecycle-2.ts` | 2 → 1 |

**Verified each is a real conversion, not a dropped gate.** All three
now seed a legacy lane set and extend it from the workflow:

```ts
const lanes = new Set<string>(["done", "archived"]);
…
for (const id of columnsWithFlag(ir, "archived")) lanes.add(id);
```

The literal still in `archive-lifecycle-2.ts:46` is `column: "archived"`
as a **move destination**, not a gate comparison — the same distinction
the planner-lane move targets get.

## Verified NOT a split-brain

That is the thing this file exists to catch — TypeScript moving to the
resolved role while the SQL sides keep comparing the raw string. The
**Drizzle and raw-sql inventories are unchanged and both pass**. Worth
stating explicitly because those assertions run *after* the TypeScript
one: a plain red tells you nothing about them, so they had to be re-run
green to know.

## One thing I nearly got wrong

My first edit was a whole-file string replace and it threw on an
assertion count. That turned out to be load-bearing: **these paths
appear in more than one inventory in this file** (`AUDITED_TS_SITES` and
the raw-sql inventory both list `async-mission-store.ts`). An unscoped
replace would have silently edited the raw-sql inventory too — making
the parity guard agree with itself and defeating the exact
cross-encoding check it exists for. The edit is now scoped to
`AUDITED_TS_SITES` by line range.

## Evidence

- Guard still bites: appending a real `task.column === "archived"` to an
audited file → **fails**.
- Core **4773 passed / 0 failed** · engine **10988 passed / 0 failed** ·
gate **732 green** · lint clean.

Test-only; `agent-store.ts` restored clean after the mutation.

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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 12:49:27 -07:00
gsxdsm
ba40942a10 batch-dashboard-app: 75 → 2 across packages/dashboard/app — the last two are deliberate, not missed (#2772)
**Batch branch is live: `batch-dashboard-app`.** Push conversions here
as commits rather than opening per-file PRs — that is the CI-run
bottleneck this model removes.

**One-line ownership note for you to arbitrate:** you have addressed me
as U11, U12 and U7 at different points, so the `u12 worker ->
batch-dashboard-app` mapping is ambiguous from my side. I claimed it
because `dashboard/app` is where I have done the most work this session
(TaskContextMenu, Column, TaskCard, TaskDetailModal, columnRoles,
taskActivity) and I know which of its guards are load-bearing fallbacks.
**If another worker is the intended owner, say so and I will hand the
branch over rather than both of us pushing to it** — two workers on one
shared branch is exactly what silently discarded a reviewed fix in #2645
today.

## The work order (measured at branch point, tests excluded)

**75 guards across 32 files.** Largest: `TaskContextMenu.tsx` 9 ·
`Column.tsx` 7 · `ListView.tsx` 6 · `TaskDetailModal.tsx` 4 · then a
long tail of 3s, 2s and 1s. Full per-file list is in the committed work
order so feeders can claim without re-measuring.

## Two rules this surface keeps tripping on

**1. A literal after `??`, or in the `else` of a `flags ?` ternary, is a
DEGRADED-MODE answer — not an unconverted guard.** Two real states reach
it: the **pre-load window** (board renders before the workflows fetch
resolves) and a card stranded on an id its workflow no longer declares.
In both, `columnFlagsById` has no entry at all. Deleting the fallback
does not remove a decision — it substitutes "no role" silently, and
affordances vanish during first paint.

Those sites reach 0 by **marking**, not deleting. Expect
`TaskContextMenu.tsx` and the `utils` files to be **mostly marks**. A "9
→ 0" that deleted 9 fallbacks is a regression wearing a green census.

**2. A marker excuses ONLY the construct it is attached to** — the
statement or function holding the literal, not a sibling declaration.
This has cost three passes, two of them mine; my first attempt on
`reliability-metrics.ts` scored **1 of 6**. **Verify by the count
moving, not by the comment existing.** With the ratchet gate-blocking, a
mis-marked batch either wedges the gate or locks the miss into a
re-recorded baseline.

## Status

Opening commit is the work order only — **0 of 75 converted so far.** I
am near the end of my context, so I am establishing the branch and the
shared list rather than starting conversions I cannot finish cleanly.
Feeders can begin immediately; I will keep the branch rebased.

My other PR **#2762** (`live-agent-count.ts` 6 → 0) is green and
unconflicted — per your rule it should land rather than fold into a
batch, and it is `packages/core` so it belongs to batch-core anyway.

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

* **New Features**
* Task UI now resolves workflow “column roles” per task to drive
diffs/merge details, routing/steering, progress/runtime visibility, and
review badges.
* Right-dock/overflow views and dev-server now use per-task column
traits for “executing” behavior and dependency-based “Up Next”
eligibility.
* **Bug Fixes**
* Fixed bulk action selection/delete/archive eligibility and prevented
cross-workflow role leakage.
* Made in-review/stale-paused-review, stuck, and effective
executor/validator model logic role-aware.
* **Tests**
* Added regression coverage for degraded-flag behavior and ensured
resolved-flag props aren’t ignored.
  * Added a static check to fail builds on inert optional flag seams.
* **Documentation**
  * Updated batch work-order and mega-batch branch guidance.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---

## Late addition: the seam gate was masking a real offender

`scripts/check-inert-flag-seams.mjs` matched call sites by NAME, so two
same-named functions in
different modules were conflated. I had documented that as a known
false-positive source and moved
on — reports mentioning `sortTasksForDisplayColumn` are noise, read past
them.

That annotation was the damage. Core's `sortTasksForDisplayColumn`
genuinely never receives its
`columnFlags` argument outside its own tests. The dashboard's separate
function of the same name
(`app/components/taskSorting.ts`), called with up to five arguments from
`Lane`/`Board`/`ListView`,
was raising the arg-count max and clearing core's seam. The offender was
behind a row everyone had
been told to skip.

The gate now records the module each callee is imported from and matches
it against the seam's
declaring module.

**Measured, by reverting the change:** the scan prints `17 seams, all
supplied` and emits **no row**
for the function. With the change, it is reported. Both directions
watched.

Reported on #2783 rather than fixed from outside — core owns it, and
"wire the flags" vs "drop the
parameter and let the literal stay counted" is their judgment call.
TEMPORARY allow-list entry
carries it meanwhile; the existing staleness check fails the moment the
site becomes supplied, so
the entry cannot outlive the fix.

Two known limits remain, both inherent to name matching and both
documented in the script: the
one-supplier floor, and the `__tests__` exclusion (hence the two
permanent `ALLOWED` entries).


## And the one-supplier floor, closed the same way

I wrote in the section above that the floor "hasn't cost anything yet."
That is verbatim the
reasoning that kept the imported-shadow bug alive, so I closed it
instead of leaving the note.

`best < arity` asked only whether SOME caller supplied the argument. One
correct call site cleared
the seam while every sibling took the legacy fallback — the
`isTaskStuck` defect class, where two of
three sites omitted the flags and the gate stayed green because the
third was right. Review caught
that one. A partially-supplied seam is the harder of the two:
wholly-unsupplied is uniformly wrong,
this works on the board you tested and degrades on the column you did
not.

**Measured:** dropping the flags argument at `Column.tsx`'s supplied
call site produces
`supplied by 5/6 call sites; omitted at
packages/dashboard/app/components/Column.tsx:1 (of 2)`;
restoring returns `all supplied at every call site`. Red and green both
watched.

Two real omissions found, both on `isNearDuplicateCanonicalInactive`:

- **`TaskDetailModal.tsx`** — deliberate, and it **corrects a note I
left at that site**. The old
note said hoisting the flags state was "the actual fix." It is not, for
this call: the flags in
scope describe the *modal's* task, and the canonical is a **different
task** on a column this
component never resolves. Passing them would type-check, read as a
conversion, and answer about
the wrong task — exactly what `column-role-degraded-flags.test.ts`
exists to catch. Supplying it
  correctly needs a fetch, which is a data change and out of scope.
- **`core/task-store/branch-group-ops.ts`** — genuinely wireable (the
impl is async and already
holds `store` and `canonicalId`). Reported on #2783, not edited from
outside.

Exemptions for this class are keyed by **call site**
(`<file>::<function>`), not by function name.
A name-level entry would waive every site of a partially-supplied seam,
which is backwards — its
other sites are correct and are the reason the omission is worth
reporting. Both entries carry the
same staleness check as the name-level list and cannot outlive their
fix.

Remaining known limit, now the only one: the `__tests__` exclusion,
which makes a test-only export
read as having no callers. That is what the two permanent `ALLOWED`
entries are.


## The `__tests__` exclusion, and two allow-list entries built on false
reasons

Named as the "last remaining limit" above, so it got closed too. The
scan now reads test files for
call sites — but counts them **separately**, and a test never clears a
seam. That direction is the
dangerous one: counting test callers as suppliers would have re-hidden
core's
`sortTasksForDisplayColumn`, whose only suppliers are its own tests.
Measured by lifting its
exemption: still reported.

Both permanent allow-list entries claimed the scanner couldn't see their
callers. **Both reasons
were false**, and reading tests is what proved it:

- **`evaluateMergeBlockerGuard`** — zero callers in tests either. Its
only reference in the repo is
its own declaration; never registered as a trait hook; the
`evaluateDefaultWorkflowGuards` reader
its file header credits does not exist. The `lifecycleColumns`
conversion went onto dead code, and
its note describes a crossing the guard cannot make. Reported on #2783,
including the two things I
am explicitly *not* concluding (no `"guard"` hook is registered in
production; whether that is
  residue or a dropped registration needs core's intent).
- **`isRecoverableMissingWorktreeReviewFailure`** — 5 test call sites.
It wraps
`...WithProgress`/`...NoProgress`, the live pair called from
`self-healing.ts`, both supplying
  `reviewColumns`. Entry kept, true reason recorded.

### A wrong turn, recorded because it is the failure mode this PR is
about

I first classified no-production-caller seams as *informational* when
they weren't re-exported from
a package index, reasoning that a public export might be called
externally. That silently downgraded
`sortTasksForDisplayColumn` — a confirmed real offender — from failing
to a footnote. Publication
status has nothing to do with whether there is production behaviour to
be wrong. Reverted to the
simple rule: no production caller means inert, and it fails.

It is worth stating plainly because it is the exact shape of everything
else in this PR: a change
that made the gate read *cleaner* while making it catch *less*, and it
type-checked, passed every
test, and would have reviewed fine.

### Where that leaves the check

Every blind spot named in this PR has now been closed, and **each one
produced a real defect within
minutes of closing it** — imported shadows, the one-supplier floor, the
`__tests__` exclusion. Four
verified findings went to core, one to engine. I would not read the
remaining ~240 guards' green
gates as evidence that they are clean; I would read them as untested.


## Two guards for one question, one of them worse

Having hardened the script, I checked its older twin rather than
assuming it was fine.
`resolved-flags-seams-have-suppliers.test.ts` carried its own copy of
the trailing-flags-parameter
check — written before the script existed — with **all three** holes the
script has since closed.

**Measured on one reintroduced defect** (dropping the flags argument at
`Column.tsx`'s supplied
`isNearDuplicateCanonicalInactive` call):

| | result |
|---|---|
| `scripts/check-inert-flag-seams.mjs` | `supplied by 5/6 call sites;
omitted at .../Column.tsx:1 (of 2)` |
| this test's arity half | **3 passed** |

Deleted the arity half. Redundancy between a strong and a weak check
isn't redundancy — it's a green
result available to whoever runs the weak one, and there was no signal
at the call site telling you
which you were looking at.

The **props-shape half stays**: it has no twin in the script, and I
confirmed it still fires by
reintroducing the original `PrPanel` defect (outer component stops
destructuring `taskColumnFlags`)
— it reports `PrPanel declares taskColumnFlags but never takes it`.

Dashboard app suite: **113 files / 3921 tests** (was 3922 — the deleted
case is the difference).


## The gate started catching defects as they landed

Syncing with main brought in three fresh conversions from other workers.
The hardened check flagged
all three immediately — the first time these guards have fired on
someone else's landed code rather
than on my own.

- **`TaskCard`** — `getRunningOptionalGateBadge(task)` omitted flags
while *both* `ListView` sites
supplied. Fixed, and `taskColumnFlags` added to the `useMemo` deps: no
`exhaustive-deps` rule here,
so a memo that reads flags without listing them keeps the first-paint
`undefined` answer and
  reproduces the bug through staleness instead of omission.
- **`TaskTokenStatsPanel`** — `getTotalAgentActiveMs` omitted while
`TaskCard` supplied, so the same
runtime number came from the real column on a card and from legacy ids
in the detail modal. Now
takes `columnFlags`, supplied from `detailColumnFlags` — correct here
because the panel renders the
  modal's **own** task, unlike the near-duplicate canonical above.
- **`ListView` ×2** — passed `columnFlagsById.get(task.column)`, the
cross-workflow **union**. A task
whose own workflow doesn't declare that column gets a *neighbour
workflow's* traits. The landed
comment justified it as "this list already owns `columnFlagsById`" —
exactly the reasoning
`column-role-degraded-flags.test.ts` exists to reject. It failed on
merge and is how I found this.

Also: the `getTotalAgentActiveMs` exemption I was carrying
**self-retired**. Main wired the seam, the
staleness check failed the entry, and I removed it. That mechanism has
now paid for itself once.

### Pre-existing, NOT from this PR: `App.test.tsx` is red on main

`app/components/__tests__/App.test.tsx` fails **10 of 141** identically
with my changes, with my
changes stashed, and with main's own `App.tsx` restored. Not mine, and
not in the merge gate.

**Bisected on clean `main` checkouts, so this is measured rather than
inferred:**

| commit | date | result |
|---|---|---|
| `main~400` (`41d60f0355`) | 2026-07-25 | **140 passed** (140 tests) |
| `main~275` (`74d6513fae`) | 2026-07-27 | 3 failed / 141 |
| `main~210` (`d2ce1ba8b5`) | 2026-07-29 | 10 failed / 141 |
| `main` (`6fc98fd6c7`) | 2026-07-30 | 10 failed / 141 |

So it is **not one regression** — it degraded in two stages across
2026-07-25 → 07-29, and the test
file itself changed in that window (140 → 141 tests). Three commits
touched it there:
`73b2a32e2b`, `f26cbedf4f`, `f157bf7460`. That window overlaps the
workflow-owned lifecycle
migration, which is suggestive but not something I confirmed.

The failures are render-level, not assertion-level — `Unable to find an
element with the text: + New
Task`, `Unable to find role="dialog"`, `Unable to find ... Back nav
task`. The board appears to
render nothing. That reads like a real regression or a harness mismatch
after the lifecycle
migration, not a flake, so I have deliberately **not** quarantined it —
quarantine is for flakes, and
using it here would hide the signal. Flagging for whoever owns
`App.tsx`.

My suites: `app/__tests__` **113 files / 3921 tests** green, `tsc` 0,
lint 0, census `--strict` 0,
seam gate 0.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 12:46:12 -07:00
gsxdsm
2ccd78abbc fix: main is red on the lifecycle ratchet — re-record the census baseline (#2811)
**`main` is RED on the lifecycle ratchet right now.** `node
scripts/lifecycle-column-census.mjs --strict` exits **1** on pristine
`origin/main`, which is the `Lint` job's *Lifecycle-column ratchet* step
— so **every open PR fails Lint** until this lands, regardless of its
own contents.

Verified on a detached checkout of `origin/main`, not on a branch of
mine.

## Cause

Eight `DELIBERATE-LITERAL` markers were added across seven files without
re-recording the baseline:

```
packages/core/src/task-move-disposer.ts            (in-progress, todo)
packages/core/src/task-store/archive-lifecycle-2.ts (archived)
packages/dashboard/src/github-tracking-comments.ts  (done)
packages/dashboard/src/gitlab-tracking-comments.ts  (in-progress)
packages/dashboard/src/server.ts                    (archived)
packages/dashboard/src/task-planner-chat-context.ts (done)
packages/dashboard/src/test/mockCoreEngine.ts       (in-review)
```

Adding a marker RECLASSIFIES a site (column-guard → deliberate), so the
tracked deliberate totals move and `--strict` fails until the baseline
records the new shape. It is the same mechanism that turned #2775 red
earlier today — a marker landing without its baseline — which is worth
noting because it has now happened twice from different PRs.

## The fix

Baseline re-recorded, nothing else. Zero source changes; the diff is one
derived file.

- `--strict` exits **0**
- `pnpm test:gate` — **161 / 13 / 487 / 71**
- `pnpm lint` clean

## Worth a follow-up by whoever owns the ratchet

The failure is structural rather than careless: a PR that adds a marker
is *doing the right thing*, and the baseline requirement is only
discovered when CI goes red — after merge, for everyone else. Two
options, neither of which I am taking unilaterally on a red-main fix:

1. have `--strict` treat a marker-only reclassification as an accepted
rise (it is not new debt — the count of unconverted guards goes
**down**);
2. or fail the PR that adds the marker, by comparing against the base
ref rather than the recorded baseline — the machinery for that already
exists in this script.

I would take (1): a marker is the documented way to close a site, and
requiring a second mechanical step to record it is a trap that catches
good behaviour.

🤖 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**
* Improved lifecycle census error messages to distinguish genuine
increases in column-guard debt from reclassified deliberate literals.
* Added clearer remediation guidance for reclassified results, including
when to update the baseline.
* Updated lifecycle census baseline mappings to reflect current
classifications.

* **Tests**
* Added coverage for unchanged baselines, genuine guard-count increases,
and marker-only reclassification scenarios.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 12:40:05 -07:00
gsxdsm
9fab32e1d9 fix(tests): 5 engine reds from #2783 — a stale census baseline and a turn-counting test (#2814)
## Red on main

#2783 (batch-core) landed and put **5 failures** on `main`. Both causes
are the bookkeeping half of correct changes, not defects in them.

## 1. The census baseline — 4 failures

`census-baseline-corruption-guard` plus 3 `lifecycle-column-census`
ratchet cases, all downstream of one thing:

```
lifecycle-column-census --strict: column-guard count ROSE

  packages/core/src/task-move-disposer.ts (DELIBERATE-LITERAL: in-progress): 0 -> 1
  packages/core/src/task-move-disposer.ts (DELIBERATE-LITERAL: todo): 0 -> 1
  packages/core/src/task-store/archive-lifecycle-2.ts (DELIBERATE-LITERAL: archived): 0 -> 1
  packages/dashboard/src/github-tracking-comments.ts (DELIBERATE-LITERAL: done): 0 -> 1
  packages/dashboard/src/gitlab-tracking-comments.ts (DELIBERATE-LITERAL: in-progress): 0 -> 1
  packages/dashboard/src/server.ts (DELIBERATE-LITERAL: archived): 0 -> 1
```

**The rise is legitimate.** #2783 *annotated* documented fast-path
literals — e.g. `task-move-disposer.ts`'s *"a fast path, not the guard …
the actual lane decision is the RESOLVED membership test inside this
block"* — and the census tracks marked literals per file. Re-recorded
with `--strict --update-baseline`; the same run also **tightened 20
entries whose counts dropped**, so this moves the ratchet down as well
as up.

## 2. The disposal-order test — 1 failure

```
executor-user-cancel > re-dispatch (task:moved → in-progress) awaits prior disposal before execute()
AssertionError: expected -1 to be greater than 2
```

`-1` reads like the re-dispatch was **dropped**. It was not — that would
be a real cancel-race bug, so I checked before touching the test:

```
PROBE_MICROTASK    callOrder=["abort-started","abort-resolved","dispose","execute"]
PROBE_AFTER_TIMER  callOrder=["abort-started","abort-resolved","dispose","execute"]
```

Correct order, reached once drained, unchanged after a real 50ms timer.
The test drained exactly **two** microtask turns and #2783's disposer
refactor added await hops, so `execute` had not been recorded yet.

A fixed turn count encodes today's await depth into the test: any added
`await` on the product path fails it for a reason that has nothing to do
with the invariant. It now waits on the **outcome** via `vi.waitFor`.
The ordering assertion is untouched and is still the point.

## Evidence

| mutation | result |
|---|---|
| `execute` never recorded (stands in for a dropped re-dispatch) |
**fails** — `waitFor` times out |
| `execute` observed *before* `dispose` | **fails** — `expected
'execute' to be 'dispose'` |
| baseline: fresh `--strict` run | *"every file matches its baseline
exactly"* |

Engine **10985 passed / 0 failed** (was 5 failed) · gate **732 green** ·
lint clean.

## Method note, against myself

I pre-flighted #2783 and **reported it clean** — but I ran only
`@fusion/core` and the dashboard `api` group, because that is what the
diff touches. The census and disposal tests live in `packages/engine`,
which batch-core does not modify at all.

**The suite that breaks is not always the suite the diff points at.** A
cross-package ratchet like the census is exactly the case where scoping
pre-flight to the changed packages produces a confident "clean" that is
wrong. Pre-flight needs the engine suite regardless of which package a
batch touches; I have adjusted accordingly for the remaining queue.

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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 12:33:55 -07:00
gsxdsm
6fc98fd6c7 the third census-invisible class: 51 hardcoded moveTask destinations, measured — and duplicates never archived on a renamed board (#2808)
A third census-invisible class, measured — plus the two worst instances
fixed.

## The shape

```ts
if (task.column !== "in-review") { … return; }     // the census counts THIS
await this.store.moveTask(taskId, "in-progress");  // and cannot see THIS
```

The census is an AST scan for **comparisons**. A `moveTask` destination
is a **call argument**, so no backlog entry ever points at one.
Converting the guard alone is *worse than converting neither*: the
handler starts admitting work on a renamed board and then tries to move
the card into a lane that board may not declare.

This bit twice in one week — #2797 (`branch-worktree` requeued into a
lane that may not exist) and #2807 (a GitHub "changes requested" review
dropped, then a move to a hardcoded `in-progress`). Both times it was
found only because the guard *next to it* happened to be under
conversion. So I went looking.

## Measured

Across `core`/`engine`/`dashboard`/`cli`/`plugins`, excluding
`__tests__`/`*.test.*` and comment lines:

| | count |
| --- | ---: |
| hardcoded `moveTask` destinations in production | **51** |
| …passing `recoveryRehome: true` — **deliberate**, not defects | 22 |
| …plain, rejected on a board that does not declare the target | **29**
|

**The 22 must not be "fixed".** `moves.ts` exempts them on purpose
(#1411): a card stranded in an undeclared column has to stay rescuable
to a legacy safe-landing column, or it can never be recovered at all. A
sweep that converts them deletes the rescue path. That distinction is
the reason this is 29 and not 51, and it is why I measured before
writing.

## Why this got sharper recently

The `workflowHasColumn(workflowIr, toColumn)` rejection used to sit
inside a block gated on `isWorkflowColumnsCompatibilityFlagEnabled` — a
settings key **nothing in production writes** — so it never executed and
the legacy `VALID_TRANSITIONS` table decided instead. U12 hoisted it out
of that dead branch and it is now live, proven on a real store by
`live-move-path-undeclared-target.test.ts`:

```
moveTask(card in "todo" -> "triage")  now REJECTS: /Unknown column for this workflow/
```

That changed the failure mode of all 29 from *"silently lands the card
in an undeclared column"* to *"throws"*.

**29 is not a crash count.** Whether a throw surfaces or disappears
depends on whether the caller catches, which is per-site and I did
**not** measure it — the doc says so explicitly rather than letting the
number imply severity it hasn't earned.

## Fixed here: 9 of the 29

`duplicate-intake` and `duplicate-guard` both archive a duplicate. On a
renamed archive lane the move is rejected, so **the duplicate is never
archived and keeps sitting on the operator's board as live work** — and
in `duplicate-guard` the row has already been stamped
`deterministicDuplicateOf`, so it is *marked* a duplicate while
occupying an active lane. Half-applied, which is the same trap as
#2797's branch clear.

Both now resolve the `archived`-trait column from the task's own
workflow through one shared helper, unioned with the legacy id.

**`cli/commands/task-lifecycle`** — `finalizePullRequestMerge` and
`finalizeNoOpMergeTask` both move the card to a hardcoded `"done"`, and
both run `updateTask({ status: null, mergeRetries: 0 })` *first*. On a
rejection the merge has already landed and the bookkeeping is already
cleared while the card never reaches its complete lane: the operator
sees a merged branch, a card still sitting in review, and a reset retry
counter. Same half-applied shape as #2797's branch clear. Both now route
through one resolver so they cannot drift.

**`contamination` / `foreign-only-contamination` (×2) /
`restart-recovery-coordinator`** — four recovery requeues to a hardcoded
`"todo"`, none of them a `recoveryRehome` escape. On a board without
that column the move is rejected and **the recovery never completes** —
the card stays contaminated or stranded, which is precisely the state
these paths exist to clear.

**Consolidation.** `resolveReboundTargetForTask` and
`resolveArchiveTargetForTask` now live beside
`resolveTaskLifecycleColumns` in `workflow-lifecycle-traits`, already
the store-dependent resolution seam. My first pass put the archive
helper inside `duplicate-intake` and had `duplicate-guard` import it
from there — wrong home, and it would have grown a copy per caller as
more sites converted. Seven call sites now share two definitions.

**Plain (non-`recoveryRehome`) destinations: 29 → 21.**

**Coverage on the CLI pair is scoped, and I'd rather say so than imply
more:** the test covers the *resolver*, not the two call sites. Both
enclosing functions are private and reachable only through
`processPullRequest`, which needs a live GitHub surface — exporting them
purely to test wiring is a worse trade than stating what is covered.
Three cases: renamed lane resolves, no-workflow falls back to the legacy
id (which also pins that a default board is byte-identical), and a
throwing lookup falls back.

## Revert result (measured)

| conversion | reverted → |
| --- | --- |
| duplicate archive destination | new case fails — `moveTask` called
with `"archived"` on a board whose archive lane is `boxed` |
| CLI complete-lane resolver | replacing the body with a bare `return
"done"` fails the renamed case |
| both move-target resolvers | replacing either body with a bare return
of its legacy id fails 5 cases across the resolver suite and
`duplicate-guard` |

Each resolver has a **non-vacuous companion** asserting it does *not*
return the legacy id on a renamed board — without it, a resolver
returning any string would pass. The fallback cases are load-bearing
rather than padding: `resolveWorkflowIrForTask` degrades to the built-in
IR rather than throwing, and the built-in rebound/archive lanes *are*
`todo`/`archived`, so those cases also pin that a default board is
byte-identical.

The pre-existing case asserting the legacy `"archived"` passes both
ways, which is exactly why it could not detect this and why the new one
supplies a workflow.

## Ownership note

`packages/core` was `batch-core`'s territory and `packages/cli` was
`batch-cli-plugins`'. Both batches have landed, and this is
newly-discovered work in the class documented here rather than leftover
conversion backlog. Four sites, two shared helpers — happy for either
half to move if those owners would rather carry it.

## Verification

- `pnpm test:gate` — 161 + 487 + 13 + 71, green
- `duplicate-guard` + `duplicate-intake` — 40 passed
- `tsc` on core and engine — clean
- `pnpm lint`, `check:changesets`, census `--strict` — all clean (run
explicitly; a clean `pnpm lint` alone is not evidence the CI Lint check
passes)


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

## Summary by CodeRabbit

- **Bug Fixes**
- Duplicate tasks are now archived to each workflow’s configured archive
lane.
- Completed tasks are moved to the workflow-specific completion lane,
with a safe fallback for older workflows.
- Recovery and requeue actions now use each workflow’s configured
rebound lane instead of assuming a fixed destination.

- **Documentation**
- Added guidance on avoiding failures caused by hardcoded workflow
destinations and incomplete lifecycle conversions.

- **Tests**
- Added coverage for renamed workflow lanes, fallback behavior,
duplicate archiving, and recovery destinations.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 12:25:00 -07:00
gsxdsm
240a6be0aa fix(core): dependency update deadlocked on a self-blocked task (#2810)
## The bug

`updateTaskDependenciesImpl` wraps its whole body in
`store.withTaskLock(id, …)`, then reads the current blocker with
`readDepTask(task.blockedBy)` → `store.getTask()`. `getTaskImpl` opens
with `withTaskLock(id, …)` too, and the per-task lock is
**non-reentrant**.

So when `blockedBy` is the task's **own id**, the call waits forever on
a lock its own frame holds — and holds that lock while doing so, leaving
the row permanently unlockable.

## Found by generalising #2809, not by luck

#2809 removed one `getTask`-inside-`withTaskLock`. An AST scan for the
same shape across `packages/core` and `packages/engine` returned
**exactly three sites**:

| site | verdict |
|---|---|
| `lifecycle-ops.ts:1049` | the deadlock fixed in #2809 |
| `update-task-deps.ts:233` (`assertTaskExists`) | **safe** — a
self-dependency is rejected 15 lines earlier |
| `update-task-deps.ts:344` (`readDepTask`) | **this bug** |

Both surviving sites carry the same `FNXC:SqliteDualPathCleanup` note —
*"In backend mode, readTaskFromDb uses store.db (SQLite) which is
unavailable. Replace with async store.getTask() calls."* That port is
the common cause across the whole class: it swapped a **lock-free** read
for a **lock-acquiring** one.

## Why `blockedBy === id` is reachable

The dependencies list rejects self-reference explicitly (*"Task X cannot
depend on itself"*) — and that guard is precisely why the sibling
`assertTaskExists` read on this same lock is safe, so it is left
unchanged. **`blockedBy` has no such guard:** `updateTask({ blockedBy
})` accepts the task's own id.

The first test asserts that rather than assuming it. The whole
regression rests on that state being reachable, so it is proven, not
stipulated — and it also pins the asymmetry, so a future guard on
`blockedBy` will show up here as a deliberate change.

## The fix

Return the in-lock copy already in scope instead of re-reading. One
line, no new read path, and **strictly more correct than a re-read**: it
is the state this mutation is reasoning about, rather than whatever a
concurrent writer left behind.

## Verification

- **Mutation-verified against the real defect.** With the fix reverted
the regression case fails by name — `updateTaskDependencies did not
settle within 8000ms — deadlock` — while the precondition and the
ordinary-path cases stay green. That is the actual pre-fix behaviour.
- **A differential** covering the ordinary case (blocked by *another*
task). Without it, a fix that short-circuited *every* blocker read would
pass everything else.
- Timeboxed for the same reason as #2809: a deadlock otherwise surfaces
as a suite-level timeout naming no case. Not a flake knob — the fixed
path settles in ~0.5 s and the broken one never settles.
- `pnpm test:gate` — **exit 0**
- `pnpm lint` — clean

Changeset included (`patch`, category `fix`). Independent of #2809 —
different file, no overlap — but the same class, and the scan above is
the argument that the class is now closed.

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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 12:12:33 -07:00
gsxdsm
b7288572a1 engine: a GitHub "changes requested" review was silently dropped on a renamed board (1 → 0) (#2807)
A human reviewer's feedback was being thrown away.

`PrCommentHandler.handleChangesRequested` gated on `task.column !==
"in-review"` and returned early. On any board whose review lane is
renamed, a GitHub **"changes requested"** review produced **no steering
comment** and the card **never went back to work** — the feedback
vanished behind a log line nobody reads. No error, no audit row.

## Census

| file | main | here |
| --- | ---: | ---: |
| `packages/engine/src/pr-comment-handler.ts` | 1 | **0** |

## Two literals, only one countable — again

```ts
if (task.column !== "in-review") { … return; }   // counted
…
await this.store.moveTask(taskId, "in-progress"); // INVISIBLE to the census
```

The census scores comparisons. The requeue **destination** is a call
argument, so nothing in the backlog pointed at it — the same pairing as
the branch-worktree auto-requeue in #2797, and the same trap: converting
the gate alone would make the handler *admit* the review and then
attempt a move into a lane the board may not declare, which `moveTask`
rejects. A half-conversion here turns a silent drop into a thrown
rejection. They convert together or not at all.

That is now the second confirmed instance of this shape. The pattern to
look for is a **counted guard whose body performs a hardcoded
`moveTask`** — the guard is the visible half and the move is the
dangerous one.

## Revert results (measured, each run independently)

| conversion | reverted → |
| --- | --- |
| review-lane gate | RENAMED case fails — `updateTask`/`moveTask` never
called; the review is dropped |
| requeue destination | RENAMED case fails — `moveTask` called with
`"in-progress"` instead of the board's wip lane |

The legacy case passes both ways, which is why both vocabularies run. A
non-vacuous companion (renamed board, card sitting in the hold lane)
keeps a gate that admits everything from passing.

## Verification

- `pnpm test:gate` — 161 + 487 + 13 + 71, green
- `pr-comment-handler.test.ts` — 34 passed
- `npx tsc -p packages/engine/tsconfig.json --noEmit` — clean
- `pnpm lint` — clean
- `node scripts/lifecycle-column-census.mjs --strict` — exit 0

(Running the census explicitly, not just `pnpm lint`: CI's Lint job runs
both, and a clean local `pnpm lint` is **not** evidence the Lint check
passes — that cost a round-trip on #2797.)
2026-07-30 12:06:14 -07:00
gsxdsm
74cba4b46d batch-core: one shared landed-lane helper for the source-issue surfaces (75 → 72) (#2783)
## batch-core continued — the source-issue cluster

Follow-on to #2780 (merged). Scope is still `packages/core` +
`packages/dashboard/src`.

### The defect

Five places asked the same question — *has this task landed?* — and all
five compared against the literal `done`:

| surface | consequence on a renamed board |
|---|---|
| GitHub source-issue commenter | never comments on or closes the source
issue |
| GitLab source-issue commenter | same |
| GitLab `closedAt` backfill reconciler | finds nothing, reports a clean
scan |
| session-diff boundary | finished tasks diff against an already-merged
branch |
| tracking-comment transition | (already converted; left alone) |

The commenters are the sharpest case: they returned **before reading a
single setting**, so on a renamed board the feature looked *disabled*
rather than broken — an operator checking `githubCommentOnDone` would
see it enabled and still get nothing.

The backfill is the quietest: `scanned: N, filled: 0` reads as "nothing
to do", so the failure was indistinguishable from success.

### The fix

One home: `packages/dashboard/src/task-lifecycle-lanes.ts`. Callers now
only ask.

Five copies of one question is exactly how the halves drift apart — the
motivating incident is FN-6115 → FN-6118 → FN-6123, where the same
affordance was fixed three times because it lived in two components.
This also folds in the duplicate landed-lane helper I had left in
`register-session-diff-routes.ts` in the previous PR, which was the
sixth copy waiting to happen.

Two helpers, and the difference is deliberate:

- **`landedColumnsForTask`** — `complete ∪ archived`. Membership, since
a board may declare more than one column carrying either role, and
`columnsWithFlag(...)[0]` would silently ignore the second.
- **`completeColumnsForTask`** — complete only. The GitLab backfill's
own FNXC note records that archived tasks live in `archiveDb` and are
*intentionally* excluded, so it must not widen to the archived role just
because the shared helper offers it. Today it lists with
`includeArchived: false` and would see no archived rows either way — but
that is an incidental property of the query, not the contract. The test
pins the difference so the two are not later "simplified" into one,
which would change that caller's behaviour without touching it.

Both treat an **empty** resolved set as *unexpressed*, not absent — the
v1 hazard: `synthesizeDefaultColumns` upgrades a v1 graph with `traits:
[]` on every column, so reading empty as "no complete lane" would stop
these surfaces firing on every pre-v2 project.

The reconciler is two-stage on purpose: the cheap provider and
`closedAt` tests run first and reject almost everything, so a workflow
read only happens for real candidates, and it shares one IR cache across
the scan — one read per distinct workflow rather than per task.

### Census

`batch-core` scope **75 → 72**; repo total **338**.

### Verification

- `pnpm --filter @fusion/dashboard exec tsc --noEmit -p tsconfig.json` →
0 errors
- `pnpm lint` → 0 errors
- commenter + reconciler suites → **63 passed**; helper suite → **5
passed**
- **Mutation-verified:** making the helper ignore its resolved set fails
1 of 5.

---

## Round 2 — server.ts, chat.ts, and a correction

**Census: 75 → 67** across this PR.

### The correction (see the review thread above)

My first pass gated the source-issue commenters on
`landedColumnsForTask` (`complete ∪ archived`), which **widened** the
trigger — `to === "done"` never fired on archival, and the landed set
does. Both commenters now use `completeColumnsForTask`, and the unused
`hasTaskLanded` wrapper is gone.

The ratchet for it is pinned on the **default** board, deliberately: a
widening is visible exactly where the legacy names still apply, so no
renamed-board fixture would catch it.

### `chat.ts` — three sites, and a pair that had to move together

- **Chat verification** required `column === "in-progress"`, so on a
renamed board every chat-driven verification was refused with a message
naming a column the board does not have.
- **The planner refinement pair.** Two separate guards decide this
feature: `createSession` *registers* the tool only for a finished task,
and the tool's own `execute()` *refuses* a non-finished source. Both
compared `done`. Converting only one half would have offered the tool
and then had it refuse itself — the half-converted-pair shape. The new
test asserts **both** halves in one case (tool present *and* refinement
created), and each half reverted independently fails it.

Existing `chat-manager` coverage caught neither revert, which is why the
case exists rather than relying on the suite that was already there.

Complete-only again, not the landed set: an archived task is off the
board and is not a refinement source.

### `server.ts`

- **Planner-chat retention** — the archival cutoff was a literal, so on
a renamed board task-planner chat sessions were retained forever; the
rule this listener exists to enforce never fired. Resolved, and awaited
inside the existing fire-and-forget chain rather than by making the
listener `async` — `task:moved` has synchronous subscribers whose
ordering is load-bearing elsewhere, and a chat-row delete is not the
right place to introduce a microtask boundary into that emit.

- **`isBadgeEligibleTask` — deliberately NOT converted, and marked as
backlog.** On a renamed board it is genuinely wrong: an archived card
stays badge-eligible, its snapshot is never evicted, and the cache grows
for the daemon's lifetime — the exact memory leak the predicate was
added to fix, back under a different column name.

What blocks it is measured, not assumed: both callers are synchronous
`task:updated` / `task:created` listeners whose next statement is
documented as *"Update local cache immediately"*, so awaiting lets a
second event for the same task interleave between the eligibility check
and the cache write.

I did **not** add an optional `archivedColumns` parameter, because
nothing could fill it — the callers are the sync listeners. That is the
inert-injection shape this PR's own review caught twice on #2780: the
predicate would read as converted, its test would pass by injecting the
value, and production would keep the literal. The unblocking change (a
resolved-archived-lane cache on the badge-snapshot scope, keeping the
predicate synchronous) is recorded at the site.

### Verification

- `tsc --noEmit` → 0 errors; `pnpm lint` → 0 errors
- `chat-manager` → 101 passed; commenter/reconciler/helper/badge suites
→ 55 passed
- Mutation-verified per fix, including each half of the refinement pair
separately

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

## Summary by CodeRabbit

* **Bug Fixes**
* Improved task lifecycle handling for renamed workflow lanes, including
completed, archived, landed, and in-progress states.
* Task lists now exclude completed tasks regardless of the completion
lane’s name.
* Chat verification and refinement actions now recognize configured
workflow lanes.
* GitHub and GitLab completion comments trigger only for genuinely
completed tasks, not archived tasks.
* Knowledge index refreshes and GitLab metadata updates now support
custom completion lanes.
* **Tests**
* Added regression coverage for renamed completion lanes and
archived-task behavior.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 12:03:07 -07:00
gsxdsm
e84e9d7f60 fix: the caller audit — five unwired parameters, five defects in their callers (#2803)
Seven fixes that were sitting on separate handoff branches with no owner
while `main` moved. Consolidated, rebased onto current `main`, and
verified **together** rather than only per-branch. The individual
branches remain if a subset is preferred.

This is the same consolidation that got `batch-core` and #2787 adopted.
**Close it if it breaks queue policy** — the branch keeps the work safe
either way.

## Where these came from

#2787's review found an optional parameter whose production caller never
passed it. That is a class, so I ran it against everything I had landed
and found five more. **All five turned out to have their real defect in
the CALLER, not the parameter** — in four of them the parameter was
unreachable:

| unwired parameter | what was actually wrong |
|---|---|
| `blocker-fanout.escalationColumns` | the hold default made the count
zero — **no bottleneck warning was emitted at all** |
| analytics `columnFlagsByName` | routes never built a map — **0
in-progress / 0 in-review beside correct cost totals** |
| `isLegacyAutoMergeStampCandidate` | the read **queried a column a
renamed board does not have**, so the backfill iterated nothing |
| `rankAssignedTasksForWakeDelta` | `getTasksByAssignedAgent`'s
`excludeArchived` used the literal — **archived cards returned as open
work** |
| `duplicate-intake.columnFlagsByColumnId` | intake could **archive or
soft-delete a newly created task** as a duplicate of finished work |

The heuristic worth keeping: **an optional parameter no production
caller fills is a marker pointing at an unexamined caller.** The census
cannot see any of these five — every gate is a `Set`/array literal or a
query filter, i.e. a definition rather than a comparison.

## Also included

- **`executor.ts`** — the stale-spec guard did the exact thing its own
comment forbids: on a renamed board it ran on a LIVE task and pulled it
out of execution into replan. `activeMergeStatuses` protected merging
cards *by accident*, which is why the symptom looked arbitrary.
- **`register-project-routes.ts`** — project health reported **0 active
tasks**; its list also still contained `triage`, dead since U11.
- **`dashboard/app/utils/taskTiming.ts`** — a **second copy** of
`getTotalAgentActiveMs`. Core's was converted; the card chip imports
this one, so the census counted the site as done while the rendered
number stayed keyed on `"in-progress"`.

## Verification

Verified as a set: `pnpm test:gate` **161 / 13 / 487 / 71** · core
suites **15 passed** · engine **7** · dashboard **12** · four `tsc`
targets clean · lint clean · census `--strict` exits 0.

Each fix is revert-proven individually; the specific case that fails is
named in each test header.

## Two honesty notes

**Three guards here are structural, not behavioural, and say so in their
headers.** `sanitizeAgentTaskLinks` is a closure inside
`createApiRoutes`; the analytics aggregators need a live
`AsyncDataLayer`; the stale-spec guard sits deep inside `execute()`.
Each ratchet fails on revert — verified — but none is an end-to-end
proof, and the headers state which half they cover.

**One of my behavioural test sets would have lied.** The intake-dedup
cases drive `findSameAgentDuplicates` directly; I removed the wiring to
measure the revert and **they stayed green**, because they pin the
predicate and not the caller. That is the exact illusion this audit was
chasing, reproduced in my own file. The forward now has its own
structural check.

## Deliberately not included

`worktree-pool.ts:1205` — it **fails safe** (a missed match protects a
branch from cleanup rather than deleting it) and sits in the merger's
branch-reaping path where the opposite error destroys work. That
deserves its owner's judgement, not a drive-by conversion.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 12:02:53 -07:00
gsxdsm
a8cfce8fbd executor: stale merge evidence re-entering execution, and a live checkout that read as unowned (12 → 8) (#2805)
Two executor conversions with real operator consequences, one census
false positive, and three sites deliberately left with their reasons
recorded.

## Census

| file | main | here |
| --- | ---: | ---: |
| `packages/engine/src/executor.ts` | 12 | **8** |

Of the 4: three genuine conversions, one reclassification.

## What was broken

**`resetMergeStateIfNeeded` — cards re-entered execution carrying stale
merge evidence.** Merge state is cleared when a card *leaves* a lane
where a merge could have been recorded. Keyed on `in-review`/`done`, a
renamed board matched neither, so a card bouncing back into execution
kept `mergeDetails` — including a **commit sha from its previous pass**
— into its next run. `review` is not a trait, so this resolves through
the same five flags (`complete`, `mergeOrchestration`, `mergeBlocker`,
`humanReview`) the dependency gates in this file already use; two gates
answering "is this a merge-bearing lane?" differently would be a split
brain.

**The worktree-owner scan — a live checkout read as unowned.**
`findActiveWorktreeOwner` asks "is anyone else working in this
checkout?". Its in-memory `activeWorktrees` leg is
vocabulary-independent, but the **durable** leg — the one that answers
after an engine restart, when the in-memory map is empty — filtered with
`t.column !== "in-progress"`. On a renamed board that matched nobody, so
the worktree read as free and a second task could be handed a checkout
another task is live in. Post-restart is exactly when this function
matters.

Not the query-filter class: that `listTasks` call passes no `column`, so
the predicate is the only lane gate on the path.

## A third census false positive in this package

Line 16094's `to` is a **review-addressing record status** — the method
signature is `to: "queued" | "in-progress" | "addressed" | "failed"`,
and the next two lines test it against `"addressed"` and `"failed"`,
which are not columns at all. Marked `DELIBERATE-LITERAL`.

That is the third in `packages/engine` after the two `cli-agent`
`CliMachineState` ones (#2797). The backlog total includes non-columns;
a sweep that "converts" them turns a status machine into a workflow
role.

## Revert results (measured, each run independently)

| conversion | reverted → |
| --- | --- |
| worktree-owner wip predicate | RENAMED case fails — checkout reads as
**free** while another task is live in it |
| `resetMergeStateIfNeeded` lanes | RENAMED case fails — card keeps
`commitSha: "abc123"` from its previous pass |

Both DEFAULT cases pass before and after, which is why both vocabularies
run. Each has a non-vacuous companion (holder sitting in the complete
lane; a return from the hold lane) so a predicate matching every column
would not pass.

**Both reach their seam directly through a cast.** The public routes are
`handleBranchConflict` (needs a real `BranchConflictError` plus a git
repo) and the `task:moved` listener (drags in the whole `execute()`
path); going through either would make these tests about a git fixture
rather than about the lane predicate. The alternative was the status quo
— all 91 `executor-worktree*.test.ts` cases seed `column:
"in-progress"`, so they assert the legacy fallback and pass either way.
I shipped the conversions in one commit *stating* they were unproven,
then closed that gap in the next; the history shows both.

### Two fake defects found while writing those tests

Worth naming, because both are the documented green-for-the-wrong-reason
shape:

1. The first fake had no `updateTask`, so the cleanup **threw** rather
than asserting anything.
2. The second returned a new object without persisting — and
`cleanupMergeStateForReverification` **re-reads through `getTask`**. The
re-read handed back the stale row, so *both* vocabularies reported
"nothing changed" and it would have read as a passing negative test.

## Deliberately NOT converted, with reasons

- **The `task:moved` listener cluster** (`3521`/`3545`/`3596`/`3606`),
including the AGENTS Move-Task hard-cancel contract `userCanceled:
source === "user" && to === "todo"`. Its prologue is synchronous
(`userCanceledTaskIds.delete`, watchdog clear) and deferring it to a
microtask changes hard-cancel ordering. The sync IR reader is not an
option — it returns the DEFAULT workflow for every task in production. A
safe conversion needs lanes resolved on an earlier async boundary: new
machinery plus an ordering change, which is out of fleet scope and not a
guess worth making on a hard-cancel path.
- **`17081`** pairs `latestColumn === "in-progress"` with a
**hardcoded** `moveTask(taskId, "in-progress")` two lines above —
census-invisible, the same shape as the branch-worktree requeue bug in
#2797. They have to convert together, and the move needs the same
rejection guard.
- **`5903`** is the query-filter class: `listTasks({ column:
"in-progress" })` followed by a re-assertion of the same literal.
Converting it drops a count and changes nothing — see
`docs/solutions/architecture-patterns/self-healing-sweeps-are-blind-on-a-renamed-board.md`
(#2800).

## Verification

- `pnpm test:gate` — 161 + 487 + 13 + 71, green
- `npx tsc -p packages/engine/tsconfig.json --noEmit` — clean
- `pnpm lint` — clean
- `--strict` exits 0

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 12:02:41 -07:00
gsxdsm
7dc7a41e0e fix(tests): the load-lane guard matched a VARIABLE NAME — make it AST-based (#2804)
## A red that no CI run can see

Found by pre-flighting #2796 against current `main`. Merged, the engine
suite fails:

```
scheduler-load-lane-union.test.ts > the scheduler builds this same union
expected 'import {…' to contain '...columnsWithFlag(loadLaneIr, "intake")'
```

**Neither side is red on its own.** `main` is green (10913 passed, 0
failed) and #2796's own CI is green — this test landed on `main` via
**#2787**, *after* #2796 was cut, and #2796 does not touch the file. It
fails only in the merged state, which is exactly the shape no branch's
CI checks.

## #2796 is not at fault

The union is still built (`scheduler.ts`, the `columnsWithFlag(ir, ...)`
spread). Resolving assignment load per task renamed the local from
`loadLaneIr` to `ir`, and the guard hardcoded that name:

```ts
expect(source).toContain(`...columnsWithFlag(loadLaneIr, "${flag}")`);
```

It would fail identically on a reformat, a line wrap, or any rename —
reporting drift that did not happen. And the reflex fix is to edit the
string to match, which protects nothing and teaches nobody anything.

## The fix

Parse `scheduler.ts` and collect the string literal passed as the
**second** argument to every `columnsWithFlag(...)` call, whatever the
first argument is called. Same invariant — every legacy role is unioned
somewhere in the scheduler — now actually checked.

It also asserts the parse found **something** before checking the six
flags. A visitor that matched nothing would make every assertion below
it vacuous, which is the specific failure mode this guard family keeps
producing.

Still structural rather than behavioural, for the reason the file header
already gives: the call site sits inside a dispatch path a unit test has
no business standing up. The three sibling cases cover the resolver's
behaviour; this one covers the wiring.

## Evidence — the discrimination is the right way round

| mutation | expected | result |
|---|---|---|
| rename `ir` → `loadLaneIr` (behaviour identical) | pass | **4/4
passed** |
| drop `...columnsWithFlag(ir, "hold")` from the union | fail |
**fails**: `scheduler.ts no longer passes "hold" to columnsWithFlag` |

Engine **10936 passed / 0 failed** · gate **732 green** · lint clean ·
engine `tsc --noEmit` **0 errors**. Test-only; `scheduler.ts` restored
clean after the mutations.

**Unblocks #2796 with no change needed on its side** — commented there.

## Pre-flight results for the rest of the queue

Same method (merge with current `main`, run the suites), since
batch-engine's earlier landing put 32 failures on main that were only
caught post-merge:

| PR | result |
|---|---|
| #2785 batch-engine-tail | clean — 10903 passed |
| #2783 batch-core-2 | clean — core 4751 passed; its one api failure is
pre-existing on main |
| #2797 engine tail | clean — 10941 passed |
| #2772 batch-dashboard-app | clean — backfill total 112 → **111**, no
lane regresses |
| #2796 assignment load | **this failure only** |

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 11:59:25 -07:00
gsxdsm
b74d4cdd98 test(engine): a third inert-conversion mechanism — a resolver called with a sentinel task id (#2806)
## What

One new live-PostgreSQL E2E suite, 3 tests. **No production file is
touched** — evidence, per the E2E worker's remit.


`packages/engine/src/__tests__/workflow-sweep-sentinel-task-id-live-e2e.pg.test.ts`

## A third mechanism

Two are already measured in this series:

| mechanism | PRs |
|---|---|
| the site resolves the workflow **synchronously**, so PostgreSQL hands
it the default board | #2789–#2794 |
| the role answer is an **optional parameter** and the caller does not
pass it | #2795–#2802 |

This is a third, and it is inert **by construction** rather than by
environment. `triage.ts`'s startup sweep resolves its lane vocabulary
with a **sentinel task id**:

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

There is no task `""`, so no selection can be read for it and no board
can be resolved from it. The lanes come back as the default board's and
the union collapses to the legacy pair `{triage, todo}`.

**Note what this means for the other mechanisms' fixes: making
`resolvePlannerLanes` async would not repair this site.** The defect is
the argument, not the resolver.

## What breaks

The sweep clears stale `planning` status so a card cannot hold a
planning admission slot forever. Its own comment says the union is
*"load-bearing, not defensive"*, because the merged post-U11 default
collapses `intake` and `hold` onto `todo` and *"nothing ever swept
`triage`"*.

That reasoning fixes the **merged** case and leaves the **renamed** one.
A card parked in a renamed hold column with a stale `planning` status is
in none of the four queried columns, is never swept, and occupies a
planning admission slot permanently — the exact failure the comment
describes, on every custom board.

**This one is sweep-wide**, which is what makes the sentinel distinct
from the other two mechanisms: they resolve per task and get one card's
answer wrong; this resolves **once for the whole board** and cannot be
right for any workflow but the default, however many boards the project
runs.

## Evidence discipline

- **Observed state.** Whether the card's persisted `status` is still
`planning` after the real sweep runs against a real store.
- The sweep is a private method, invoked through a cast. That is
production code executing, not a stand-in, and nothing about the
assertion depends on the cast. `processor.stop()` runs in a `finally` so
one case's admission provider cannot outlive it and observe another's
store.
- The first case isolates the **mechanism**: two custom workflows exist
by the time it runs and neither can influence the answer, because the
argument names no task.

## Mutation-verified

Adding the renamed hold column to the swept set:

| case | result |
|---|---|
| sentinel resolves the default lanes | passes — correct, it asserts the
resolver, not the query |
| CONTROL (default board) | passes — correct, unaffected |
| CHARACTERIZATION (renamed board) | **fails** |

Exactly one case moves, and it is the one that should.

## Not done, and why

**No fix.** The sweep needs a lane vocabulary for *every* board in the
project, not one board's — so the fix is a union over the distinct
workflows present, or a per-task filter after a broader query, not a
swap of the sentinel for a task id. That is a design decision in
`triage.ts`, another worker's file. The differential says what the fix
must make true.

## Verification

- new suite — **3/3 passed**, mutation matrix above
- full live-PG E2E surface — **151/151 passed** (148 on main + 3)
- `pnpm lint` — clean

Lane: `.pg.test.ts`, skipped via `pgDescribe` when no PostgreSQL is
reachable, so the merge gate is unaffected. Throwaway per-file database;
never port 4040.

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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 11:50:05 -07:00
gsxdsm
f9f06a4fb7 engine tail: nine files to zero — incl. a census-INVISIBLE requeue into a lane that does not exist (−13) (#2797)
Follow-up to #2785. Nine engine files to **zero**, all one question —
*"is this task finished?"* — asked in nine places, wrong in every one on
a renamed board.

## Census, per file (measured, `--strict` verified)

| file | main | here |
| --- | ---: | ---: |
| `agent-reflection.ts` | 2 | **0** |
| `merger-scope-auto-widen.ts` | 2 | **0** |
| `worktree-pool.ts` | 2 | **0** |
| `cli-agent/state-machine.ts` | 2 | **0** (reclassified — see below) |
| `auto-recovery-handlers/branch-worktree.ts` | 1 | **0** |
| `cli-agent/task-session.ts` | 1 | **0** (reclassified) |
| `merger-integration-worktree.ts` | 1 | **0** |
| `merger-orphan-rehome.ts` | 1 | **0** |
| `plugin-runner.ts` | 1 | **0** |
| **net** | | **−13** |

## The one worth reading: `branch-worktree` had TWO defects, and the
census could only see one

```ts
if (task.column === "in-progress") { …clear branch… }        // counted
await this.deps.taskStore.moveTask(task.id, "todo", { … });  // INVISIBLE
```

The census scores **comparisons**. The requeue *destination* is a call
argument, so nothing in the backlog ever pointed at it — and it is the
worse of the two: a board with no `todo` column was requeued into a lane
**that does not exist**. The counted literal is the smaller half (a
renamed wip lane meant the stale branch was never cleared, so the card
carried a dead branch back into execution).

Converting the comparison alone would have dropped a census count and
left the board requeuing into nowhere. Destination now resolves through
`resolveReboundTarget` (KTD-10 ordering: hold → intake → first column).

Reverted **independently**: destination restored → 2 fail (`moveTask`
called with `"todo"`, not `"backlog"`); wip test restored → 1 fail
(`updateTask` never called).

## The rest

- **`plugin-runner`** — `onTaskCompleted` never fired on a renamed
board. Every plugin that closes an issue, posts a notification, or
records a metric on completion **silently stopped**, with nothing
logged. Resolved *asynchronously* inside the existing fire-and-forget
seam, not via `resolveTaskWorkflowIrSync` — per
`sync-workflow-ir-callsite-allowlist` that reader returns the DEFAULT
workflow for every task in production, so a sync guard here would read
as converted and still be wrong. The listener is already
`void`-dispatched, so awaiting inside it changes no observable ordering
(the shape `NotificationService` already uses).
- **`merger-orphan-rehome`** — a renamed complete lane made every source
task read as unfinished, so orphaned commits were never rehomed and
stayed stranded off the integration branch. Resolves by the **trailer
id**, not `sourceTask.id`, which the fake store does not populate.
- **`agent-reflection`** — `classifyOutcome` returned `null` for every
finished task, so both callers treated completed work as nothing to
reflect on: one recorded `reflection:skipped` with reason
`"not-completed"`, the other silently `continue`d. Reflection captured
**nothing at all** on a custom board.
- **`worktree-pool`** — shipped tasks' worktrees stayed in the ACTIVE
set, so the reclaim pass never returned them and the board walks into
worktree exhaustion — a stall whose cause is invisible from the symptom.
- **`merger-scope-auto-widen`** — finished cards counted as active
claimants, so a merge was blocked by a task that no longer exists in any
meaningful sense.
- **`merger-integration-worktree`** — a shipped task still counted as a
live worktree user, so the integration worktree could never be reused
and the merge path took the slower rebuild every time.

## The census OVERSTATED the engine backlog by 3

`cli-agent/state-machine.ts` and `cli-agent/task-session.ts` compare
against `done` — but that is a **`CliMachineState`**
(`ready`/`busy`/`waitingOnInput`/`done`/`resuming`/`idle`) tracking one
CLI agent process. It never reads a board column. The census matches the
bare string.

Marked `DELIBERATE-LITERAL` rather than left for a later sweep to
"convert" a process state into a workflow role. Worth flagging
fleet-wide: the backlog total includes at least these three non-columns.

## Revert results (measured, each run)

| conversion | reverted → |
| --- | --- |
| `plugin-runner` complete gate | RENAMED case fails — `onTaskCompleted`
never invoked |
| `merger-orphan-rehome` source gate | RENAMED case fails —
`orphan:false, reason:"source-task-not-done"` |
| `branch-worktree` destination | 2 fail — `moveTask` called with
`"todo"` |
| `branch-worktree` wip test | 1 fail — `updateTask` never called |

Each has a **non-vacuous companion** (renamed board, non-complete lane /
mid-flight source / non-wip column) so a guard that fired
unconditionally would not pass.

**Four are NOT revert-proven, and I am not claiming otherwise:**
`agent-reflection`, `merger-scope-auto-widen`,
`merger-integration-worktree`, `worktree-pool`. Their suites omit a
workflow and therefore assert the legacy fallback — they pass before and
after. `merger-scope-auto-widen` has no test file at all;
`scanIdleWorktrees` is mocked in every suite that touches it and driving
it for real needs git worktrees on disk. All four strictly **widen** the
finished set (resolved roles ∪ the legacy ids), so default boards are
byte-identical. That is the argument for shipping them, not a substitute
for coverage.

## Examined and deliberately NOT converted

- **`backlog-pressure-reporter:173`** — fed by `listTasks({ column:
"todo" })`, a hardcoded **query** filter. On a renamed board `todoFull`
is empty and the predicate never runs. Converting it drops a census
count and changes nothing observable; the fix belongs at the query
layer.
- **`auto-merge-finalization:28`** — the catch-arm legacy fallback,
which must stay for the same reason `columnRoles.ts` keeps its id
fallback.
- **`auto-merge-finalization:84`** — only selects between two diagnostic
reason strings that are **both** `ok: false`, on a pure validator with
no store in scope. Converting it would thread a store through a pure
function to change a label.

## Merge resolution note

Merging main brought conflicts in `agent-assignment.ts` and
`ephemeral-worker-manager.ts`. **Main's versions won both** and mine are
dropped: main threads an optional `activeColumns` from
`scheduler.ts:2340` (a cleaner seam than widening the store type to
resolve internally), and its `isAgentIdle` carries a greptile P1 fix
mine lacked — `columnsWithFlag` membership rather than first-per-role,
so a workflow declaring two wip lanes has both recognised. That is the
fourth time in this program main's version of a contested file was the
better one.

## Verification

- `pnpm test:gate` — 161 + 487 + 13 + 71, green
- `npx tsc -p packages/engine/tsconfig.json --noEmit` — clean
- `pnpm lint` — clean
- `--strict` exits 0


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

## Summary by CodeRabbit

* **Bug Fixes**
* Workflow-dependent task completion now recognizes custom lifecycle
columns, including renamed boards.
* Recovery requeues tasks to the configured destination and clears
branch details only from the appropriate work-in-progress column.
* Improved handling of completed tasks, orphaned work, shared worktrees,
and scope evaluation across custom workflows.
* Plugin completion hooks now trigger for any column configured as
complete.
* **Tests**
* Added coverage for renamed workflow columns and custom completion,
recovery, and rehoming behavior.
* **Documentation**
  * Clarified CLI state terminology in internal developer comments.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 11:28:54 -07:00
gsxdsm
29186a96da fix(tests): new CLI red from #2775 — the test pinned a decision its own PR superseded (#2801)
## New red on main

#2775 landed and put one failure on `main`, in a test that PR itself
added:

```
pr-create-review-lane-resolved.test.ts
  > refuses WITHOUT naming a phantom lane when the workflow declares no review lane
AssertionError: expected undefined to be defined
```

## Two review rounds pushed `pr.ts` in opposite directions; the test is
from the losing one

| round | decision |
|---|---|
| **1** (greptile P2) | a resolved workflow with no review-trait column
is an **answer** — do not invent `'in-review'`, say *"no review lane"*.
**This test was written against that.** |
| **2** (greptile) | refusing on an empty set rejects **every v1
workflow**, because `synthesizeDefaultColumns` upgrades a v1 graph by
emitting every column with `traits: []` — so a v1 board whose
`in-review` column plainly exists resolves to an empty review set. |

**Round 2 shipped** (`pr.ts:206-207`) and is right: an empty set is
indistinguishable from a v1 upgrade, so it means *unexpressed* rather
than *absent* and takes the same legacy fallback as an unreadable
workflow. Both rounds are extensively documented in `pr.ts` — the code
is deliberate and I have not touched it.

The consequence is simply that **there is no "no review lane" message in
the shipped code at all**, so `errors.find((e) => e.includes("no review
lane"))` returned `undefined`. The test could never have passed against
what merged.

## The fix

Re-pointed at the contract that actually shipped: the filtered board
takes the legacy `'in-review'` fallback, and the refusal must **not**
name the renamed lanes (`signoff`, `waiting-on-a-human`) that this board
no longer declares — which preserves the anti-phantom-lane intent the
test was named for.

## Flagged, not guessed

The round-1 behaviour is **not recoverable** without a way to
distinguish *"v2 board that declares no review lane"* from *"v1 board
whose traits were synthesised empty"*. The IR does not currently carry
that signal, so emitting a distinct message would re-break every pre-v2
project — the exact regression round 2 caught. Recorded in the test
rather than invented.

## Evidence

Mutations, both caught:

| mutation | result |
|---|---|
| fallback names lanes the board lacks | **1 failed** |
| the review-lane gate removed entirely | **2 failed** |

Full CLI package **1684 passed / 106 skipped (126 files)** — was 1
failed. Gate **732 green** · lint clean. Test-only; `pr.ts` restored
clean after the mutations.

## How this was found

Pre-flighting the open batch PRs against current `main` rather than
their branch heads, after batch-engine's previous landing put 32
failures on main that were only caught post-merge. #2785 and #2783 both
came back clean (commented on each); re-running `main` itself after the
newest landings surfaced this one.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 11:25:46 -07:00
gsxdsm
bb8be93c52 test(engine): audit the auto-heal review-lane call sites (a DELIBERATE-LITERAL note that is not true) (#2802)
## What

One new source-level audit test, 3 cases. **No production file is
touched.** Fourth instance of the optional-role-parameter class (#2795,
#2798, #2799) — and the only one so far where the source **annotation
asserts the opposite of the fact**.


`packages/engine/src/__tests__/auto-heal-review-lane-callsite-audit.test.ts`

## The finding

`project-engine.ts`'s `hasAutoHealableVerificationBufferFailure` takes
the review-lane answer as an optional parameter defaulting to
`task.column === "in-review"`. Its `DELIBERATE-LITERAL` note says:

> "Both call sites pass the resolved answer; the default exists so an
unconverted caller keeps exactly today's behaviour rather than silently
changing meaning."

**There are three call sites, not two:**

| site | passes the resolved lane? |
|---|---|
| `canMergeTask:2657` (threads its own param) | ✅ |
| ← `canMergeTask:2903` | ✅ `t.column === reviewLane` |
| ← `canMergeTask:3334` | ✅ `task.column === mergeLoopReviewLane` |
| **merge loop `:3655`** — direct call | ❌ **nothing** |

The note counts the two gating callers and misses the healing one. Note
which half is converted: **the sites deciding whether a card MAY merge
resolve the lane; the site that would RECOVER a stuck card does not.**

The consequence is in the same comment: on a renamed board *"a task
whose merge verification died on a buffer-overflow error was never
auto-healed — it sat retry-exhausted until a human reset it. The failure
is invisible because 'no auto-heal' looks identical to 'nothing to
heal'."*

## Why this is a source audit and not an E2E

The predicate and its caller are both **private methods** of
`ProjectEngine`. The three sibling files in this series each carry a
live behavioural differential because their predicates are exported;
this one cannot, and inventing a mock `ProjectEngine` to assert a
private method would prove only that the mock behaves as written. Stated
plainly rather than substituted for — the finding is a call-site fact,
and a call-site fact is what is asserted.

The third case deliberately pins the **false note itself**, so the audit
fails when someone corrects the sentence — forcing them to also decide
what to do about the third site rather than fixing the prose and leaving
the gap.

## A self-correction, forced by the mutation run

The first version filtered call sites on whether the argument text
contained `isReviewColumn` / `ReviewLane`. Converting the unconverted
site to pass a plain `true` left the count at one and **the suite stayed
green** — the "alarm in both directions" the header claims did not
exist.

Now it counts **arguments** (depth-aware, so nested calls and object
literals do not confuse it), which is the property actually being
asserted and cannot be spelled around. Re-verified:

| state | result |
|---|---|
| main | 3/3 pass |
| site 3655 converted to pass a third argument | **fails** |

Recorded in an FNXC note next to the helper, because the first version
is the exact mistake this series exists to catch.

## Not done, and why

**No fix.** Passing the resolved lane at `:3655` means resolving the
task's review column inside the merge loop; whether that resolution
belongs there or should be hoisted alongside `mergeLoopReviewLane`
(already computed nearby, which is what makes the omission look
accidental rather than considered) is a decision for the file's owner.

## Verification

- new suite — **3/3 passed**, mutation-verified in both directions
- `pnpm lint` — clean
- Unit lane, no PostgreSQL required; adds no gate surface.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 11:19:12 -07:00
gsxdsm
ea0af4826f test(engine): third instance of the optional-role-parameter class, intra-file (#2799)
## What

One new live-PostgreSQL E2E suite, 4 tests. **No production file is
touched** — evidence, per the E2E worker's remit. Third measured
instance of the pattern from #2795 and #2798.


`packages/engine/src/__tests__/workflow-planning-continuation-terminal-gap-live-e2e.pg.test.ts`

## The finding — and why it is the sharpest form yet

The converted and unconverted call sites are **in the same file**, and
one is nested inside the other's call tree.

`in-process-runtime.ts` resolves terminal columns through an optional
parameter defaulting to `LEGACY_TERMINAL_PAIR` (`done` + `archived`):

| site | passes the resolved set? |
|---|---|
| `drainDuePlanningContinuations:386` | ✅ `{ terminalColumns }` |
| `selectActionablePlanningContinuations:413` | ❌ nothing |
| `resolvePlanningContinuationCandidate:199` → inner predicate | ❌ not
threaded |

**What breaks.** `selectActionablePlanningContinuations` documents its
own purpose as excluding *"soft-deleted / archived / done tasks so
archive-fallback rows returned by getTask cannot re-enter plan-review
after the card left the board."* On a renamed board its terminal test is
against ids that board does not have, so a card sitting in its
**complete** column is classified `actionable` and re-enters plan-review
— precisely the thing the function exists to prevent, silently, on every
custom board.

**The third site matters too**, because it shows the conversion is not
whole even along the *converted* path:
`resolvePlanningContinuationCandidate` applies the caller's resolved set
to its own terminal test, then delegates to
`isPlanningContinuationTaskDispatchable(task)` without passing it, so
that inner predicate re-tests against the legacy pair. A partially
threaded conversion is indistinguishable from a complete one at every
call site that looks converted.

### The class so far

| seam | call sites passing the resolved answer |
|---|---|
| `shouldHoldActiveFileScopeLease` | 2 of 4 — #2795 |
| `evaluateParkedAgentTaskLink` | 2 of 6 — #2798 |
| `resolvePlanningContinuationCandidate` | **1 of 2**, plus one
unthreaded inner call — this PR |

Verified clean and reported as such: `restart-recovery-coordinator.ts`,
the `isRecoverableMissingWorktreeReviewFailure` family,
`spec-staleness.ts`'s `plannerColumns`, `task-revert.ts`'s
`revertableColumns`, `agent-assignment.ts`'s `activeColumns`. Audited
since this PR was opened and also clean: `surfacing-sweeps.ts`'s
`roleColumn` (resolved internally through the async resolver — not a
caller-supplied parameter at all) and `auto-claim-snapshot.ts`'s role
trio (both `isRunnableAutoClaimCandidate` call sites pass
`rolesByTask`). **The class audit is therefore complete**: four seams
have unconverted callers (#2795, #2798, this PR, #2802); every other
seam is correct.

None of it is visible to the lifecycle-column census: there is no column
literal at any unconverted call site — the literal lives one function
away, where it is correct for an unconverted caller and correctly
annotated.

## Scope, stated honestly

The three behavioural cases are driven end to end with real persisted
rows from a live store and the real exported functions. The **call-site
split is asserted against source text** — driving the drain needs the
runtime's full dependency set, which I did not build — and the audit
case says so rather than dressing it up. It is an alarm in both
directions.

## Mutation-verified

Flipping `LEGACY_TERMINAL_PAIR` from `done` to the renamed complete id:

| case | result |
|---|---|
| CONTROL (default board) | **fails** |
| CHARACTERIZATION (renamed board) | **fails** |
| BOUND (resolved set passed) | passes — correct, the argument overrides
the default |
| AUDIT | passes — correct, it is a source assertion |

## Not done, and why

**No fix.** Threading the resolved set into
`selectActionablePlanningContinuations` changes its signature and every
caller; threading it into the inner predicate changes behaviour along
the already-converted path. Both are decisions for the file's owner. The
differential says exactly what the fix should make true.

## Verification

- new suite — **4/4 passed**, mutation matrix above
- full live-PG E2E surface — **137/137 passed** (133 on main + 4)
- `pnpm lint` — clean

Lane: `.pg.test.ts`, skipped via `pgDescribe` when no PostgreSQL is
reachable, so the merge gate is unaffected. Throwaway per-file database;
never port 4040.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 11:15:56 -07:00
gsxdsm
ed0df8b0e2 evidence: the self-healing sweeps do not RUN on a renamed board — 49 hardcoded column QUERIES, and 17/30 fakes hide it (#2800)
**Evidence only — no conversions, no behaviour change.** One doc, one
test. It changes how the fleet should read the largest remaining file in
the backlog.

## The finding, measured on `origin/main`

`packages/engine/src/self-healing.ts` carries:

- **97** lifecycle-column comparisons the census counts, and
- **49** calls of the shape `this.store.listTasks({ column: "<literal>",
… })`.

`listTasks`' option is `column?: ColumnId` — **one literal column**,
applied as a filter in the store. On a workflow whose lanes are renamed,
every one of those 49 queries returns an **empty array**, so the sweep
it feeds does nothing at all.

**The self-healing sweeps are not
mostly-correct-with-some-unconverted-guards. They never execute.** The
`in-review` family alone is roughly half the calls: merge recovery,
wedged merges, branch rebind, pending-step reconciliation.

## Why this matters to the census specifically

```ts
const tasks = await this.store.listTasks({ column: "done", slim: true });
const candidates = tasks.filter((task) =>
  task.column === "done" &&        // <-- the census counts THIS
  …
);
```

The census scores the **comparison**, not the query. Converting it is a
legal-looking change that drops a count and changes **nothing an
operator can observe** — the loop body still never runs, because the
list was already empty.

Roughly **31** of self-healing's remaining comparisons are this shape.
Driving `self-healing.ts` to 0 would report the subsystem as converted
while it stays inert on custom boards. In this file the census total is
not merely a floor — it is actively misleading, and I'd rather the fleet
know that before someone spends a week on the 97.

## Why the existing suite cannot see it

Measured across `packages/engine/src/__tests__/self-healing*.test.ts`:

- **30** files define a `listTasks` on their store fake.
- **17** ignore the `column` option entirely.

```ts
// representative of the 17
listTasks: vi.fn(async (options?: { limit?: number; offset?: number }) => {
  const all = [...tasksById.values()];   // options.column is never read
  return all.slice(offset, offset + limit);
}),
```

The fake is **more permissive than production**. The sweep receives rows
the real query would have filtered out, so the test proves the sweep's
*logic* while saying nothing about whether the sweep is ever *reached*.
A green self-healing suite is not evidence that self-healing runs.

This is the mirror image of
`store-fake-defects-that-masquerade-as-production-bugs.md`: there a fake
is *missing* something production needs and the code looks broken; here
it supplies *more* and the gap looks fixed.

## About the test

It **pins a known defect** and is labelled as such in the file header —
it asserts what the engine does today, which is the wrong thing.

It asserts the **query argument**, not the outcome. The outcome is `0`
either way, so an outcome assertion cannot distinguish *"nothing to do"*
from *"asked the wrong question"*. Asserting the argument also avoids
standing up the git-evidence path these sweeps enter once they have
candidates.

- **Ratchet proven to fire:** repointing `reconcileDoneTaskIntegrity`'s
query at the renamed lane makes it fail — `1 failed | 2 passed`. A guard
that reports success without checking anything is worse than no guard,
so I ran it.
- **Guard on the guard:** a first case asserts the renamed fixture
really does resolve a complete lane that is not `done`. Without it,
every later assertion could pass vacuously if the fixture ever collapsed
to the default vocabulary.
- **Control case:** shows the ignoring fake hands back a row whose
column is `shipped` from a query that asked for `done` — the mechanism
by which the suite stays green.

When the query layer is fixed this test will fail, forcing an update.
That is the intent.

## What I did NOT do, and why

I did not fix it. `column?: ColumnId` takes one id, and the resolution
is circular at the query layer — you need a task to know its workflow,
and you are querying to find the tasks. A real fix is either a
multi-column query option (`columns?: readonly ColumnId[]`) plus a
resolved union across live workflow definitions, or dropping the filter
and post-filtering by role in the engine.

Either is a **behaviour change to a shared store API across 49 call
sites**. That is a coordinator-level decision, not something a
conversion PR should take unilaterally — the same reasoning that kept
membership predicates out of the census. I'd take it on if you want it;
it needs to be a deliberate call, not a side effect of a conversion
sweep.

## Verification

- `pnpm test:gate` — 161 + 487 + 13 + 71, green
- `npx tsc -p packages/engine/tsconfig.json --noEmit` — clean
- `pnpm lint` — clean
2026-07-30 11:15:43 -07:00
gsxdsm
5795d70b27 fix(engine): assignment load must be resolved per task — #2787 P1 follow-up (#2796)
Fix-forward for the P1 that arrived on **#2787 after it merged** — so it
lands as its own PR rather than a thread reply on merged code.

## The finding

`selectPermanentAgentForTask`'s `activeColumns` was resolved from the
**candidate** task's workflow and then applied to every row `listTasks`
returned. On a project running several workflows — the normal case —
assignments living in another workflow's load-bearing lanes vanished
from the tally, and the already-loaded-agent-wins bug returned through a
different door.

**A column id means something only relative to its OWN workflow.**
`blocker-fanout.ts` documents exactly this and offers a per-task
`classify`; the option is now that same shape rather than a third
invention:

```ts
countsAsAssignmentLoad?: (task: Task) => boolean
```

The scheduler resolves each assigned row against its own IR, sharing one
cache for the selection, so a board spanning three workflows reads three
IRs — not one per assigned card.

## Why this is the third round on the same parameter, stated plainly

1. I added the parameter and **never wired the caller** — inert in
production.
2. I wired it as a **union of wip+review**, which dropped hold/intake
and made it a *regression* for backlog work.
3. I resolved it from **one workflow** and applied it to all — this fix.

Each round was a smaller version of the same error: treating a lane
answer as global when it is per-task, and per-role when it is
per-membership. Worth recording because the first two rounds both looked
correct and both passed their tests — the tests asserted the renamed
case I was thinking about, not the shape of the data.

## Verification

- new cross-workflow case; reverting the predicate to a single
workflow's lanes **fails it**
- `agent-assignment` suite **14 passed**
- `pnpm test:gate` — **161 / 13 / 487 / 71** · lint clean · census
`--strict` exits 0

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 11:06:20 -07:00
gsxdsm
6bb5e4f787 test(engine): measure the optional-role-parameter conversion class (#2798)
## What

One new live-PostgreSQL E2E suite, 4 tests. **No production file is
touched** — evidence, per the E2E worker's remit. Follows #2795, which
found the first instance of this pattern.


`packages/engine/src/__tests__/workflow-optional-role-param-caller-audit-live-e2e.pg.test.ts`

## The finding

#2795 showed a conversion pattern the lifecycle-column census cannot
see: a role question migrated into an **optional parameter whose default
is the legacy literal**, converted at some call sites and not others.
This shows it is not a one-off, and measures it.

| seam | call sites passing the resolved answer |
|---|---|
| `shouldHoldActiveFileScopeLease` | **2 of 4** (both `scheduler.ts`;
neither `self-healing.ts`) — #2795 |
| `evaluateParkedAgentTaskLink` | **2 of 6** (`scheduler.ts`,
`task-agent-sync.ts`; neither `agent-heartbeat.ts` ×2 nor
`self-healing.ts` ×2) — this PR |

The second is the more damaging, and the callee's own FNXC note already
names the outcome: without the resolved columns "the card would be
treated as unparked and its live agent link cleared" — **a stale-link
bug turned into a dropped-link bug**. Driven here: a card parked in a
renamed board's hold column, with live execution proof, has its agent
link dropped.

### Why the census is blind to it

The callee is converted and its default is correctly marked
`DELIBERATE-LITERAL` — for an unconverted caller that default genuinely
*is* the intended behaviour. **The unconverted call sites contain no
column literal at all**; it lives one function away. So the census
counts the callee's annotated literals and sees nothing at the call
sites, and the conversion reads as complete from every angle except
running it.

This is a *class*, not two bugs. The same shape exists at roughly twenty
seams (`revertableColumns`, `plannerColumns`, `roleColumn`,
`terminalColumns`, `activeColumns`, …). Two are now measured. I checked
two others I flagged as unknown in #2795 —
`restart-recovery-coordinator.ts`'s `isReviewColumn?` and the
`isRecoverableMissingWorktreeReviewFailure` family — and **their callers
are fully converted** (`extension.ts:1924`, `task.ts:1390`,
`self-healing.ts:12087`), though the doc comment claiming `extension.ts`
"still asks with the literal" is now stale. The rest are unaudited; the
audit case is written so adding a seam is a small edit.

## Scope, stated honestly

Three cases are driven end to end: real persisted rows from a live
store, the real exported predicate, both call shapes. The **call-site
split is asserted against source text** — reaching all six sites needs
the heartbeat and self-healing harnesses, which I did not build, and the
audit case says so in its own comment rather than dressing it up.

It is an alarm in **both** directions: a new unconverted caller pushes
the count up and fails; converting an existing one pushes it down and
also fails. The second is deliberate — that is the moment someone should
read the three behavioural cases and update the number on purpose.

## Mutation-verified

Flipping the callee's default from the legacy parked pair to
`["backlog"]`:

| case | result |
|---|---|
| CONTROL (default board, no options) | **fails** |
| CHARACTERIZATION (renamed board, no options) | **fails** |
| BOUND (renamed board, options passed) | passes — correct, the argument
overrides the default |
| AUDIT | passes — correct, it is a source assertion |

## Not done, and why

**No fix.** Passing the resolved columns at the four unconverted sites
means resolving each linked task's traits inside the heartbeat and
self-healing paths — async work in loops that already hold locks — and
both files belong to other workers. The differential says exactly what
the fix should make true.

## Verification

- new suite — **4/4 passed**, mutation matrix above
- full live-PG E2E surface — **137/137 passed** (133 on main + 4)
- `pnpm lint` — clean

Lane: `.pg.test.ts`, skipped via `pgDescribe` when no PostgreSQL is
reachable, so the merge gate is unaffected. Throwaway per-file database;
never port 4040.

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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 11:03:09 -07:00
gsxdsm
60054aab0a test(engine): live-PG evidence of an inert conversion at the CALL SITE (#2795)
## What

One new live-PostgreSQL E2E suite, 4 tests. **No production file is
touched** — evidence, per the E2E worker's remit.


`packages/engine/src/__tests__/workflow-file-scope-lease-caller-gap-live-e2e.pg.test.ts`

## Why this is a different finding, not a sixth of the same one

#2789/#2791/#2792/#2793/#2794 all concern **one** mechanism: a site
resolves the workflow synchronously and silently gets the default board.
This is a **second** mechanism, and neither the lifecycle-column census
nor the sync-resolver allow-list can see it.

`shouldHoldActiveFileScopeLease` was converted by turning its two role
questions into optional parameters with literal defaults:

```ts
const isWipColumn    = options?.isWipColumn    ?? task.column === "in-progress";
const isReviewColumn = options?.isReviewColumn ?? task.column === "in-review";
```

A caller that resolved the traits passes the answer; a caller that has
not gets exactly the pre-conversion behaviour. That is a deliberate
migration device and the source says so — correctly marked
`DELIBERATE-LITERAL`.

**But the migration was only half made:**

| call site | passes the resolved answer? |
|---|---|
| `scheduler.ts:1986` | ✅ `{ isWipColumn: true }` |
| `scheduler.ts:2006` | ✅ `{ isReviewColumn: true }` |
| `self-healing.ts:4525` | ❌ neither |
| `self-healing.ts:5443` | ❌ neither |

So the same predicate is right on the scheduler's path and wrong on
self-healing's. The harm is the one the function's own FNXC note
describes: on a renamed board both branches fall through, the predicate
returns false for every card, `activeScopes` stays empty, and the
dispatch path sees no overlap — *two agents editing the same files*,
which is what the overlap machinery exists to prevent. At the
self-healing sites the consequence is narrower but identical in shape: a
stale-lease reconciler concludes a live blocker holds no lease and
proceeds to clear state the scheduler would have honoured.

### Why the existing instruments are blind to it

**There is no column literal at the self-healing call sites.** The
literal lives inside the callee's default, one function away — and there
it is correct, because for an unconverted caller it *is* the intended
behaviour. A census counting `=== "in-progress"` occurrences sees the
callee's two (properly marked) and nothing at all at the call sites. The
conversion reads as complete from every angle except running it.

This generalizes: **any conversion that migrates behaviour behind an
optional parameter leaves a residue the census scores as done.** Worth a
sweep for the same shape elsewhere — `agent-assignment.ts`'s
`activeColumns?` and `restart-recovery-coordinator.ts`'s
`isReviewColumn?` are the same pattern; I have not checked whether their
callers supply them.

## Scope, stated honestly

Three cases are driven end to end: real persisted rows from a live
store, the real exported predicate, both call shapes. The **call-site
fact is asserted against source text, not driven** — reaching those
sites needs the full dependency-lease reconcile harness, which I did not
build. The last case reads the file and says so in its own comment
rather than dressing it up as an end-to-end result. It doubles as an
alarm: when those call sites are converted it fails and points at the
three cases above, which describe exactly what changes.

## Mutation-verified

Flipping the callee's default from `"in-progress"` to `"building"`:

| case | result |
|---|---|
| CONTROL (default board, no options) | **fails** |
| CHARACTERIZATION (renamed board, no options) | **fails** |
| BOUND (renamed board, option passed) | passes — correct, the option
overrides the default |
| SOURCE-LEVEL | passes — correct, it is a source assertion |

The two default-dependent cases bind to the default; the bound case
proves the override; nothing passes for the wrong reason.

## Not done, and why

**No fix.** Passing the resolved answers at the two self-healing sites
requires resolving each blocker's column traits there — an async
resolution inside a reconcile path that already holds locks, and
`self-healing.ts` is another worker's file. Flagging with a differential
that says exactly what the fix should make true.

## Verification

- new suite — **4/4 passed**, mutation matrix above
- full live-PG E2E surface — **137/137 passed** (133 on main + 4)
- `pnpm lint` — clean

Lane: `.pg.test.ts`, skipped via `pgDescribe` when no PostgreSQL is
reachable, so the merge gate is unaffected. Throwaway per-file database;
never port 4040.

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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 10:59:57 -07:00
gsxdsm
1496ba9658 test(engine): bound the inert-sync-resolution class on a live store (#2794)
## What

One new live-PostgreSQL E2E suite, 3 tests. **No production file is
touched** — evidence, per the E2E worker's remit. Closes the series:
#2789 (scheduler), #2791 (planner lanes), #2792 (custom fields), #2793
(terminal node).


`packages/engine/src/__tests__/workflow-sync-selection-blast-radius-live-e2e.pg.test.ts`

## Why this one is different

The four PRs above each proved a site broken because it resolved a
task's workflow synchronously. Read together they invite a conclusion
that is **false and would be expensive**: that every synchronous
consumer of the workflow selection is inert.

Most are not. The difference is one line of shape:

```ts
// GUARDED (correct)
store.getTaskWorkflowSelectionAsync
  ? await store.getTaskWorkflowSelectionAsync(id)
  : store.getTaskWorkflowSelection(id)

// UNGUARDED (inert)
store.resolveTaskWorkflowIrSync(id)
```

The real PostgreSQL store **does** implement the async reader, so every
guarded site takes the async arm and resolves the card's own workflow.
Only the sync IR helper — which has no async arm to fall to — is stuck
with the default.

Observed on one live store, one persisted workflow, one task:

```
hasAsyncReader   = function
SYNC  selection  = undefined
ASYNC selection  = { workflowId: "WF-001", stepIds: [] }
EFFECTIVE planReviewMaxRevisions = 9   <- the custom workflow's declared default
```

## The point

"The ternary saves them" is an inference from reading, and the whole
premise of this program is that reading is what let the class survive in
the first place. The guarded sites are exactly the ones a fleet worker
would otherwise "fix": converting a correct site costs review time,
risks behaviour, and produces a diff that looks like progress. This
makes the bound checkable in the same lane as the defects.

Guarded call sites (correct today): `workflow-settings-resolver.ts`,
`workflow-ir-resolver.ts`, `executor.ts`,
`workflow-graph-task-runner.ts`, `workflow-task-runtime.ts`, and
`board-workflows.ts` in the dashboard.

## The allow-listed family is now closed

| site | status |
|---|---|
| `scheduler.ts` | proven broken — #2789 |
| `replan-target.ts` | proven broken — #2791 |
| `task-store-helpers.ts` | proven broken — #2792 |
| `branch-and-pr-entities.ts` | proven broken — #2793 |
| `workflow-task-create-ops.ts` | **legitimately correct** — creation
runs before any selection exists, so the default IR is the right answer
|
| `lifecycle-ops.ts` | **NOT proven, stated as such** |

`lifecycle-ops.ts`'s stale-transition-pending recovery re-runs plugin
column-transition hooks against the sync IR. Driving it needs a
registered plugin hook plus a crash-simulated marker; I did not build
that harness and I am not substituting a unit test for it. Named in the
file so it is not mistaken for covered.

## Evidence discipline

- **Observed state.** Both readers called on one live store against one
persisted workflow, plus a real resolved settings value — not a spy on
which arm ran.
- **The settings default is `9`**, deliberately not the builtin's, so
the value can only have come from this workflow.
- **Mutation-verified.** Rewriting the guarded consumer to call the sync
reader directly fails **exactly** the bound arm; the two structural arms
are correctly unaffected, which is what a bound should do.

## Verification

- new suite — **3/3 passed**, mutation-verified
- full live-PG E2E surface — **136/136 passed** (133 on main + 3; #2791
landed while this branch was in flight)
- `pnpm lint` — clean

Lane: `.pg.test.ts`, skipped via `pgDescribe` when no PostgreSQL is
reachable, so the merge gate is unaffected. Throwaway per-file database;
never port 4040.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 10:56:48 -07:00
gsxdsm
90f6319b79 batch-engine tail: re-land the ASYNC half; the sync-resolved half was inert (engine −15) (#2785)
Tail of `batch-engine` (#2773). That PR merged as a squash while later
engine work was still in flight, so `self-healing.ts`, `executor.ts` and
`worktree-pool.ts` landed at their pre-conversion counts. This re-lands
**only the half that is real**, and the reason the other half is not
here is the substance of this PR.

## Census, per file (measured, `--strict` verified)

| file | main | here |
| --- | ---: | ---: |
| `engine/src/self-healing.ts` | 107 | 97 |
| `engine/src/executor.ts` | 15 | 12 |
| `engine/src/worktree-pool.ts` | 3 | 2 |
| `engine/src/ephemeral-worker-manager.ts` | 1 | 0 |
| `engine/src/agent-tools.ts` | 5 | **0** |
| `engine/src/gridlock-detector.ts` | 3 | **0** |
| `engine/src/triage.ts` | 4 | 1 |
| `engine/src/mission-execution-loop.ts` | 2 | **0** |
| **net** | | **−28** |

Baseline re-recorded; `--strict` tightened exactly these 4 entries and
no others.

## Finding: a whole class of conversions in this program is INERT, and
the census scores it as progress

`resolveTaskWorkflowIrSync` returns the **default** workflow IR for
every task in production. The sync selection reader behind it is a
PostgreSQL-cutover stub:

```ts
// packages/core/src/task-store/workflow-definitions.ts:505
export function getTaskWorkflowSelectionImpl(_store, _taskId) {
  return undefined;   // "Backend mode cannot synchronously read PostgreSQL"
}
```

So a guard written as
`resolveLifecycleColumns(store.resolveTaskWorkflowIrSync(id))?.hold`
resolves an IR, asks for a trait, and answers **from the default
workflow for every custom board** — silently. It reads as converted and
the census counts it as converted. `main` gained
`sync-workflow-ir-callsite-allowlist.test.ts` for exactly this after my
branch point; it is what caught me.

I had built three sync resolvers on that reader — `resolveMoveLanesSync`
(self-healing, executor) and a widened `resolveTaskParkedColumnsSync`
(scheduler) — reasoning that a *synchronous* `task:moved` listener needs
a *synchronous* reader. That reasoning was sound about the shape and
never checked whether the reader reads anything.

**Dropped from this PR, deliberately, and NOT re-landed anywhere:**

- `scheduler.ts` 12 → 1 (the widening; the pre-existing narrow helper on
main is untouched)
- the executor `task:moved` handler, incl. the Move-Task hard-cancel
lane comparison
- self-healing's `task:moved` fan-out,
`classifyPausedAbortWorkflowRecovery`, `reconcileInReviewBranchRebind`,
`recoverWedgedActiveMerge`, `recoverPausedAbortFailures`, and 12
single-row lane conversions

Those sites are back to their literals. The allow-list's own guidance is
the standard I applied:

> An unconverted `=== "todo"` is strictly better, because it is at least
honest about being a literal.

I did not add my call sites to the allow-list. Six entries would have
turned the gate green in two minutes and buried the defect; the list's
contract requires proving the async resolver is genuinely unreachable,
and for a fire-and-forget listener it is not — the listener can `void`
an async lane resolution the same way `NotificationService` already
does. That is the correct fix and it is a behaviour-shaped change, so it
is out of scope here.

**Fleet-wide consequence:** any conversion routed through
`resolveTaskWorkflowIrSync` is fake progress, and the census cannot see
the difference. `pnpm test:gate` can: the allow-list test is the
detector. Its passing here (161/161) is this PR's evidence that nothing
inert survived the split.

## What IS in this PR — all async-resolved

1. **`self-healing.clearStaleBlockedBy`** — lanes resolved per
**REFERENCED** task, not per iterated task. A blocker's own workflow
decides whether it is still blocking.
2. **`executor` dependency satisfaction** — resolved per **DEPENDENCY**
via `columnsWithFlag`. Preserves the load-bearing asymmetry that a
dependency in *review* already satisfies a dependent; a bulk sweep
flattens that to complete-only and deadlocks the board.
3. **`agent-tools` — the agent task tools listed FINISHED cards as
active.** `fn_task_list` says it lists "tasks that aren't done or
archived"; `fn_task_search` offers `includeDone: false`. Both filtered
on `task.column !== "done"`, so a renamed complete lane returned
finished cards as outstanding work **to an agent**, which then reasons
and acts on them. `includeArchived` was always enforced by the QUERY and
survived a rename; `"done"` was only ever a TS predicate, which is why
exactly that half broke.

Plus the two **dedup** guards in the same file. The cross-parent
diagnostic filter kept a *shipped* card as a candidate on a renamed
board, so the guard adopted it as canonical and returned `wasDuplicate:
true` — absorbing new diagnostic work into a task nobody is working on
(the eval-followup defect shape again). The defined-feature bootstrap
preflight is **not** the query-filter class: its query passes
`includeArchived: true`, so the TS predicate is the *only* archived
guard there; on a renamed archive lane the archived sibling became the
bootstrap canonical and `claimDefinedFeatureTask` then rejects the
non-live row, so a valid first task fails to be created at all.

Both dedup invariants **already had tests** — asserted against the
legacy ids only, so both passed for the very comparison being replaced.
Extended in place into vocabulary differentials rather than added as
parallel files. Two helpers rather than one parameterised one: "is this
finished?" and "is this archived?" are different questions, and merging
them would make the archived-only guard also reject completed rows.

The list/search half re-landed **with the test it originally shipped
without.** No suite exercised either tool, so the original commit's
"304/304 green" said nothing about the change — the optional-flags
failure mode exactly. Both call sites are covered; converting two copies
and testing one is the Surface Enumeration failure this program has
already hit twice.

4. **`gridlock-detector` — FALSE dependency alarms.** The gate compared
each blocker against `done`/`in-review`/`archived`; on a renamed board
all three are true for a *finished* blocker, so no dependency ever
counted as met and the detector reported dependency gridlock for tasks
that are not blocked — `notifyGridlock` then pages the operator.
Resolved per dependency using the **same five flags** as the executor's
gate (`complete`, `archived`, `mergeOrchestration`, `mergeBlocker`,
`humanReview`) — `review` is not a trait, and two gates answering "is
this dependency satisfied?" differently is a split brain. Every
pre-existing case in that file omits a workflow, so none could detect
the change; added the renamed case plus a non-vacuous companion.

5. **`triage` — its OWN copies of the same two tools.**
`createTriageTools` carries a `fn_task_list` and `fn_task_search`
byte-identical in intent to the agent-tools pair, plus a third site
filtering duplicate candidates. Same defect on all three. Reused the
(now exported) agent-tools helper rather than adding a third copy —
deliberately stronger than the two-parallel-tests reading of Surface
Enumeration, since the copies now share one implementation and cannot
drift. **Not claiming call-site coverage:** `createTriageTools` is
private and not drivable without standing up a TriageAgent; the helper
is revert-proofed, those two call sites are covered only through it.

6. **`mission-execution-loop` — a finished fix task read as LIVE,
stalling remediation.** The comment above that line states the rule it
implements: *only an open task makes duplicate triage safe to suppress.*
On a renamed board the rule inverts — a finished fix task is not
`done`/`archived`, so it reads as live, remediation for a fresh
validation failure is suppressed indefinitely, and the mission stalls
with no error surfaced.

**Not revert-proven, and I am not claiming it is.** No test reaches the
`hasLiveFixTask` branch, and the only case that mints a fix feature is
git-gated and heavyweight; building that fixture is larger than the
conversion. The change strictly *widens* the finished set (resolved
roles ∪ the two legacy ids), so default boards are byte-identical — that
is the argument for shipping it unproven, not a substitute for coverage.

7. **Four census-invisible membership guards**, each inverted on a
renamed board — `worktree-pool` (merger-managed branch reclaim could
delete a branch out from under an in-flight merge), `agent-assignment`
(assignment load counted nothing), `ephemeral-worker-manager`
(`isAgentIdle` inverted on both sides), and the dead constants their
conversion orphaned. These are `SET.has(task.column)` shapes the census
does not count, so the −15 understates them.

## Revert results (measured, each run)

| conversion | reverted → |
| --- | --- |
| `clearStaleBlockedBy` per-referenced lanes | renamed-vocabulary case
fails; stale `blockedBy` never clears |
| executor dependency satisfaction | dependent never unblocks on a
renamed review lane |
| `worktree-pool` merger-managed set | reclaim proceeds against an
in-flight merge |
| `ephemeral-worker-manager.isAgentIdle` | idle agent reads busy on a
renamed board |
| `fn_task_list` terminal filter | RENAMED case fails — shipped card
listed as active |
| `fn_task_search` terminal filter | RENAMED case fails — same,
independently |
| cross-parent diagnostic dedup | RENAMED case fails — `wasDuplicate:
true`, new work absorbed |
| bootstrap preflight archived guard | RENAMED case fails — `validate`
called with the archived sibling |
| gridlock dependency gate | RENAMED case fails — false gridlock raised
for an unblocked task |

`agent-assignment`'s widened `taskStore` type is compile-time; its
revert is a tsc failure, not a test failure — stated rather than claimed
as coverage.

## Verification

- `pnpm test:gate` — 161 + 487 + 13 + 71, all green (161 includes
`sync-workflow-ir-callsite-allowlist`)
- `npx tsc -p packages/engine/tsconfig.json --noEmit` — clean
- `pnpm lint` — clean

One commit is a pure import restore: `columnsWithFlag` arrived in a
sibling commit that built on the inert resolver and was left behind. The
engine tsconfig excludes `src/__tests__/**`, so the gate was green while
tsc was not — worth knowing that on this package a green gate is not a
green build.


## Verified NOT a gap — measured, so the next worker does not re-open
them

- **`restart-recovery-coordinator` (5 counted).** Four already take an
optional `reviewColumns` set and the counted literals are the documented
**fallback** arm, which must stay for the same reason `columnRoles.ts`
keeps its id fallback. The sole production caller
(`self-healing.ts:12151-12154`) already passes the resolved set. The
fifth is documented at the site as a re-assertion behind a `listTasks({
column: "in-progress" })` query filter. Nothing to convert.
- **`notification/notification-service` (5 counted).** Already
documented in-file as deliberately counted with no exemption marker: the
wedge-episode site needs per-task serialisation of wedge handling (a
delivery-semantics change to operator notifications), and
`isManualMergeHold` needs a pre-resolved `LifecycleColumns` threaded
through `handleTaskUpdated`, which would pay resolution on every task
update. Both are behaviour/placement judgements, not conversions.
- **`planner-overseer` (3 counted).** `resolveWatchedStage`'s two
literals are fed by `pollPlannerOverseer`, which calls `listTasks({
column: "in-progress" })` and `{ column: "in-review" }` — hardcoded
**query** filters. On a renamed board those queries return no rows, so
the predicate never sees a renamed column. Converting it alone would
drop 3 from the census and change nothing an operator can observe. The
real fix is at the query layer; that is the tracked query-filter-bounded
class, not this PR.
- **`triage:695`** reads `resolvePlannerLanes` → the allow-listed sync
IR reader. Left as an honest literal per the rule above.

**Still open in `packages/engine`, deliberately not in this PR:**
`self-healing.ts` (97, of which ~31 are the query-filter-bounded class
and the rest need per-site classification in a 13k-line file),
`scheduler.ts` (12, blocked on the sync reader above), `executor.ts`
(12), and a tail of ~13 more copies of the "is this task finished?"
question across eight small files (`agent-reflection`,
`auto-merge-finalization`, `merger-scope-auto-widen`,
`backlog-pressure-reporter`, `merger-orphan-rehome`,
`merger-integration-worktree`, `plugin-runner`, `cli-agent/*`). That
tail is a clean follow-up: one question, eight call sites, and the
exported `resolveTerminalColumnsForTasks` helper already exists for it.

That is the same discipline as the sync-resolver finding: a census
number that drops without a behaviour change is not progress, and four
of these files would have handed over exactly that.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 10:47:44 -07:00
gsxdsm
4184fde08d batch-cli-plugins: 7 guards — 3 were a foreign enum, and fn pr create refused every card on a renamed board (#2775)
`batch-cli-plugins` — the u7 worker's mega-batch: `packages/cli` +
`plugins` + anything left.

## The batch is 7 guards, and 3 of them are not guards at all

The census's per-file list gives this batch seven sites. Reading them,
**three are a foreign vocabulary the census matches on the string
alone**:

| file | site | verdict |
|---|---|---|
| `plugins/fusion-plugin-reports/store/report-store.ts` | `next ===
"archived"` ×2 | **not a column** — `next` is a `ReportStatus` |
| `plugins/fusion-plugin-reports/store/report-types.ts` | `to ===
"failed" \|\| to === "archived"` | **not a column** — same enum, its own
terminal states |

The reports plugin has its own status lineage (`draft → generating →
review_* → approved → published`, plus `failed`/`archived`) that shares
two spellings with the lifecycle vocabulary. A report is not on a board
and has no workflow, so resolving an IR there would answer a question
nobody asked. All three are marked `DELIBERATE-LITERAL` with the reason
at the site.

**This cuts the other way from #2763.** That PR establishes the census
total as a *floor* (25 membership predicates it structurally cannot
see). This is the opposite error in the same number: a foreign enum
inflating it. The total is neither a ceiling nor a floor — it is an
estimate with error in both directions, and the per-file list is worth
reading before trusting a file's count.

## Converted (census before → after, per file)

| file | before | after |
|---|---|---|
| `packages/cli/src/commands/pr.ts` | 1 | **0** |
| `plugins/…/even-realities-glasses/notifications/diff.ts` | 1 | **0** |
| `plugins/…/reports/store/report-store.ts` | 2 | **0** (deliberate) |
| `plugins/…/reports/store/report-types.ts` | 1 | **0** (deliberate) |

### `fn pr create` refused every card on a renamed board

The live defect in this batch. The gate was `task.column !==
"in-review"`, and its error told the operator to move the task to a
column their board does not have:

```
Error: Task must be in 'in-review' column to create a PR (current: signoff)
```

There is no way to satisfy that short of renaming the workflow back. Now
resolved through core's `resolveReviewColumns`, and the message names
the lanes that actually exist.

**The SET, not `lifecycle.review`.** A board may declare more than one
review lane, and a card parked in a `humanReview`-only lane is still a
card you can open a PR from. A single-id answer keeps refusing those —
the same narrowing #2728's review caught in the CLI retry gate, which is
why the test pins both lanes.

## Skipped, with the reason

**`plugins/fusion-plugin-even-cards` (2 guards) — blocked on packaging,
not on analysis.** The defect is real: `boardToDeck` filters with
`column !== "archived" && column !== "done"`, so on a renamed board
every finished card stays in the deck, fills `maxCards`, and pushes the
active cards off the display. The wearer sees a board that never
finishes anything.

I implemented the fix and **reverted it**: this plugin is not in
`pnpm-workspace.yaml` and depends only on `@fusion/plugin-sdk` — it has
no `@fusion/core` dependency, so the route cannot reach
`resolveTaskLifecycleColumns`. Adding one is a packaging change, which
this program's rules put out of scope. Shipping only the injected
parameter without a caller was the alternative, and that is precisely
the decorative conversion #2759 documents: the census would drop by 2
and the deck would keep the bug.

Flagged for whoever owns the plugin's dependency surface. The glasses
plugin next door *does* depend on `@fusion/core`, so this is a
one-plugin problem, not a plugin-wide one.

## Honest note on the glasses conversion

`diff.ts`'s completion branch is **currently unreachable** — the only
production caller (`notifier.ts`) passes `alsoNotifyOnDone: false`. So
that conversion changes nothing at runtime today. It is converted rather
than marked deliberate because the literal is not deliberate: it is
wrong, and would ship the bug the day someone turns the flag on. Stated
here rather than left for a reviewer to discover.

## Verification

- new CLI suite **4 passed**; `pr-command` + `pr-automerge-cleanup` +
`bin-pr-router` **35 passed**
- glasses plugin **181 passed (19 files)** · reports plugin **110 passed
(23 files)**
- `pnpm test:gate` — **158 / 10 / 487 / 71** · `pnpm lint` clean ·
`--strict` exits 0

**Revert proof, measured.** Restoring `if (task.column !== "in-review")`
fails 3 of the 4 new cases (`process.exit:1` on both renamed lanes, and
the refusal message reverts to naming `in-review`). The
unresolvable-workflow case keeps passing — it is the legacy path — so
the negative cases alone do not pin the fix and all four are required.

## Handoff to `batch-engine`

`packages/engine/src/project-engine.ts` **5 → 0** is finished, green,
and pushed as `handoff/project-engine-lanes-for-batch-engine`
(`34dbb35209`) for the capacity worker to cherry-pick — it is
engine-owned, not mine to land.

It fixes two live defects: a card that **had merged** reported as a
failed merge to `fn task merge` and the dashboard button (`merged:
finalTask?.column === "done"`), and the three post-finalize `column ===
"done" && mergeConfirmed` fast-path checks, which on a renamed board
sent an already-landed card down the bounce path — re-queued,
retry-counted, and in the capped branch parked `failed` with its merge
sitting on main. Plus `hasAutoHealableVerificationBufferFailure`, which
returned false for every card on a renamed board, so a buffer-overflow
verification failure was never auto-healed.

8 new tests, revert-proven (restoring the literal fails 4 of 8), gate
green.

---

## Completion pass (u7) — the batch is now closed

Two workers converged on this branch. I rebased onto the first-landed
commit rather than force-pushing over it, took its wording wherever the
conclusion was identical, and added what was missing.

### What this pass added

1. **`even-cards` (2 sites)** — the only in-scope file the first pass
left open. Marked DELIBERATE-LITERAL: the package depends on
`@fusion/plugin-sdk` only, and the SDK does not re-export the lifecycle
role helpers, so there is no IR, no store, and no trait flags to resolve
*from*. Fixing it properly means the SDK exposing role flags on the task
shape it hands plugins — a structural change, out of scope, and recorded
at the site as the correct home. Live consequence is cosmetic: a
finished card on a renamed board shows as active in the glasses deck.

2. **A red test in the `fn pr create` conversion.** The incoming version
rendered `Task must be in 'in-review' to create a PR`, dropping the word
`column`. `task.test.ts:3422` pins `must be in 'in-review' column`, so
that hunk failed `runTaskPrCreate > exits with error when task not in
in-review column`. Restoring the word makes the single-lane message
**byte-identical** to the pre-conversion one, which is what a vocabulary
conversion should be — the guard's own test now passes unmodified.
Marked at the site so it is not "simplified" back.

3. **Duplicate imports** — the two independent conversions each added
`resolveWorkflowIrForTask`/`resolveReviewColumns`, which does not
compile. Deduped in its own commit.

### Census

Measured with `--json` on `origin/main` and on this branch.

| file | before | after | action |
|---|---|---|---|
| `packages/cli/src/commands/pr.ts` | 1 | 0 | converted |
| `plugins/fusion-plugin-reports/src/store/report-types.ts` | 1 | 0 |
marked |
| `plugins/fusion-plugin-reports/src/store/report-store.ts` | 2 | 0 |
marked |
| `plugins/fusion-plugin-even-cards/src/cards/board-cards.ts` | 2 | 0 |
marked |
| `plugins/fusion-plugin-even-realities-glasses/.../diff.ts` | 1 | 0 |
marked |

Backlog **415 → 408** (−7, exactly the in-scope count). Deliberate **40
→ 46** (+6 marked); 6 + 1 converted = 7. `--strict` exits 0. **Nothing
remains in `cli` + `plugins` + everything-else — there is no follow-up
batch behind this one.**

### One note on the `even-realities-glasses` site

Worth recording beyond "cannot resolve": its only production caller
(`notifier.ts:80`) passes `alsoNotifyOnDone: false`, so that arm is
**unreachable today**. Converting it could not have changed observed
behaviour either way.

### Verification (measured, on the merged branch)

- `pnpm --filter @runfusion/fusion exec tsc --noEmit` → exit 0
- `pnpm lint` → 0 errors
- CLI `task.test.ts` → 144 passed, including the `runTaskPrCreate` guard
test
- `@fusion-plugin-examples/reports` → 110 passed;
`even-realities-glasses` → 181 passed

**Pre-existing failures, not from this change:** the 5
`runTaskImportFromGitHub` / `runTaskImportGitHubInteractive` tests fail
identically on `origin/main` — verified by stashing this diff and
re-running (5 failed / 144 passed both ways).

---

## Census audit (unowned follow-on)

After closing the batch scope I audited whether the **392**
column-backlog number is inflated by foreign vocabularies — the class
this batch found in the reports plugin, where `"archived"` is a
`ReportStatus` rather than a board lane. If that class were widespread,
every remaining batch would be chasing sites that must not be converted.

**It is not. The number is real.** A receiver-level pass over all 392
column-category sites found exactly **3** false positives, all in
`plugins/fusion-plugin-reports` (`next`, a `ReportStatus`), all now
marked in this PR.

What was checked and cleared:

- **Property-reached foreign enums** (`step.status`, `feature.status`,
`mission.status`) — already correctly bucketed into the separate
`status` category (185), not the column backlog. Verified against
`merge-queue-ops.ts`: 11 lifecycle-spelled literals in the file, census
counts **1**, and that 1 is the genuine `.column` guard.
- **Bare step-status variables** (`status`, `currentStatus`,
`liveStatus` compared to `"done"`/`"skipped"`) — likewise excluded.
- **Every other receiver in the backlog** — `to`, `from`, `column`,
`fromColumn`, `toColumn`, `latestColumn`, `state`, `preArchiveColumn`.
All resolve to genuine task columns. `executor.ts`'s 15 sites were
spot-checked line by line: all 15 are real.

The gap the classifier genuinely cannot close is a foreign enum held in
a **bare variable** — the receiver name carries no type information, so
`next === "archived"` is indistinguishable from a lifecycle guard by AST
alone. That is why the reports sites need a marker rather than a
classifier fix, and it is now documented in
`lifecycle-column-census-ast.mjs`'s header alongside the measured scope,
so the remaining batches do not re-run this hunt.

Census tests: **43 passed**. The change is comment-only.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 10:38:39 -07:00
gsxdsm
6bdde6f246 fix: five lifecycle gates the census cannot see — incl. live ephemeral workers reaped and duplicate follow-up cards (#2787)
Five lifecycle-column fixes the census **structurally cannot see**. Each
gate is a `Set` or array literal — a *definition*, not a comparison — so
no backlog entry ever pointed at any of these files. Found by grepping
for lane-shaped list literals after the same shape surfaced in
`duplicate-intake` and `blocker-fanout` (both merged via #2780), then
confirmed by reading each USE site.

**On opening this:** I offered twice to fold these into a PR and kept
them on handoff refs to respect one-open-PR-per-worker. They have now
sat unadopted across several cycles while `main` moved, and two of them
destroy or duplicate work. Opening is the reversible call — **close it
if it breaks queue policy** and I will keep them on the branch.

## What is in it

| commit | defect on a renamed board | severity |
|---|---|---|
| `beb107a7bc` | assignment load-balancing **defeated** —
`assignmentLoad` stays empty, every candidate reads as load 0, the sort
falls through to its stable `createdAt` tiebreak, so **one agent wins
every assignment** while the rest idle | distribution |
| `cf4b59e1cb` | the zombie sweep **deletes LIVE ephemeral workers** |
**destroys work** |
| `5fe004ae64` | eval follow-up dedup sees **zero open tasks**, so every
run re-files follow-ups it already filed | **duplicate cards** |
| `a1021de8b2` | agents keep a **"working on" indicator for finished
cards** | stale UI |
| `86680d1220` | the **Files tab never loads** — the fetch never fires |
silent empty |

### The one that destroys work

`shouldDeleteOnSweep` tested a hard-coded terminal `Set`, then fell
through to `return task.column !== "in-progress"`. On a renamed board
**both halves miss, and they compound in the worst order**: the terminal
test fails, control reaches the fallthrough, and `"building" !==
"in-progress"` is `true`. An ephemeral worker **actively executing a
task** is classified as a zombie and deleted. Nothing logs.

Its fallback is **deliberately asymmetric**, and the comment says why:
an unresolvable workflow keeps the legacy literals rather than guessing.
Failing to reap a dead worker costs a slot; reaping a live one destroys
work in flight. Those are not symmetric, so uncertainty fails toward
keeping the worker.

## Verification

Verified **as a set**, not only per-branch:

- `pnpm test:gate` — **161 / 13 / 487 / 71**
- engine suites (assignment, ephemeral, eval-followups) — **44 passed**
- dashboard suites (agent-task-link, useSessionFiles) — **16 passed**
- `tsc` engine + dashboard server + dashboard app — clean
- `pnpm lint` clean · census `--strict` exits 0

**Revert-proven individually.** Restoring each literal fails its own
case: the renamed-wip zombie case, the renamed-wip assignment case, the
renamed-lane dedup case, the sanitizer ratchet, and both
`useSessionFiles` role cases.

## Two honesty notes, flagged rather than buried

**`a1021de8b2`'s guard is STRUCTURAL, not behavioural.**
`sanitizeAgentTaskLinks` is a closure inside `createApiRoutes`,
reachable only by standing up the full express app. The ratchet asserts
the source — resolver threaded per task, bare literal call gone, cache
shared, fallback retained — and **fails on revert**, verified. It is not
a substitute for a behavioural test; whoever owns the dashboard server
should add one if that seam grows.

**`useSessionFiles`'s negative case passed in isolation and failed in
the suite.** Hooks are not unmounted between cases there, so a prior
case's in-flight fetch landed inside it. That is the classic shape of a
test that gets "fixed" by reordering; it now asserts a **delta** against
the pre-render call count, which is independent of what leaks in.

## Deliberately NOT included

`worktree-pool.ts:1205` — the sixth site from the same sweep. It **fails
safe**: a missed match means the skip does not fire, so the branch is
added to `activeBranches` and *protected* from cleanup. The cost is
stale branches accumulating, not deletion. It also sits in the merger's
branch-reaping path, where the opposite error destroys work, so it
deserves its owner's judgement rather than a drive-by conversion.
Flagged, not guessed.

Also still open and unclaimed: roughly 69 untriaged literal-list sites
across engine/dashboard/cli. The grep is one line and the file list is
on #2775 — with the measured caveat that about half are false positives
on shape alone (`LEGACY_*` names, seeds unioned with resolved values,
and `roles: ["triage"]`, which is an `AgentCapability`, not the deleted
column). Only the use site settles it.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 10:35:31 -07:00
gsxdsm
dcf9900d61 test(engine): live-PG differential between resolvePlannerLanes and its async twin (#2791)
## What

One new live-PostgreSQL E2E suite, 5 tests. **No production file is
touched** — evidence, per the E2E worker's remit. Follows #2789 (same
defect class, different site).


`packages/engine/src/__tests__/workflow-planner-lanes-sync-vs-async-live-e2e.pg.test.ts`

## The finding

`replan-target.ts` exports two functions with identical logic and
identical fallbacks, differing only in how they obtain the task's IR:

```
resolvePlannerLanes(store, taskId)              -> store.resolveTaskWorkflowIrSync(taskId)
resolvePlannerLanesForTaskAsync(store, taskId)  -> await resolveWorkflowIrForTask(store, taskId)
```

Under PostgreSQL the sync selection reader answers `undefined` for every
task, so the sync twin resolves the **default** workflow for every card
regardless of the board it is on. **Nine production call sites use it**
(2 × `executor.ts`, 7 × `triage.ts`); one uses the async twin.

The module's own doc comment argues this, and the store-level fact is
proven in `sync-workflow-ir-is-always-default.pg.test.ts`. What had no
executable evidence is the consequence **at this seam** against a real
store with a real persisted workflow. That is this file — a pure
differential: both twins, same store, same task, same call.

### Two harms, different severity

1. **Wrong lanes, labelled authoritative.** `resolvedFromWorkflow`
exists to tell a caller "these came from the workflow, not the
fallback". The sync twin sets it `true` — an IR did come back — while
handing over the default board's ids. A caller that correctly checks the
flag before trusting the lanes is misled *precisely by checking it*,
which is strictly worse than the honest `false` an unresolvable store
would give.
2. **Invented forward lanes.** `wip`/`review`/`complete` are optional so
a caller *refuses* rather than moving a card into a column the board
does not declare (PR #2628's review). The sync twin defeats that
contract without touching it: never having seen the real board, it
reports the default board's forward lanes as present. The optionality is
intact in the type and unreachable in practice. The sharpest arm: a
board declaring **no** review lane gets `undefined` from the async twin
and `"in-review"` from the sync twin.

### A correction worth carrying forward

"It falls back to the legacy lanes" is the wrong mental model **twice
over**. `LEGACY_PLANNER_LANES` (`hold: "todo", intake: "triage"`) is
reached only when no IR resolves at all — under PostgreSQL, never. What
a caller actually receives is the **post-U11 merged default**, whose
intake and hold are one `todo` lane. So the sync twin does not return
`triage` for intake; it returns `todo`, and a caller reading `intake`
gets not merely a wrong id but a lane that is not a dedicated intake at
all.

This is also why the control arm uses `MERGED_VOCAB`: the shape the
twins agree on is the merged one. `DEFAULT_VOCAB`, which splits intake
out as `triage`, already separates them.

## Evidence discipline

- **Observed state.** These are exported pure functions over a live
store; the observation is their return value against a persisted
workflow definition. No spies, no mock IR anywhere in the file. Contrast
the unit coverage in `planner-lanes-async-resolution.test.ts`, which
must supply a mock `resolveTaskWorkflowIrSync` and therefore cannot see
this divergence at all.
- **Control arm.** On the post-U11 default shape the twins agree exactly
— which is why this survived: every default-board test passes and only a
renamed board separates them.
- **Characterization, not endorsement.** The four renamed arms assert
the wrong-but-current values deliberately; they flip when the call sites
move to the async twin, and that flip is the point.

### Mutation-verified, including a round that found weak arms

Replacing the sync twin's whole body with `return LEGACY_PLANNER_LANES`:

| | arms failing |
|---|---|
| first draft | **3 of 5** |
| after strengthening | **5 of 5** |

Two arms originally asserted only `wip`/`review`/`complete`, which are
identical in the merged default IR and in `LEGACY_PLANNER_LANES` — so
they proved the lanes were wrong without proving *why*, and survived the
mutation. Each now also pins `intake` (`todo` merged vs `triage`
legacy), the single field that separates "resolved the wrong board" from
"took the fallback". Recorded in the file next to the assertions.

## Not done, and why

**No fix.** The async twin already exists and is documented as a drop-in
("identical logic and identical fallbacks — the ONLY difference is
awaiting the authoritative resolver"), so the migration is mechanical
*where the caller is already async*. It is not universally so: several
`triage.ts` sites are inside synchronous paths, and `triage.ts:831`
calls `resolvePlannerLanes(this.store, "")` with an empty task id — a
sweep-wide lane read that has no single task to resolve against and
needs a decision, not a mechanical swap. Both are behaviour calls in
files another worker owns; flagging, not smuggling.

## Verification

- new suite — **5/5 passed**, mutation-verified 5/5
- full live-PG E2E surface, 20 suites — **126/126 passed** (was 121/121)
- `pnpm lint` — clean
- `pnpm check:lifecycle-columns` — exit 0

Lane: `.pg.test.ts`, skipped via `pgDescribe` when no PostgreSQL is
reachable, so the merge gate is unaffected. Throwaway per-file database;
never port 4040.

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


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

## Summary by CodeRabbit

* **Tests**
* Added end-to-end coverage comparing synchronous and asynchronous
workflow lane resolution.
* Validated lane consistency for renamed, non-default, and custom boards
using persisted workflow data.
* Added checks for incorrect fallback lanes, workflow resolution
indicators, and absent review lanes.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 10:32:24 -07:00
gsxdsm
87442b9664 test(engine): live-PG evidence that declared custom fields cannot be written (#2792)
## What

One new live-PostgreSQL E2E suite, 4 tests. **No production file is
touched** — evidence, per the E2E worker's remit. Third in the series
after #2789 and #2791; same root cause, materially worse consequence.


`packages/engine/src/__tests__/workflow-custom-fields-sync-resolution-live-e2e.pg.test.ts`

## The finding

**A workflow that declares custom fields cannot have any of them
written.**

`TaskStore.resolveTaskCustomFieldDefsSync` reads a task's field
definitions through `store.resolveTaskWorkflowIrSync`, which under
PostgreSQL answers `undefined` for every task and therefore resolves the
**default** workflow IR. The default declares no `fields`, so the
function returns `[]` for every task on every board. `task-update.ts`
validates every write against that empty list:

```ts
const defs = store.resolveTaskCustomFieldDefsSync(id);
const result = validateCustomFieldPatch(defs, updates.customFields);
if (!result.ok) throw new CustomFieldRejectionError(result.rejection);
```

Observed against a real store with a real persisted workflow declaring
one `text` field:

```
STORED fields = [{"id":"risk","name":"Risk","type":"text"}]
SYNC   defs   = []
WRITE  threw  = CustomFieldRejectionError
                custom field 'risk' rejected (no-fields-defined):
                the resolved workflow declares no custom fields; no values may be written
```

The rejection message is a true statement about the workflow that got
resolved and a false one about the workflow the card is on.

### The two halves of the feature disagree in production

The executor resolves the same definitions through the **async**
resolver (`executor.ts` → `resolveTaskCustomFieldDefs` →
`resolveWorkflowIrForTask`) and sees the real field. So an agent can be
prompted to supply a value that the store will then refuse to store. The
last case asserts both answers against **one store, one task, one
workflow** — which is why this cannot be dismissed as a fixture
artefact.

This is a different severity from the previous two PRs in the series.
#2789 and #2791 are wrong-lane defects, mostly latency, one of them
unbounded. This one is a declared feature that does not function off the
default board.

## Scope on record

Three write paths share the sync resolver: `task-update.ts` (driven
here), `workflow-task-create-ops.ts:394`, and `workflow-ops.ts:488`.
Only the first is exercised; the other two are named in the file so the
surface is recorded rather than implied.

Also worth stating plainly: because the empty list *is* the default IR's
`fields`, the same rejection is what a default-board card gets too. The
feature is not merely renamed-board-broken.

## Evidence discipline

- **Fixture integrity first.** The opening case asserts the stored
workflow really does declare the field via the async resolver. Every
other assertion is about a *missing* definition and would pass just as
well against a workflow that never declared one — that case is what
makes the rest mean something.
- **Observed state.** The thrown typed rejection plus the **absence** of
a persisted value on a re-read row. No spy on the validator.
- **Mutation-verified.** Replacing the sync resolver's body with a
hardcoded `[{id:"risk",…}]` fails **3 of 4** arms. The fourth is the
fixture-integrity case, which exercises the async path by design and
correctly survives.

## Not done, and why

**No fix.** The async resolver already exists and is already used by the
executor for the same data, so the shape of the fix is clear — but
`task-update.ts`'s validation runs inside a synchronous update path, and
making it async is a behaviour decision in `@fusion/core` that belongs
to that file's owner, not to a smuggled edit in an evidence PR. The
call-site allow-list entry for `task-store-helpers.ts` ("Synchronous
helper shared by txn-hot paths") should cite this suite either way: the
entry is accurate about the constraint and silent about the cost.

## Verification

- new suite — **4/4 passed**, mutation-verified 3/4 (fourth by design)
- full live-PG E2E surface, 20 suites — **125/125 passed** (121 on main
+ 4)
- `pnpm lint` — clean

Lane: `.pg.test.ts`, skipped via `pgDescribe` when no PostgreSQL is
reachable, so the merge gate is unaffected. Throwaway per-file database;
never port 4040.

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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 10:32:12 -07:00
gsxdsm
755ada91ac test(engine): live-PG evidence that the terminal-node guard fires on the wrong node (#2793)
## What

One new live-PostgreSQL E2E suite, 3 tests. **No production file is
touched** — evidence, per the E2E worker's remit. Fourth in the series
after #2789 (scheduler), #2791 (planner lanes), #2792 (custom fields).


`packages/engine/src/__tests__/workflow-terminal-node-sync-resolution-live-e2e.pg.test.ts`

## The finding

FN-7641 Signature 2 exists because setting `nodeId` to the terminal node
used to be written verbatim and silently do nothing — the card sat in
review with every step done, unadvanced and unexplained. The contract: a
terminal override **with** durable merge proof finalizes the card;
**without** proof it is rejected with an actionable error; non-terminal
overrides are untouched.

On a board whose terminal node is not called `end`, **both halves
invert**:

| write | contract says | actually observed |
|---|---|---|
| `nodeId: "end"` — an ordinary planning node here | written, untouched
| **rejected** with a merge-proof error about finalizing a card the
operator was not finalizing |
| `nodeId: "finish"` — this board's real `end`-kind node | finalize, or
reject | **written verbatim**, no error, card left in review |

The second row is the original FN-7641 bug, restored on every custom
board.

## The correction the mutation runs forced

My first draft blamed `isTaskTerminalNodeIdImpl`'s sync IR resolution
alone. Mutating it changed only one of the two cases, which is how I
found there are **two** guards:

```
branch-and-pr-entities.ts:568   validateNodeOverrideChange(task, nodeId, { isTerminalNodeId })
                                -> sync IR resolution (the default board, under PostgreSQL)
task-update.ts:53               validateNodeOverrideChange(task, nodeId)
                                -> NO options, so `defaultIsTerminalNodeId` — the bare literal
                                   `nodeId === "end"`
```

The inner one is an unconverted literal sitting behind a converted call
site, and it silently overrides it. **Converting the outer guard alone
changes nothing an operator can see.** A column census cannot find the
inner one either — `end` is a node id, not a column. This is the "a
guard survives in a branch of the same function" shape, one function
apart.

### Mutation matrix

| corrected | `end` rejected | `finish` silent |
|---|---|---|
| *(nothing — main)* | pass | pass |
| outer sync-IR guard only | pass | **fail** |
| inner `defaultIsTerminalNodeId` only | pass | **fail** |
| **both** | **fail** | **fail** |

Two different failure structures, which is why the cases are kept apart:

- **`end` rejected is over-determined** — both guards independently call
it terminal, so it survives a mutation of either one. Not a weak
assertion: a faithful record of a defect with two independent causes,
and the reason a partial fix here is invisible.
- **`finish` silent is under-determined** — both guards must miss the
id, so correcting either flips it. This is the arm that notices a
partial fix.

The fixture-integrity case exercises the async resolver by design and
correctly survives every mutation.

## Fixture

The shared builder's terminal node is `end`, so it cannot express this
shape. This file derives from it: one `lifecycleIr`, node ids shifted so
the `end`-kind node is `finish` and the non-terminal planning node takes
the name `end`. Columns, traits, edges and structure are otherwise the
builder's, so the only variable is which node ids carry which kind. The
first case asserts that shift really happened — both characterizations
are claims about which node is terminal and would read as defects if the
fixture had quietly kept the builder's ids.

## Evidence discipline

- **Observed state.** Whether `updateTask` throws, and what the re-read
row's `nodeId` and `column` actually are. No spies.
- **Characterization, not endorsement.** Both cases assert the
wrong-but-current behaviour deliberately, and the matrix above says
exactly which fix flips which.

## Not done, and why

**No fix.** It needs two coordinated edits in `@fusion/core` — threading
the resolved terminal check into `task-update.ts:53`, and making the
outer resolution async — and the second is the same synchronous-path
constraint as #2792. Both are behaviour decisions in another worker's
files. Worth flagging that fixing only the allow-listed sync site would
look like progress and deliver none, which the matrix above makes
checkable.

## Verification

- new suite — **3/3 passed**, mutation matrix above
- full live-PG E2E surface — **124/124 passed** (121 on main + 3)
- `pnpm lint` — clean

Lane: `.pg.test.ts`, skipped via `pgDescribe` when no PostgreSQL is
reachable, so the merge gate is unaffected. Throwaway per-file database;
never port 4040.

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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 10:29:04 -07:00
gsxdsm
59b5e61fa2 fix(tests): the last CLI reds — import assertions still required the column U11 removed (#2788)
## What was red

All 5 failures in a full `@runfusion/fusion` run on `origin/main` (`5
failed / 1673 passed`), in `src/commands/__tests__/task.test.ts`:

```
- "column": "triage",
```

## The product change is intentional and documented

**#2603 (U11)** removed the hardcoded `column: "triage"` from the
GitHub/GitLab import writes so `createTaskImpl` resolves the
**workflow's** intake column instead. Passing `column` would override
that resolution and, post-U11, name a lane the default workflow no
longer declares.

`task.ts` still carries the note at three sites:

> `createTaskImpl` resolves the WORKFLOW'S intake column, and
`input.column` would override it. Hard-coding `"triage"` created the
card in a column the default [workflow does not declare].

Six `toHaveBeenCalledWith` assertions still required the removed
literal, so a correct product change surfaced as five CLI failures.

## Scoped deliberately

Only the **six assertion-side** occurrences are removed. The other
**16** `column: "triage"` literals in this file are mock *return* values
and `makeTask` fixtures, and they stay — what a created task comes
*back* as is a different question from what the import *asks for*, and
blanking them would weaken unrelated cases.

## Evidence

- Full CLI package: **1678 passed / 106 skipped, 125 files green** (was
5 failed).
- **Mutation:** reintroduce `column: "triage"` into the import write →
**2 failed**. The assertions still pin the invariant rather than having
been loosened into always-true — the thing worth checking when a fix is
"delete an expectation".
- Gate **732 green** · `pnpm lint` clean. Test-only (mutation reverted;
`git diff` clean).

## Ownership

`packages/cli` belongs to the **batch-cli-plugins** owner (u7) under the
mega-batch split. This is fix-forward on a red rather than a conversion,
confined to one test file, and touches no production code.

With #2779 and #2786 this leaves engine, core and CLI at **0 failures**
on main. The remaining known reds are the 123 dashboard failures
documented in #2784.

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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 10:20:01 -07:00
gsxdsm
02d0f80068 fix(tests): update the archived-gate inventory after #2780's conversions (#2786)
## New red on main

`archived-column-gate-parity.test.ts` fails with **"TypeScript encoding
changed"** after batch-core (#2780). It is the only failure in a full
`@fusion/core` run (`1 failed / 4748 passed`).

## The conversions are right — this is their missing half

#2780 moved four files off raw `column === "archived"` comparisons onto
`isTerminalColumnRole`, exactly the intended role-based pattern:

| file | before → after |
|---|---|
| `assigned-task-ranking.ts` | 1 → 0 |
| `duplicate-intake.ts` | 1 → 0 |
| `near-duplicate-canonical.ts` | 1 → 0 |
| `store.ts` | 2 → 1 |

(The literal still in `duplicate-intake.ts` is a `moveTask`
**destination**, not a gate comparison — a different question, like the
planner-lane move targets.)

The guard's own failure text asks for the inventory to be updated **in
the same commit** as a conversion. That did not happen, so the ratchet
went red on main. This PR is only that bookkeeping.

## Verified NOT a split-brain

That is the thing this file exists to catch — TypeScript moving to the
resolved role while the SQL sides keep comparing the raw string, so on a
renamed board one says a task is archived and the others return it as
live.

The Drizzle and raw-sql inventories are **unchanged and both pass**.
Worth stating explicitly because those two assertions run *after* the
TypeScript one: a plain red tells you nothing about them, they had to be
re-run green to know.

## The guard still bites

Appending a real `task.column === "archived"` to an audited file fails
it immediately.

**Recorded because it nearly fooled me:** my first two mutation attempts
*passed*, which looked like a guard blind to new comparisons — a much
worse finding than a stale inventory. Both had been inserted at line 2
of a file whose line 1 opens a JSDoc block, so my "code" was comment
text and was never compiled. **A mutation that does not compile is not
evidence of anything.** Appended at end of file instead, the guard fails
on the first run.

Gate **732 green** · lint clean. Test-only.

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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 10:13:55 -07:00
gsxdsm
e467d939a5 fix(tests): the last 3 engine reds — a pause guard asserted at the wrong layer (#2779)
## What was red

The final 3 failures in `executor-prompt.test.ts` ("global pause
behavior"), all the same assertion:

```ts
expect(mockedCreateFnAgent).not.toHaveBeenCalled();
```

made after calling `executor.execute(task)` **directly** on a paused
todo row.

## It is not a regression, and not a live safety hole

I initially flagged this in #2778 as a possible live hole — "a
user-paused todo task **now** reaches `createFnAgent`". **That framing
was wrong**, and the bisect is what corrected it:

| commit | result |
|---|---|
| `origin/main` (HEAD) | fail |
| `main~40` | fail |
| `main~80` | fail |
| `main~150` | fail |
| `main~250` | fail |

Red 250 commits back. It never described shipped behaviour, so nothing
regressed.

**`execute()` holds no pause gate.** Neither `executeCore` nor the
workflow-graph executor consults `paused`/`userPaused` before starting a
session — I checked both. Refusing to dispatch a parked row is the
**scheduler's** invariant, enforced twice:

1. Candidacy is keyed on both flags (`scheduler.ts:138`) — `userPaused`
is a durable operator stop even when legacy `paused` is false.
2. The row is **re-read immediately before dispatch** and refused if it
comes back parked (`scheduler.ts:2086`) — this closes the race the first
check cannot.

The test called `execute()` directly, stepping around the component that
owns the guarantee, then asserted the bypassed layer enforced it. A true
statement about the system was being made to look false.

Every protective outcome #2371 documented **does** hold and stays
asserted: `fn_task_done` never completes the card, it is never handed to
`in-review`, no completion watchdog is armed, the pause is never
cleared, and the run narrates the benign paused park. Only *"no session
was created"* was false. The 3 sibling assertions in `resumeOrphaned`
are untouched — that path genuinely does refuse.

## The invariant moves to the layer that owns it

Rather than delete an assertion and lose the coverage,
`scheduler-paused-dispatch-refusal.test.ts` pins it through real
`schedule()` passes:

- **control** — an unparked ready card IS dispatched
- refuses a row parked with legacy `paused`
- refuses a row parked with `userPaused` alone
- refuses when the operator pauses **after selection, before dispatch**

Driven through `schedule()` rather than by calling the predicate
directly: a test that calls the guard cannot tell whether the dispatch
path still *consults* it — which is precisely how the executor-prompt
version came to assert a layer that had stopped being asked.

## The control earned its place on the first run

It failed immediately, and twice over: the hold-release gate refuses a
card still carrying a bootstrap seed (fixed with the shared
`seedPlannedSpec`), and a `moveTaskIf` stub returning `moved: false`
makes the release unobservable. Without the control, all three refusals
would have passed **vacuously** — a scheduler that dispatches nothing
refuses everything.

## Evidence

- **106/106** across both files.
- **Mutation:** removing the pre-dispatch pause re-read → the race case
fails. The other two are caught earlier by candidacy (defence in depth);
the passing control makes their refusal attributable to the flag alone,
since the same store dispatches without it.
- Gate **732 green** · lint clean · engine `tsc --noEmit` **0 errors**.
Test-only (the scheduler mutation was reverted; `git diff` clean).

## Engine suite status

Measured baseline on `origin/main`: **39 failures / 10835 passed**. With
#2776 (32, notifier harness) and #2778 (4), this last set takes the
engine suite to **0 failures**.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 10:04:52 -07:00