Commit Graph

12445 Commits

Author SHA1 Message Date
gsxdsm
6ca7cc94ec triage census — core/types/archive-planning.ts 1→0, plus the self-healing.ts audit (10 guards, 2 traps a mechanical conversion would miss) (#2622)
Batched: one conversion plus the audit for the largest remaining file,
so the CAPACITY worker inherits the analysis instead of redoing it.

## Per-file counts

| File | Before | After |
|---|---:|---:|
| `packages/core/src/types/archive-planning.ts` | 1 | **0** |

Verified with the raw pattern (`column [!=]== "triage"`), which is what
the coordinator greps — including checking that my own explanatory
comment did not reintroduce the literal. It did, on the first attempt;
caught and removed before pushing.

## The conversion: a doc that manufactures dead guards

There is **no executable guard** in this file — raw 1, code 0. I fixed
it anyway, because the documentation was wrong in the way that
propagates: it told consumers to derive running agents via a hardcoded
intake-column comparison. Post-merge the default lineage declares one
Planning column and no `triage`, so anyone implementing from that
sentence writes a comparison that matches nothing — **a dead guard
authored on purpose, from an instruction we left lying around.** A doc
handing out a dead predicate is worse than a dead guard, because it
manufactures more of them.

Now describes roles. Also disambiguates the neighbouring line where
"triage agent" is a lane/role id, not a column — the same conflation
that accounts for 23 of the original broad 48.

## Audit: `self-healing.ts` (10 guards, unclaimed at time of writing)

45% of the remaining bar, and every one sits in a recovery sweep. **6 of
the 10 are sole-`triage` and already dead for default-workflow cards.**

| Line | Guard | Fires for default cards? | What silently stops |
|---|---|---|---|
| 2964 / 2984 / 3019 | advanced-triage recovery: filter, live re-check,
`moveTaskIf` CAS | **No** | stranded specification work never recovered
|
| 12173 / 12494 | orphaned-approved + orphaned-planning sweeps | **No**
| orphaned planning sessions never reaped |
| 12321 | `task_refine` candidates | **No** | refinement tasks never
recovered |
| 9218, 10703, 11282, 12218 | paired with `todo` | Yes, via the `todo`
arm | — (legacy-compat arms) |

**Two traps a mechanical conversion walks straight into:**

1. **`listTasks({ column: "triage" })` at 12172 and 12493 is a dead
QUERY, not just a dead filter.** Convert only the `t.column ===
"triage"` predicate and both sweeps scan an empty result set — the file
counts as converted while the sweeps stay exactly as dead. This is the
coordinator's rule #2 in its most literal form: a guard surviving in
another branch of the same function.

2. **Lines 2964 / 2984 / 3019 are one transaction** — filter, live
re-verify, and a `moveTaskIf` compare-and-set. Convert them
independently and you get a filter matching the resolved intake column
against a CAS still demanding the literal, so **every move refuses**.
Silently: `moveTaskIf` returning false is indistinguishable from a lost
race.

Both need the resolved-vs-guessed distinction from **#2618** — a
`builtin:legacy-coding` card in `triage` must still be recovered when
the store cannot name its workflow.

## Related live finding, not fixed here

`resolvePlannerLanesForTask` (merged in #2610; used by
`executor.ts:11978`, `scheduler.ts:1843`/`:2414`,
`mission-autopilot.ts:973`) cannot tell a resolved workflow from a
guessed one. Probe on main against a `{ getTask }`-only store:

```
PROBE lanes: ["todo"]   dedicated: []
```

So a `builtin:legacy-coding` card in `triage` is not recognised as a
planner lane — mission-feature rollback stops firing and the
spec-staleness planner skip never fires. Neither errors. #2618 is the
fix; ~3 lines per resolver in `planner-lane-resolution.ts`.

## Verification

`tsc --noEmit` on `@fusion/core` clean; `pnpm lint` clean. Comment-only
change, no behaviour change, no changeset.

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

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

## Summary by CodeRabbit

* **Documentation**
  * Clarified terminology for archived task planning overrides.
* Updated project health documentation to better explain how active
agent counts are calculated across workflow stages.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 22:23:45 -07:00
gsxdsm
73338502e5 fix(test) + E2E: re-green main's lifecycle release leg, and prove the MERGED board + REVISE rework (#2634)
**Second batch.** Three commits, no production code. `pnpm test:gate`
green, `pnpm lint` clean, all three E2E suites together **3 files / 41
tests, exit 0**.

## 1. main's lifecycle E2E is RED right now — this fixes it

Independently of my work, on a detached `origin/main`: **2 failed / 18
passed**. Scenarios 1 and 2 fail with `sweep.released` **empty**.

**Cause:** `seedTask` relied on task creation's PROMPT.md, which is a
bootstrap seed (`"# <id>\n\n<description>"`). FN-7648's
`isUnplannedForExecution` reads that file for any card resting in an
intake- **or** hold-trait column and refuses to move an unplanned card
into a processing column. The sweep reported `held: [{ reason:
"move-rejected-or-no-slot" }]`.

**That is the gate working.** The fixture was asking the scheduler to
release a card that had never been specified. The fix is the one the
graph-entry contract doc already prescribes: *"Scheduler/release test
fixtures must model a card that cleared the gate ... A held unreviewed
card is the gate working."* `seedTask` now writes a planned PROMPT.md.

**Verified it repairs main, not just this branch:** applying only that
file to a detached `origin/main` leaves scenarios 1 and 2 **passing**,
with the 4 residual failures being scenarios 3 and 6 — which need the
fixture-options commit main does not have.

### I was wrong in #2627 and this corrects it

In #2627 I named the in-transaction capacity gate (#2488/#2499) as the
likely cause. **It was not.** Two hypotheses died, both recorded in the
code comment so nobody re-runs them:

| Hypothesis | Result |
|---|---|
| E2E settings lack `maxConcurrent` → capacity gate rejects the move |
added `maxConcurrent`/`maxWorktrees` → **still 2 failed**. Not the
cause. |
| the move itself is refused | a direct `moveTask(id, wip)` →
**succeeded**. Never the blocker. |

Only then did probing the two release gates give
`isTaskBlockedOnApproval=false`, `isUnplannedForExecution=true`, and
dumping the file show the stub. I've flagged the wrong lead on #2627 too
— a plausible-sounding cause pointed at another worker's PR is worse
than no lead.

## 2. E2E evidence: the MERGED intake+hold board

U11's shape — one column carrying intake **and** hold — had no
end-to-end coverage; every prior E2E drove intake and hold as separate
columns.

- shared fixture gains opt-in `mergedIntakeAndHold`, plus `MERGED_VOCAB`
(legacy ids, so a failure is attributable to the **role** merge alone)
and `MERGED_RENAMED_VOCAB` (ids move too).
- lifecycle scenario 3 drives the full spine: planning runs **in place**
on the dual-role column, the real `runHoldReleaseSweep` releases
**from** it, the graph runs to complete.
- 4 merge-safeguard cases on the merged board (finalize, proofless
refusal with the same reason, merged+renamed landing no legacy id,
at-most-once).

## 3. E2E evidence: a REVISE routes back through rework

The plan's `InReview → InProgress: review requests changes` had **no**
live-engine evidence on any board — the fixture's review seam always
succeeded.

Two things the engine taught me, both corrected here:
- the **IR validator refused** my rework edge: it is only legal into a
node with `config.reworkRegion: true`. A real contract, and the
validator catching it is the system working. `exec` now declares it (the
shape the builtin uses on `merge-attempt`).
- my first assertion was wrong. A REVISE does **not** leave the card in
wip — rework re-enters `exec` within the same run, review approves on
its second call, and the card finishes at complete. The evidence is the
**seam sequence**
`["planning","execute","review","execute","review","merge"]`, not an
intermediate column the run has already passed. Asserting the final
column alone would have been satisfied by a graph that ignored the
REVISE entirely.

## Both families are mutation-attributed

| Scenario | Mutation | Result |
|---|---|---|
| 3 — merged intake+hold | `isHeldTask` treats intake/hold as exclusive
| **exactly its 2 tests** fail |
| 6 — REVISE → rework | disable rework re-entry in
`workflow-graph-executor` | **exactly its 2 tests** fail |

Both fixture options are opt-in; the two pre-existing suites are
behaviourally unchanged (27 → 29 → 41 passed across the additions, no
existing assertion touched).

## Still not shipped: safeguard 2's graph E2E

Attempted twice, deleted both times. Attempt 1 passed and then survived
mutating `merge-gate` to ignore `task.autoMerge` — the card parked on
the review column's `merge-blocker` trait, not the gate. Attempt 2
removed that trait to isolate the gate, and the **control** case parked
too. Isolating it needs a merge path mirroring the builtin (`merge-gate
→ merge node → end`) rather than a direct edge to `end` — a real
redesign, not a speculative edit. The enforcement that holds today is
`allowInReviewMergeProcessing` in `project-engine` (unit-mutation
verified, NEW=9; gated via #2526).

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 22:23:32 -07:00
gsxdsm
eb874f3da3 convert(cli/commands/task.ts): triage guard 1 → 0 (+ a live main regression in the lifecycle E2E release path) (#2627)
**Batched push, gate open.** One conversion; the two E2E commits are
held back and the reason is below — it is the more important half of
this PR body.

## Conversion

| File | triage column comparisons before | after |
|---|---|---|
| `packages/cli/src/commands/task.ts` | **1** | **0** |

`pnpm test:gate` green, `pnpm lint` clean.

All four non-terminal columns rendered the **same** glyph, so the four
id comparisons were only ever asking "is this column terminal?". Naming
`triage` made it a lifecycle-vocabulary site for no behavioural reason —
the merged Planning column dropped that id, so the comparison silently
stopped matching while the output stayed correct **by accident** (the
fallthrough gave it the same glyph).

Behaviour-identical **only** because the loop iterates the legacy
`COLUMNS` constant (`types/board.ts:27` — exactly the six ids), so `col`
can never be a custom id. Stated because the forms **diverge** outside
that set: the old chain fell through to the terminal glyph for an
unrecognised id, the new form returns the non-terminal one. If this ever
iterates workflow-resolved columns that difference becomes live, and the
right answer is a trait lookup, not this.

**Deeper bug deliberately untouched, for U12:** because the loop
iterates the legacy enum, a card in a workflow-renamed column **is not
rendered at all**. That is R8's surface change, far bigger than this
glyph.

*(Note: this file is not on the 45-guard list, so it will not move your
count. Flagging so the numbers reconcile.)*

---

## ⚠️ Live regression on origin/main — the lifecycle E2E release path

While rebasing to push, the flagship lifecycle E2E went red. **I
verified it on `origin/main` alone, with none of my commits: 2 failed /
18 passed.**

```
scenario 1 — DEFAULT vocabulary  → AssertionError: expected [] to include 'FN-E2E-1'
                                   (r.sweep.released is EMPTY)
scenario 2 — RENAMED vocabulary  → audit trail differential broken:
                                   renamed produced [{end},{review}], default produced []
```

Both are pre-existing tests I have never touched. **The capacity release
sweep is releasing nothing.**

**Likely cause, from reading rather than bisecting** — so treat it as a
lead, not a verdict: `moves.ts:1059` now resolves a capacity pool id and
enforces `enforcePooledColumnCapacity` **inside the move transaction**
(#2488 "bind the in-transaction capacity gate", made user-visible by
#2499 "make the capacity gate actually bind for real projects"). The E2E
drives with `settings = { experimentalFeatures: { workflowGraphExecutor:
true } }` — **no `maxConcurrent`** — while the fixture's wip column
declares `{ trait: "wip", config: { limitSetting: "maxConcurrent",
countPending: true } }`. If the resolved limit is finite and the pooled
count meets it, the hold→wip move is rejected on capacity and the sweep
correctly reports nothing released.

If that is right, it is a **test-harness/production interaction, not a
product break** — but it means the program's primary end-to-end evidence
for the capacity boundary is currently inert on main, which matters for
completion criterion 3. It needs the capacity worker's eyes, since
#2488/#2499 are theirs and I would be guessing at the intended
pool/limit contract.

## Why my two E2E commits are held

They add scenario 3 (merged intake+hold board) and scenario 6 (REVISE →
rework), both of which **depend on the same release leg**. On current
main they fail for main's reason, taking the file from 2 failures to 4.
Pushing them would add red to the count you are tracking and obscure
whose regression it is.

Both are complete, mutation-attributed, and green against the commit I
wrote them on:

| Scenario | Proves | Mutation that fails it |
|---|---|---|
| 3 — merged intake+hold | capacity release works from a dual-role
column | `isHeldTask` treating intake/hold as exclusive → exactly its 2
tests |
| 6 — REVISE → rework | `InReview → InProgress` on renamed *and* merged
boards | disabling rework re-entry → exactly its 2 tests |

They go out in the next batch the moment the release path is green.

## Also not shipped, twice attempted, deleted both times

Safeguard 2 (`autoMerge:false` terminal-until-human) still has **no**
graph-level E2E. Attempt 1 passed and then survived mutating
`merge-gate` to ignore `task.autoMerge` — the card was parking on the
review column's `merge-blocker` trait, not the gate. Attempt 2 removed
that trait to isolate the gate, and then the *control* case parked too,
so the flag still was not the discriminator. A fixture that can isolate
it needs a merge path mirroring the builtin (`merge-gate → merge node →
end`) rather than a direct edge to `end` — a real redesign, not a
speculative edit. The enforcement that actually holds today is
`allowInReviewMergeProcessing` in `project-engine` (unit-mutation
verified, NEW=9; gated via #2526).

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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 22:04:59 -07:00
gsxdsm
6a33d8f8cc Phase B — TAKING task-creation.ts: intake classification by trait (4 sites → 0) (#2613)
**Taking:** `packages/core/src/task-store/task-creation.ts`

| file | guards before | after |
|---|---:|---:|
| `packages/core/src/task-store/task-creation.ts` | **4** | **0** |

(4 remaining pattern matches in that file are inside the new explanatory
comments, not code.)

## What the literals meant, and why they had stopped meaning it

```
resolvedEntryColumn !== "triage"   ×2   "this workflow has a MANUAL intake"
task.column === "triage"           ×2   "created into the intake column"
```

The first named the **default workflow's intake id** to express *"not
the default workflow"*. Post-U11 the default's intake **is** `todo`, so
the comparison became vacuously true for the default workflow and the
guard stopped separating the two shapes it exists to separate. The real
fact is the intake trait's `autoTriage: false`, which
`resolveWorkflowIntakeFacts` now reads from the IR alongside the intake
column id.

The second was the last-resort clause for a card whose workflow could
not be resolved. `intakeFacts.intake` covers that properly — it falls
back to `DEFAULT_WORKFLOW_ID` rather than to a bare id — so an explicit
`column: "triage"` create on a workflow that still declares `triage`
(R11) is matched through the *resolved* intake instead of a coincidence
of naming.

`isUnplannedStartCreate` is also restated in terms of what it actually
detects — *"the card landed past its workflow's manual intake"*, which
is what quick-add Start does by submitting the workflow id and the
post-intake column together. That replaces `&& task.column === "todo"`,
another id standing in for a relationship.

## Two expectations the conversion legitimately inverted

Both read before changing, neither retargeted blindly.

**1. *"keeps generateSpecifiedPrompt for a direct create into todo (not
bootstrap)"***

`todo` **is** the default's intake now, so a card created there with no
spec **must** get the bootstrap seed — triage admits a card for planning
only when its `PROMPT.md` reads as a seed. Keeping the old expectation
would have pinned the FN-8587 stall: a boilerplate spec that reads as
"already planned" and is never planned.

The old behaviour survived only by **accident of resolution failing** in
the harness, which left the `=== "triage"` literal as the sole deciding
clause. Removing that literal is what surfaced it — which is the point
of the conversion. Split into two tests so the contract it was really
protecting (an explicit **non**-intake column stays a specified create)
keeps its own case.

**2. `store-reservation-atomicity`'s file-scope rollback test**

It stubs `generateSpecifiedPrompt` to inject a bad `## File Scope`, so
it needs a create that actually **calls** that generator. A `todo`
create now gets the bootstrap seed instead, and bootstrap intake prompts
deliberately skip the file-scope hard-fail because their body is
freeform operator prose where a stray `## File Scope` token is not a
real declaration.

Moved to a non-intake column so validation still runs. Left as-is the
test would have been **vacuous** — no throw, no rollback exercised —
while still reporting green.

## Verification

Core package vs the 47-failure post-merge main baseline: **47 failed —
zero new.**

Two files reported failures in the wide run and pass in isolation
(`create-task-reserved-id` 4/4, `schema-applier` 75/75) — the known
contention pattern in this suite; re-run before attributing.

Gate **482 + 10 + 71** green. Lint and core typecheck clean.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 21:39:57 -07:00
gsxdsm
88df46bedb test: re-green executor-workspace onto FN-6756's contract (+ flag a dead branch) (#2617)
**Test-only.** One file. No production changes.
`executor-workspace.test.ts`: **2 failed / 10 passed → 13 passed**.

(This commit was pushed earlier and I failed to open its PR — the work
was finished and sitting on a dangling branch, which is why
`executor-workspace` still shows in main's failure census.)

## Why it was red

Both cases asserted that `clearPhantomExecutorBinding` **succeeds**
while session-registry paths are held.

PR #2531 (FN-6756, P0: *"stop reaping worktrees out from under live
planners"*) inverted that. `hasLiveSessionSurface` now includes
`activeSessionRegistry.pathsForTask(taskId).length > 0`, and that guard
runs **before** both branches — so any registered path refuses the
clear. The FNXC note at `executor.ts:2729` says the kind-blind guard is
deliberate: *"A leaked entry now blocks THIS sweep rather than a live
planner losing its worktree — the strictly safer failure."*

So the old expectations describe the pre-#2531 contract. Rewritten to
the current one, which had **no direct coverage**: a refusal leaves
`activeWorktrees` and the registry entries untouched. The FN-6736 KTD2
invariant ("every held path, not one") is kept, exercised with no
registry paths so the guard permits it.

**Verified the new tests guard:** removing the registry term from
`hasLiveSessionSurface` fails 2 of them (`NEW-failures=2`), and only
them.

## Flagged, not fixed — a possible dead branch

The guard appears to make **both branches it precedes** unreachable for
their stated purpose:

- the default branch exists to unregister every held registry path
(FN-6736);
- `preserveWorktrees: true` exists to **keep** those paths so a
`moveTask(preserveWorktree: true)` re-dispatch reattaches to the same
worktree (FN-7249) — and its **only** production caller is the
self-healing reclaim at `self-healing.ts:3565`.

Both need registered paths to do anything, and the guard rejects exactly
that case. With none registered, one sweeps nothing and the other
preserves nothing.

The third new test pins this so the conflict is **executable rather than
prose**: `preserveWorktrees: true` returns `false` while the path it
exists to preserve is registered.

I did not "fix" it by rewriting the assertion to match production — that
would bury a possible regression in FN-7249's reattach path. Resolving
it (exempting the non-destructive `preserveWorktrees` path, or narrowing
the guard by kind) is a product decision for the FN-6756 owner.

## Main's engine-default census, measured just now

| Point | Failed | Files |
|---|---|---|
| when I started this sweep | 283 | 28 |
| after the logger-mock fix (#2573) | 106 | 23 |
| **now** | **65** | **12** |

This PR clears one of the remaining 12.
`executor-review-verdicts.test.ts` is newly red and in my lane — taking
that next.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 21:37:14 -07:00
gsxdsm
d438cd1d13 U12 drift: register-task-workflow-routes.ts — resolve the intake column (7 -> 1) (#2614)
**File claimed:
`packages/dashboard/src/routes/register-task-workflow-routes.ts`.**
Per-file lifecycle-column guard count: **7 → 1**, and the 1 is comment
prose (line 3758), so this file is done for completion-bar item 1.

## The bug this fixes

`retrySpecification` decided "this Retry is a re-plan, not a generic
retry" with `task.column === "triage"`. `status: "planning"` is
retryable **only** through that flag — it is not in the generic
`failed`/`stuck-killed` set. So on any lineage whose intake column is
not literally named `triage`, a card visibly sitting in planning got
`400 Task is not in a retryable state`. The operator's Retry button did
nothing, with no error to explain why.

Post-#2515 that includes the **default** workflow:
`columnsWithFlag(resolveDefaultWorkflowIr(), "intake")` is `["todo"]`
and the default's columns are `[todo, in-progress, in-review, done,
archived]` — `triage` is not declared at all. The pre-existing `todo`
fallback below it papered over the default case (it fires when the
workflow has no `triage`), which is why this did not show up as a total
outage; custom and renamed lineages had no such cover.

Now: `const retryIntakeColumn = await
resolveIntakeColumnForTask(scopedStore, task.id)`.

## Red-green, measured

`packages/dashboard/src/__tests__/plan-approval-intake-column.test.ts` —
new case, custom lineage with intake `backlog`, card in `backlog` with
`status: "planning"`:

- with the change: `200`
- with `task.column === "triage"` restored: **`AssertionError: expected
400 to be 200`**

The fixture uses `planning` deliberately. A `failed` fixture would pass
either way through the generic retryable set and prove nothing.

## What is NOT tested, and why not

This PR also removes four `&& task.column !== "triage"` disjuncts I
added earlier while widening the P0 approve/reject guard. **Those are
untestable by construction** and I am not claiming coverage for them:
removing an extra acceptance only shrinks what the guard accepts, and no
case can feed these routes a `triage` card now that no shipped lineage
declares one. I re-widened one guard and confirmed the suite stays green
— i.e. nothing depends on the disjunct in either direction. That is the
honest result, not a passing test.

## Three fixtures updated, not guards re-widened

`stranded-refinements-routes.test.ts` failed with three `expected 400 to
be 200` — the same failures that made me widen in the first place. This
time I probed instead: `BASE_TASK` had `column: "triage"`, a column the
default workflow no longer declares, so a 400 is **correct** and the
fixtures were pre-merge artifacts describing a board shape the product
stopped shipping. Changed to `column: "todo"` with the resolver output
recorded in the file.

## Observation, deliberately not fixed here

The `todo` fallback at ~2642 (`retrySpecification =
!workflowHasColumn(workflowIr, "triage")`) is now near-dead: for the
merged default the first branch already fires. It survives only for a
lineage that has a `todo` column, no `triage`, and some *other* intake
column — where treating a `todo` card as planning is arguably wrong.
Deleting it is a behaviour change with its own blast radius, so it does
not ride along in a conversion commit.

## Verification

`pnpm lint` clean. `pnpm test:gate` green (10 / 482 / 71). Target
suites: `plan-approval-intake-column.test.ts` 8/8,
`stranded-refinements-routes.test.ts` 12/12.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 21:34:31 -07:00
gsxdsm
1469d57477 U12 drift: ListView.tsx — one tested column-role helper (5 -> 0) (#2620)
**File claimed: `packages/dashboard/app/components/ListView.tsx`.**
Per-file lifecycle-column guard count: **5 → 0** (3 live, 2 in comment
prose that described the deleted code).

## What was actually wrong

All three live sites were *already* flags-first. The defect was that
each carried its own inline copy of the same fallback:

```ts
targetFlags ? Boolean(targetFlags.intake || targetFlags.hold) : column === "todo" || column === "triage"
```

Three copies, none reachable from a test, each reading like a lifecycle
rule rather than the degraded mode it is. A fourth copy was the natural
next step.

## Why the fallback survives instead of being deleted

`columnFlagsById` is legitimately empty in two states: the pre-load
window before the workflows fetch resolves, and a card stranded in a
column its workflow no longer declares. A bare `flags.intake === true`
returns false in both, and **both failures are silent** — the Planning
badge stops appearing, and a backwards move stops asking whether to
preserve step progress, so the operator loses completed steps with no
prompt and no error. Deleting the fallback is not the cleanup it looks
like.

So it is kept, named (`isPreImplementationColumnRole`,
`isIntakeColumnRole` in `app/utils/columnRoles.ts`), defined once, and
documented with that reason at the definition. The legacy ids now live
in a named `LEGACY_PRE_IMPLEMENTATION_COLUMN_IDS` set — a last-resort
guess, not a comparison masquerading as a rule.

## Tests, and the case that never had one

`app/__tests__/columnRoles.test.ts` (6). The degraded branch is now
covered for the first time — it was unreachable while inline inside two
`handleMove` closures and a `useCallback`.

It also pins the **inversion** a fourth copy would eventually get wrong:
a resolved column whose traits say it is *not* pre-implementation must
not be overridden by an id that happens to be `todo` or `triage`. That
is the direction that trains operators to dismiss the prompt.

Mutation-checked, measured:

| mutation | result |
|---|---|
| ignore the flags argument (`return LEGACY_….has(columnId)`) | **4
failed / 2 passed** |
| ignore the id fallback (`return Boolean(flags?.intake \|\|
flags?.hold)`) | **4 failed / 2 passed** |

## Behaviour preservation

`ListView.test.tsx` + `workflow-resolved-columns.test.tsx`: **260
passed**, unchanged. The extraction is a pure move — the two helper
bodies are the inline expressions verbatim, with the id set hoisted.
`pnpm lint` clean. `tsc -p tsconfig.app.json` clean (the app config, not
the root one that silently skips `app/`).

No changeset: behaviour-preserving refactor.

## Backlog measured on `origin/main` at time of writing

48 total. `self-healing.ts` (10) is the capacity worker's;
`register-task-workflow-routes.ts` (7) is my #2614. Remaining unowned in
this area after this PR: `TaskCard.tsx` 4, `TaskDetailModal.tsx` 3,
`TaskContextMenu.tsx` 2, `Column.tsx` 2, `taskActivity.ts` 2. Several of
those hold the *same* fallback pattern and can now call this helper
rather than grow another copy.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 21:23:46 -07:00
gsxdsm
89d6d76d60 Unowned: the R7 sweep guessed with another workflow's columns — its "do not guess" guard was unreachable dead code (#2600)
## Unowned: the R7 sweep's "do not guess a column" guard could not fire

Picked up from my own #2543 finding. Independent of my other PRs.

### The guard existed in comment form only

`reconcileUndeclaredTaskColumns` wraps IR resolution in a try/catch
whose comment reads:

> An unresolvable workflow is its own fault path; do not guess a column.

But `resolveWorkflowIrById` catches **every** failure and returns
`defaultCodingWorkflowIr()`, and `resolveWorkflowIrForTask` does the
same for a failed selection read. The resolver never rejects, so that
catch is **dead code**.

What actually happened to a card whose workflow could not be loaded: it
was judged against the **default** workflow, and if its column was not
one the default declares, the sweep re-homed it to the **default's**
rebound target. It guessed, using a workflow that is not the card's own
— the precise outcome the guard was written to prevent, in a **startup
recovery path that runs against every task**.

### How it was found, which is the part worth keeping

By being **unable to make a test of the guard fail**. Three separate
mutations all passed — deleting the `continue`, deleting the try/catch,
and simulating a whole-sweep abort at that very catch. I had written
that off once as "this case pins the outcome, not the mechanism". The
inability was the signal, not a limitation of the assertion: the branch
is unreachable.

This is the seventh instance of the program's core shape, and the first
I found in a guard I had just finished writing coverage for.

### The fix

The sweep now **proves the resolved IR belongs to the task** before
moving its card: it reads the task's workflow selection and confirms
that id resolves to a real definition (built-in or stored).

- A task with **no** selection legitimately resolves to the default
workflow — not treated as unresolvable.
- An unreadable selection **read** is itself grounds not to guess.

Placed at the **move site**, not at resolution, deliberately: it costs
one definition read only for a card already about to be moved — a
healthy board reaches that line for nobody — and it keeps the fix inside
the sweep instead of changing a resolver whose soft-failure many other
callers depend on. Changing `resolveWorkflowIrById` to reject would have
been the tidier-looking fix and a much wider blast radius.

### Revert-proof, both directions

- Remove the proof → the case fails `expected 2 to be 1`: the unloadable
card is re-homed on a guess.
- The same case asserts the neighbour **is** still repaired, so the fix
cannot be mistaken for letting one bad card disable the sweep for
everyone else. That is the per-task isolation property, and a
single-task fixture cannot distinguish it from a whole-sweep abort —
verified by injecting a throw at the loop head (`expected 0 to be 2`).

### Verification

`pnpm test:gate` (482 + 10 + 71), `pnpm lint`, engine typecheck green.
Sweep suite + `legacy-tombstones`: 13 passed.


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

## Summary by CodeRabbit

* **Bug Fixes**
* Prevented startup recovery from moving cards into incorrect columns
when their workflow cannot be loaded or resolved.
* Cards with unreadable workflow information now remain in place, while
other recoverable cards continue to be repaired correctly.
* Added safeguards to avoid guessing a fallback workflow during column
reconciliation.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 21:21:04 -07:00
gsxdsm
534798dea0 test(U9): E2E evidence for the merge safeguards on a real PG store (completion-bar item 3) (#2615)
**U9 E2E evidence.** One new `.pg.test.ts`, 6 tests, green. No
production changes. `pnpm test:gate` green — `pgDescribe`-skipped
without PostgreSQL, so the gate is unaffected.

## What this closes

The U9 safeguard baseline verified all six merge safeguards by
**mutation at unit level**. The sibling `workflow-merge-family-live-e2e`
covers exactly **one** end-to-end. This drives
`finalizeProvenAutoMergeTask` — the last move a card makes — against a
real PostgreSQL `TaskStore`, asserting on the **persisted column** read
back after clearing the task cache. Never on "a function was called".

Only the merge **proof** is seeded (`mergeDetails.mergeConfirmed`),
which is what a real merger writes; there's no git and none is needed.
Column resolution, blocker evaluation, the move and its guards, and
persistence are all real. Includes the rename differential, where a
guard keyed on a literal goes silent.

## Three things I expected and measured wrong

Corrected in the file rather than worked around — each is a claim I
would otherwise have shipped:

**1. Dependency gating does not reach this seam.** My first draft
asserted a refusal. A proven-merged card with a live `blockedBy`
finalizes to the complete column anyway. That's coherent: dependency
gating lives in `getTaskCompletionBlocker` and gates whether work may be
*called* complete, while this seam runs after `mergeConfirmed` —
refusing would strand a merged card in review and misreport the
repository without un-merging anything. Now pinned as designed behavior
*with* that reasoning, not filed as a hole.

**2. The at-most-once outcome is `already-done`**, not the
`already-complete` I guessed.

**3. `expect(outcome).toBe("blocked")` cannot attribute a refusal.** The
finalizer has **three layered refusal gates**, and the two proof gates
emit the *same* reason (`missing-merge-confirmation`, also returned by
`validateWorkflowDoneMergeProof`). So removing either one left my
original assertion **green**:

| Mutation | Result |
|---|---|
| remove the durable-proof gate | 6 passed — invisible |
| remove the main-path proof gate | 6 passed — invisible |
| remove **both** | **2 failed** / 4 passed |

Fixed by pinning the **reason**, not just the refusal. The lesson
generalises: single-gate mutation cannot detect redundant
defense-in-depth from outside, so the unit-level attribution in the
baseline doc and this E2E are **complementary**, not duplicative. I
nearly labelled these tests as proving a specific gate they don't.

## Flagged, not changed — safeguard 1 at this seam

Written as open questions and answered by running them. **Both a
`paused` and a `userPaused` proven-merged card are moved to the complete
column.**

For `paused` that's documented design — `auto-merge-finalization.ts:243`
evaluates hard blockers with `paused: false` because the branch already
landed.

For `userPaused` it sits against the invariant re-ratified in #2486:
*never MUTATE lifecycle state of a user-paused card.* The mitigating
argument is the same one — the merge is durable, so the move is
bookkeeping that reflects reality, and refusing would leave an
operator's card permanently misfiled in review.

**Either reading may be right. What was not acceptable is that it was
untested.** Both are now explicit named assertions with the tension in
the comment, so tightening the pause contract becomes a decision rather
than a discovery. Resolution belongs to whoever owns the pause contract
— I'm not quietly changing merge behavior on a paused card.

## Safeguard coverage after this PR

| # | Safeguard | Unit (mutation) | E2E |
|---|---|---|---|
| 1 | user pause | ✅ | ✅ pinned as an exception at this seam — flagged
above |
| 2 | autoMerge:false | ✅ | ✗ gate lives upstream in `project-engine`,
not this seam |
| 3 | dependency gating | ✅ | ✅ pinned as *not* applying here, with
rationale |
| 4 | capacity single-flight | ✅ | ✗ in-memory pump, no store seam to
observe |
| 5 | merge-proof | ✅ | ✅ both vocabularies, reason-attributed |
| 6 | at-most-once | ✅ | ✅ second finalize classifies `already-done`, no
second move |

The two gaps are stated rather than implied: safeguard 2's gate is
`allowInReviewMergeProcessing` in `project-engine`, which needs an
engine harness rather than a store one, and safeguard 4 is an in-memory
single-flight latch with nothing persisted to assert on. Both are
covered by mutation at unit level and both are in the gate as of
#2526/#2569.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 21:20:17 -07:00
gsxdsm
592fd5c0c6 U11 [mission-feature-sync + spec-staleness]: convert the last two planner-lane guards (48 -> 46) (#2610)
**Taking: `engine/mission-feature-sync.ts`, `engine/spec-staleness.ts`**
— the last two planner-lane guards in my area.

## Census (comment-stripped, `=== "triage"` / `!== "triage"` in
`packages/*/src`, tests excluded)

| file | before | after |
|---|---:|---:|
| `packages/engine/src/mission-feature-sync.ts` | 1 | **0** |
| `packages/engine/src/spec-staleness.ts` | 1 | **0** |
| **repo total** | **48** | **46** |

## Both are real conversions, not seams

Each guard takes its vocabulary from the **caller**, which holds the
store — so unlike a defaulted parameter nothing passes, these can
actually be driven.

**`reconcileMissionFeatureState`** — a card back in a planner lane
returns the mission feature to `triaged`. Keyed on literals, a renamed
workflow left the feature reading `in-progress` forever: the roadmap
claims work is underway while the card waits to be re-planned. Nothing
errors; the rollup is just wrong. The vocabulary arrives via
`MissionFeatureSyncContext` rather than by widening this module's
deliberately narrowed `Pick<TaskStore, "getTask">`.

**`shouldSkipSpecStalenessForPreservedProgress`** — returning `false`
for a planner-lane card is what *keeps* staleness evaluation on. Miss
the lane and it falls through to the preserved-progress branch, so a
card with progress skips staleness and keeps a spec that should have
been re-validated.

## The two take different defaults — and I got it wrong first

I defaulted **both** to the `triage`/`todo` pair and broke the
pre-existing U11 proof in `spec-staleness.test.ts`, which states the
reason exactly:

> same column, different status, opposite correct answer

- **mission-feature-sync → the PAIR.** It asks "is this card waiting to
be planned?", true in either lane.
- **spec-staleness → the DEDICATED planner column only.** On a merged
lineage `todo` is *also* the hold lane, so the planner distinction there
is carried by **status** (`planning` / `needs-replan`), not by the
column. Treating the merged column as a planner lane stops a parked card
with preserved progress from skipping staleness. Its default is now the
single legacy id — byte-identical to the literal it replaced.

That asymmetry is now pinned by its own test rather than left for the
next reader to rediscover.

## Findings on the remaining census, from measuring it

Two of the 46 are **not lifecycle-column guards** and converting them
would be wrong:

- `tool-availability.ts:32` — `surface === "triage"` where `surface:
"triage" | "executor"` is an **agent lane**, not a column.
- `skill-resolver.ts:432` — `sessionPurpose === "triage"`, a **session
purpose**.

Also worth noting for the count: `replan-target.ts` reads as 2 in a raw
grep but is **0** — both hits are inside comments. `board-workflows.ts`
(2) and `archive-planning.ts` (1) are likewise comment-only. A raw grep
says 52; comment-stripped says 46.

## Not wired at the call sites yet

`scheduler.ts` / `mission-autopilot.ts` (mission sync) and `executor.ts`
/ `scheduler.ts` (staleness) still omit the new option, so behaviour is
byte-identical today. Deliberate: `executor.ts` belongs to u8's active
slice and I would rather not create a textual collision for a
pass-through. The seam is proven by tests and the count is real; wiring
is a follow-up.

## Verification

- **Mutation-verified:** restoring either literal fails a test
- 35 tests green across the three suites, merge gate green (482 + 132 +
10), tsc clean, lint clean

No changeset: `@fusion/engine` is private.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-07-29 21:20:09 -07:00
gsxdsm
9a11e0b136 U2b reproduction: the live move path accepts the column U11 deleted (characterized, not patched) (#2601)
Found while proving U11's caveat 2. **Characterization plus guard-rails
— no production change, deliberately.**

## The defect

A default-workflow card in Planning can be moved **into `triage`** — a
column its workflow no longer declares — re-creating exactly the
stranded state `reconcileUndeclaredTaskColumns` exists to repair.

Measured on a fresh store:

```
experimentalFeatures.workflowColumns   null            ← no production writer
createTask(...)                        column = "todo"
moveTask("todo" → "triage")            ACCEPTED
moveTask("todo" → "bogus-column")      REJECTED: "Valid targets: in-progress, triage, archived"
```

The second rejection is the tell. Validation is real — but it is the
**legacy `VALID_TRANSITIONS`** table talking, and that table does not
know the card's workflow. Its `todo` row still lists `triage`.

## Why the workflow-aware check does not run

`moves.ts` gates its adjacency block — including
`workflowHasColumn(workflowIr, toColumn)` — on
`isWorkflowColumnsCompatibilityFlagEnabled`, which reads the raw
`experimentalFeatures.workflowColumns` key. Nothing writes it, so the
block is dead on the path every real project takes.

**Corollary, already reported:** U11's undeclared-source escape hatch in
`resolveAllowedColumns` also does not run in production. It was added
with #2515 so a stranded card would have a legal move instead of `Valid
targets: none`; on the live path that rescue comes from the legacy table
instead. Mutation-verified — stubbing the hatch back to `[]` leaves the
operator-move test green.

## Why I did not fix it

PR #2499 un-gated the capacity check and **explicitly scoped validation
out**:

> SCOPE, deliberately narrow: only the CAPACITY check is un-gated.
`workflowIr` stays flag-gated so transition VALIDATION keeps its current
behavior — the inline path's bare-Error/"Valid targets:" contract is
unchanged, and none of the Phase A2 divergences are flipped here.

That is a considered decision by the owner of this function, and several
suites pin the contract it protects. Overriding it from outside would
flip an error shape I do not own.

**What has changed since that decision is U11:** the legacy table now
offers a target the default workflow does not declare, which it never
did before. That is new input to the scoping call, not licence to ignore
it — so this lands as a reproduction for U2b rather than a patch.

U2b's branch (`feature/workflow-move-path-convergence`) is stale — HEAD
predates several merged PRs, clean tree — so nothing is being raced.

## What ships

The defect is **characterized, not asserted-as-correct**: the test pins
today's behaviour so it is visible and measurable, and an `it.todo`
states the intended behaviour. Writing it as a passing "refuses" test
would have required the fix; writing it as a failing test would redden
CI; asserting the current behaviour as *correct* would be a lie.
Characterization plus `it.todo` is the honest third option.

Four guard-rails pin what a fix must **not** break:

- every declared lifecycle move (`todo → in-progress → in-review →
done`)
- archiving
- a `recoveryRehome` deliberately reaching an undeclared column — the
path that rescues already-stranded cards, and the one a careless fix
would break
- a premise test asserting the compatibility flag really is unset, so
the suite fails loudly if that ever changes rather than silently testing
a different code path

## Exposure

Narrow but real. U10 already fixed the dashboard move menu to offer only
workflow-declared targets, so the board does not present this. The
**write path** does — REST API, CLI, plugins, any stale client — which
is why the guard belongs in `moves.ts` rather than only in the UI.

5 passed + 1 todo; lint clean.

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

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

## Summary by CodeRabbit

* **Bug Fixes**
* Added coverage for task moves involving workflow-declared and
undeclared columns.
* Documented a known issue where tasks can currently be moved into the
deleted `triage` column.
  * Preserved valid moves, archiving, and recovery re-homing behavior.

* **Documentation**
* Added reproduction steps, affected move paths, and guardrails for
addressing the issue.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 21:12:57 -07:00
gsxdsm
f47fc167ee convert(core/task-store/comments-ops.ts): triage guards 3 → 1, and the dead approval-invalidation it hid (#2608)
**Taking `packages/core/src/task-store/comments-ops.ts`** (announced for
collision avoidance). Two commits: a behaviour-identical extraction,
then the conversion.

| File | triage column comparisons before | after |
|---|---|---|
| `packages/core/src/task-store/comments-ops.ts` | **3** | **1** |

`pnpm test:gate` green.

## The bug the literal was hiding

`builtin:coding` → `BUILTIN_STEPWISE_FINAL_REVIEW_CODING_WORKFLOW_IR`,
whose merged Planning column keeps the id **`todo`** and declares **no
`triage` column**. So `task.column === "triage" && task.status ===
"awaiting-approval"` never matched a default card. The damage was
graded:

- **with a real spec** — the card fell through to the re-triage arm.
Same `needs-replan` write, but audited as *"requested re-specification
of planned task"* instead of *"invalidated spec approval"*.
- **with a bootstrap-stub spec** — `hasRealPrompt` was false and
**neither arm fired**, so a user comment on a card awaiting spec
approval invalidated **nothing**. The approval silently stood.

That second case is the real regression; the wording is cosmetic. I
checked both rather than assuming the first one was the whole story.

## The conversion

The column was never the discriminator. Callers reach this only after
establishing the card sits in a pre-implementation column, so re-testing
it inside was redundant before U11 and wrong after. **Status carries the
distinction** — the same conclusion `spec-staleness.test.ts` already
reached for its sibling guard.

**Red-green:** the 3 new cases fail with the literal reinstated (**3
failed / 4 passed**) and pass without it. Two assert the merged-Planning
card is now invalidated; the third uses a `planning`-named column to
show no column id remains in the decision at all.

**The 1 remaining literal is deliberate:** the caller's gate `column ===
"todo" || column === "triage"` names *both* vocabularies, so it still
fires for default cards, and narrowing it to traits needs an IR the
caller doesn't have.

Commit 1 is move-only — the extracted body is the inlined expression
verbatim, `triage` literals included, so the moved logic diffs empty
apart from field renames. Behaviour change is entirely in commit 2.

---

## Census correction — the 48 is 41, and "reach ZERO" is wrong as stated

I re-measured before picking a file, and the shared number needs three
corrections. Same-scope method: `packages/*/src`, `.ts`, tests excluded,
**comments stripped**.

| Measurement | Count |
|---|---|
| raw `=== "triage"` / `!== "triage"` | 54 |
| …comments stripped | **48** ← matches your figure |
| …of those, genuine **column** comparisons | **41** |
| …non-column identifiers that must NOT be converted | **7** |

The 7 are `role === "triage"` ×3 (`agent-prompts.ts`), `agentType ===
"triage"` ×2 (`usage-limit-detector.ts`), `sessionPurpose === "triage"`
(`skill-resolver.ts`), `surface === "triage"` (`tool-availability.ts`).
**The triage service keeps its name; only the column id was merged
away.** Converting these would break the triage lane, so the bar cannot
be literal zero — it's zero *column* comparisons, with those 7
documented as permanent.

Two I nearly misclassified and hand-checked: `col === "triage"`
(`cli/commands/task.ts`, indexes `COLUMN_LABELS`) and `from ===
"triage"` (`executor.ts`, a `moveTask` from-column) **are** columns
despite their names.

## Of the 41, which are actually dead

Splitting by whether a `todo` companion arm sits in the same condition:

- **27 have one** → still fire for default cards. Real but lower
priority.
- **14 have none** → candidates for silently-dead. But on inspection
that set shrinks further:
- `register-task-workflow-routes.ts` ×5 compare against a *resolved*
`approveIntakeColumn`/`refineIntakeColumn` variable **plus** a legacy
`"triage"` fallback, so they still fire via the variable;
- `spec-staleness.ts:40` is a **deliberate R11 compat retention** —
`spec-staleness.test.ts` already carries a "U11 proof" block concluding
the guard is carried by status, not column, and that other workflows
still declare `triage`. Converting it would be wrong;
  - `self-healing.ts` ×7 is U4's file;
  - `comments-ops.ts` ×1 was genuinely dead — this PR.

**So the actionable dead set is far smaller than 14, and
`self-healing.ts` holds most of it.** I'd suggest whoever takes
`self-healing.ts` starts from that 7 rather than its 11 total.

## Files I evaluated and did NOT convert

- **`replan-target.ts`** — my first pick, then both its "sites" turned
out to be **comment text**. Zero real sites; already trait-resolved via
`workflowHasColumn`.
- **`mission-feature-sync.ts:88`** — `(column === "triage" || column ===
"todo")` still fires via the `todo` arm. The genuine gap is a
custom-named planning column, but `reconcileMissionFeatureState`'s store
is narrowed to `Pick<TaskStore,"getTask">`, so trait resolution means
plumbing through `scheduler.ts` — **U5's file**. Left to avoid the
collision, per KTD-2's warning that most sites have no IR in scope.
- **`tool-availability.ts` / `skill-resolver.ts` /
`usage-limit-detector.ts`** — non-column identifiers, see above.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 21:07:28 -07:00
gsxdsm
45e8b5f7ac U8: pin the completion-finalize ordering invariant before moving the last out-of-band exit (#2599)
Groundwork for moving `paused-after-completion`, the **last**
out-of-band exit. Stacked on #2590.

## What lands

1. **An indentation defect I introduced.** My bulk edit when the exit
vocabulary landed left the second `paused-after-completion` site
mis-indented inside a `finally` block. Cosmetic, but misleading
indentation in a `finally` is how a future reader misjudges scope.

2. **The adjacency ratchet now requires `markCompletionFinalized` before
the handoff, at every reporting site.** It previously checked only the
first occurrence, and only for the handoff itself.

That ordering is the invariant `handleGraphFailure` depends on and
**cannot check for itself**: `alreadyFinalizedToReview` /
`completionFinalized` exist to recognise this out-of-band move when a
later teardown re-marks the abort as `hard-cancel`. Without the durable
marker set first, a completed no-commit task is re-parked `failed` —
FN-6644/FN-6641.

It is asserted **structurally, and labelled as such in the test**. Both
call sites sit in pause and `finally` paths that cannot be driven
without mocking an entire agent session; presenting a source assertion
as behavioural coverage would repeat the overclaim I have been correctly
pulled up on twice in this unit.

Red-green: removing `markCompletionFinalized` from either site fails the
ratchet.

## Why the move itself is not in this PR

`paused-after-completion` is structurally harder than the pending-review
ending that #2590 moved, and the difference is worth recording before
someone assumes it is a copy-paste:

- it does **four** things, not one — `markCompletionFinalized`,
`handoffTaskToReview`,
`clearCompletedTaskWatchdog`/`signalTaskComplete`. Only the handoff is
lifecycle; the rest is substrate that must stay put.
- one of the two sites is inside a **`finally`**. Moving a transition
out of a `finally` is not the same operation as moving one out of a
branch: the graph may already be unwinding, so "report and let the graph
route" needs a defined answer for a run that is already ending.
- there is **no behavioural coverage of either site today** — the
closest tests only exercise the exit vocabulary. The pending-review move
succeeded on the fourth attempt precisely because FN-5436 existed to
catch each wrong version; this exit has no equivalent, so the move needs
that floor built first, and building it means real session mocking
rather than a shortcut.

## Verification

- exit-events + primitive-exit-events + step-session + ownership ledger
— green
- `pnpm lint` clean; `tsc --noEmit` clean
- No user-facing behaviour change, so no changeset

🤖 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 handling of workflow steps that pause for review.
- Tasks now remain in review when a review request has no subsequent
decision.
  - Added clearer completion events for primitive prompt steps.
  - Preserved correct failure handling when later workflow steps fail.

- **Workflow Improvements**
- Built-in workflows now route pending reviews through a dedicated
review handoff.
- User-authored workflows retain compatible review parking behavior when
routing is unavailable.

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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 21:04:39 -07:00
gsxdsm
3f763cba87 U8: the graph owns the pending-review park — ownership ledger 28 → 27 (#2590)
The routing move this unit has been building toward, landing on the path
the engine actually runs. **Includes #2578's commit** (the live-path fix
it depends on) — merge that first, or this supersedes it.

## What changes

Three things together, because a half-routed move is a card that
silently does not advance:

1. The **live** implementation primitive (`runCodingSession`) returns
`{outcome: "failure", value: "review-pending"}` for that ending.
2. The primitive step handler stops flattening every ending to
`step-done`/`step-failed`, so the value survives the foreach —
`runForeach` propagates a failing instance's value as the node's own —
and reaches an edge.
3. The inline `handoffTaskToReview` in `runImplementation` is
**deleted**. The phase reports and stops, which is all an implementation
phase should do.

Built-in workflows route to the `review-pending-handoff` node added in
#2519/#2546, which performs the handoff and ends the run: the same two
effects in the same order, with the graph as the owner.

## Proof, end to end

FN-5436 — the test that blocked this move twice and was right both times
— now passes, with a **stronger** assertion than it had:

```ts
expect(store.moveTask).toHaveBeenCalledWith("FN-5436-B", "in-review",
  expect.objectContaining({
    workflowMoveSource: "workflow-graph",
    workflowMoveMetadata: expect.objectContaining({ nodeId: "review-pending-handoff" }),
  }));
```

The old two-argument `moveTask(id, "in-review")` could not distinguish a
graph-owned park from an out-of-band one — which is the entire
distinction this unit exists to make. The invariant (park in review,
never `failed`) is unchanged; the owner is now proven.

## Every ratchet fired, and each records a real change

| Ratchet | Before | After | Why |
|---|---|---|---|
| Ownership ledger — `runImplementation` review handoffs | 3 | **2** |
the handoff left the phase |
| Ownership ledger — `handleGraphFailure` | 0 | **1** | the named compat
classifier |
| Ledger headline — executor-owned dispositions | 28 | **27** | first
decrement of the unit |
| Out-of-band exit list | 2 | **1** | pending-review is graph-owned now
|
| Primitive routing pin | "must not reroute" | routes *only* the moved
ending | declared, not discovered |

None was relaxed. The `handleGraphFailure` 0 → 1 is the honest one: for
a user-authored graph without the edge this is a **relocation, not an
elimination** — the transition is still executor-performed, but from one
named classifier in the failure ladder rather than a call buried two
thousand lines into a session loop. The ledger says so rather than
letting the headline number imply more progress than there is.

## Why it took four attempts

Recorded because the reason is reusable: the value was being produced on
`createAuthoritativeWorkflowSeams`, a handler that never runs (#2578).
Every earlier attempt was correct code on a dead path, and the only
thing that showed it was instrumenting until a negative result was
proven observable rather than assumed.

## Verification

- step-session + exit-events + primitive-exit-events + ownership ledger
+ graph-requeue-gate + task-done-blocked — **83 tests green**
- `pnpm test:gate` green (10 / 482 / 71); `pnpm lint` clean; `tsc
--noEmit` clean
- Changeset included (`patch`, `internal`)

🤖 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 handling of tasks awaiting review so they are correctly
routed to the review workflow.
* Tasks now remain in review instead of being marked as failed when no
follow-up review route is configured.
* Review handoffs now include workflow ownership and provenance details.
* Preserved standard failure handling for tasks that are not awaiting
review.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 20:59:06 -07:00
gsxdsm
d5f1ce7abd U11 [writes]: stop CREATING cards into a column the workflow no longer declares (9 -> 0, engine+cli) (#2603)
**Taking: `engine/triage.ts`, `engine/pr-comment-handler.ts`,
`engine/eval-followups.ts`, `cli/commands/task.ts`, `cli/extension.ts`**
(write class — no collision with the comparison backlog).

## A class the census does not count

The 48-guard work list tracks `=== "triage"` **comparisons**. These are
`column: "triage"` **writes** — and post-#2515 every one creates a card
directly into the state STALL 3 was about, except **manufactured
continuously** rather than left behind by the upgrade.

## Why they bite

`createTaskImpl` resolves the column as:

```ts
column: input.column || options?.resolvedEntryColumn || fallbackIntakeColumn || "triage"
```

`input.column` **wins**, so an explicit `column: "triage"` overrides the
workflow's resolved intake column entirely.
`store-create-intake-column.test.ts` already pins that a create with
**no** column lands in the default workflow's intake (now `todo`) —
these callers opted out of it.

The sharpest is `triage.ts`'s `fn_task_create` agent tool: it passed
`workflowId: params.workflow_id` **and** `column: "triage"` in the same
call. The caller chose a workflow and the column ignored it — a Coding
(Ideas) create landed in `triage` instead of `ideas`.

## Counts

**Comparison guards: unchanged by this PR.** This is the write class;
conflating the two would misreport convergence toward the zero bar.

| file | `column: "triage"` writes before | after |
|---|---:|---:|
| `packages/engine/src/triage.ts` | 1 | **0** |
| `packages/engine/src/pr-comment-handler.ts` | 1 | **0** |
| `packages/engine/src/eval-followups.ts` | 1 | **0** |
| `packages/cli/src/commands/task.ts` | 3 | **0** |
| `packages/cli/src/extension.ts` | 3 | **0** |
| **total** | **9** | **0** |

## A test that pinned the defect

`pr-comment-handler.test.ts` asserted `column: "triage"` in the
createTask call — so it would have **failed the fix and passed the
bug**. Rewritten to assert the invariant (the caller passes no column,
so the workflow's intake wins) plus an explicit `Object.hasOwn(arg,
"column") === false`, which is what actually catches a reintroduction.

## Interaction with #2591

My merged #2591 rescues these cards once created — they sit on a legacy
planner id their workflow doesn't declare and are still in planning
stage. So this isn't a *visible* stall today; the rescue absorbs it.
**That's the reason to fix it rather than leave it:** a self-healing
path silently absorbing a steady stream of malformed creates is exactly
how the underlying defect stays invisible.

## Deliberately not touched

- `{ id: "start", kind: "start", column: "triage" }` in the builtin
coding / PR / lead-generation IRs — workflow-internal **node
declarations** for workflows that still legitimately declare a `triage`
column, not lifecycle writes.
- Left for their owners: `core/task-store/project-store-ops.ts:210`,
`core/task-store/update-task-deps.ts:111` (main worker),
`dashboard/src/routes/register-gitlab.ts:108` (u12). Same defect, same
one-line shape.

## Verification

- 304 engine/CLI tests green across the affected suites
- merge gate green (482 + 132 + 10), engine + CLI tsc clean, lint clean

No changeset: `@fusion/engine` and `@fusion/core` are private; the CLI
change is a bug fix with no user-facing API change — happy to add one if
you'd rather it appear in release notes.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-07-29 20:58:52 -07:00
gsxdsm
9c1c6f7479 docs(engine): close the unproven-sites ledger — one entry was wrong, the rest need two named lanes (#2544)
Comment-only change to the ledger. No test or production code moves.

## Why this is a PR and not a note

The ledger is the artifact that keeps *"the E2E covers the conversion"*
honest. It gets the same treatment as the code: claims verified by
mutation, not by reading.

## Correction: one entry was wrong

`core/task-store/reads.ts` was listed as **unproven**. It isn't. Core's
`store-stale-paused-renamed-hold.pg.test.ts` is a real-store test that
drives `listTasks` against a renamed hold column — and forcing the
hydration back to the `todo` literal **fails exactly that file's renamed
case**.

I had listed it as unproven because I assumed a separate E2E was needed.
Verified *before* removing it, since "already covered somewhere else" is
precisely the assumption that lets a gap hide.

## What remains, and why it is not another table row

**Lane 1 — real git.** `merger.ts`'s `resolveMergerLifecycleColumn` and
`executor.ts`'s `resolveReboundColumnFor` are module-private helpers
whose only callers sit inside merge/session machinery needing a real
worktree, branch and squash; `merger-ai.ts` is the same. Re-checked with
the lens that freed `auto-merge-finalization` and both self-healing
rebounds — **these genuinely need the lane.** The earlier over-broad
claim doesn't retroactively excuse them.

**Lane 2 — dashboard HTTP.** The four `register-task-workflow-routes`
sites sit behind `registerTaskWorkflowRoutes(ctx, deps)`, needing a full
`ApiRoutesContext` plus twelve injected deps. Standing that up is the
mock-the-world shell FN-5048 says not to add. The narrower alternative —
exporting the two private resolvers — yields **unit** evidence while
looking like E2E.

Deliberately not done rather than done badly and overclaimed.
`live-agent-count`'s `columnIsIntakeOrHold` is the same lane: only the
*waiting* predicate reads it, and its consumers are dashboard-side.

## Running total

**10 of 15 census sites proven end to end** across six suites; 5 remain,
each named with the lane it needs.

## Verification

- lifecycle suite 20/20; engine `tsc --noEmit` clean; `pnpm test:gate`
green (414 + 10 + 71)

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

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

## Summary by CodeRabbit

* **Tests**
* Updated end-to-end test coverage documentation to accurately reflect
verified workflow and task-store behavior.
* Clarified coverage gaps for live agent-count logic and dashboard
workflow routes.
  * Added a two-lane breakdown describing remaining coverage work.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 20:56:04 -07:00
gsxdsm
71f64025d8 triage census — core/live-agent-count.ts: the literal fallback is NOT fixture-only (finding, 2 sites still open) (#2604)
Taking `packages/core/src/live-agent-count.ts` from the shared
triage-guard backlog. **This PR does not convert it** — it corrects a
comment that would have stopped the conversion, and records why the
conversion is not a one-liner.

## Per-file guard count

| File | Before | After | Note |
|---|---:|---:|---|
| `packages/core/src/live-agent-count.ts` | 2 | **2** | not converted —
see below |

Census across `packages/*/src` excluding tests, for the pattern `column
=== "triage"` / `column !== "triage"`:

| File | Sites |
|---|---:|
| `engine/self-healing.ts` | 10 |
| `dashboard/src/routes/register-task-workflow-routes.ts` | 7 |
| `core/task-store/comments-ops.ts` | 3 |
| `dashboard/src/routes/board-workflows.ts` | 2 |
| `core/task-store/task-creation.ts` | 2 |
| `core/live-agent-count.ts` | 2 |
| `engine/spec-staleness.ts`, `engine/replan-target.ts`,
`engine/mission-feature-sync.ts`, `core/types/archive-planning.ts` | 1
each |

## The finding

The comment in this file asserted the literal fallback was unreachable:

> *"The literal fallback is fixture-only; board/store callers always
supply flags/IR."*

**It is false.** `useExecutorStats` resolves
`columnFlagsByTaskId?.get(task.id) ?? columnFlagsById?.get(task.column)`
— `undefined` for any card whose column is absent from the board's flag
map, which is exactly the renamed-or-undeclared column case. So the
literals run in production, on the cards least likely to match them.

**Consequence is under-reporting, not a stall.** A card in a renamed
planner column matches neither `triage` nor `todo`, so
`isWaitingAgentTask` returns false and the footer's queued count
silently omits it. Default-workflow cards still match through the `todo`
arm after the Planning merge, which is why nothing looks broken — the
same "still fires via the todo arm" shape as the executor sites I
audited in #2572, but here with a real observable effect.

## Why I did not convert it

Removing the id guesses means deciding what an **absent flag set**
should mean, and `"not intake"` is as much a guess as `"todo is intake"`
— either choice moves the numbers the operator sees in the footer. Doing
that safely needs the dashboard's flag-map population understood and a
test that pins the queued count, neither of which is a small change.

A comment asserting an untrue invariant is worse than no comment: it is
precisely what would stop the next person converting these two sites,
because they would read it and move on. Correcting it is the useful part
I can land with confidence right now; the census entry stays open.

## Verification

`tsc --noEmit` on `@fusion/core` clean; `pnpm lint` clean. Comment-only
change to production source, so no behaviour change and no changeset.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 20:47:52 -07:00
gsxdsm
c9df4b9dee U11 migration proof: the path an operator actually hits, with all three caveats answered (#2597)
Tests only. Proves the upgrade path the existing E2E does not cover, and
answers the three caveats.

## Why the existing coverage was not enough

The existing cases strand a card in a synthetic
`a-column-no-workflow-declares` on a **fixture** vocabulary. The real
upgrade leaves cards in **`triage`**, on the **real `builtin:coding`**
workflow.

That difference is the whole point: `triage` is still a legal `ColumnId`
and is still declared by legacy-coding, Ideas and every linear built-in
(R11), so nothing rejects it and **nothing throws**. The card simply
sits in a column its *own* workflow no longer declares — where it
carries no trait flags and is invisible to every trait-driven sweep.

## What is proven, on a temp PostgreSQL project

- A card left in the deleted `triage` column on a default-workflow board
is re-homed to `todo`, the merged Planning column.
- **Revert check in-suite:** without the sweep running, the card stays
in `triage`. Without this, the case above could pass because some
*other* sweep or a store-open reconcile moved the card — and would keep
passing if the sweep were deleted outright.
- **Progress and the plan artifact survive.** `preserveProgress: true`
is asserted end-to-end rather than trusted from the option name.
- A `userPaused` card is skipped and stays in the deleted column.

**Mutation-verified:** stubbing `reconcileUndeclaredTaskColumns` to
`return 0` turns **5 of 11** tests red, including all three positive
migration cases. The sweep is demonstrably the mover.

## The three caveats — answered

**1. `userPaused` cards are skipped → caveat, not a stall.**
An operator park is authoritative and the sweep must not override it, so
the card does stay in a column its workflow no longer declares. But it
is reachable two ways: unpausing makes the next sweep re-home it, and
**U11's undeclared-source escape hatch in `resolveAllowedColumns`
(merged with #2515) lets an operator move it by hand meanwhile** — that
path returns the workflow's rebound target instead of `Valid targets:
none`. Recorded as a test so the behaviour is a decision rather than an
accident.

My recommendation: **leave it skipped.** Re-homing a paused card
silently moves work an operator deliberately froze, and the escape hatch
already gives them a way out. Overriding a park to fix a column is the
wrong trade.

**2. Sweep only runs when self-healing is enabled → caveat, not a stall,
for the same reason.**
The escape hatch lives in the **move-validation** path, not in
self-healing, so it works with self-healing off entirely. A card
stranded that way is draggable out of the deleted column by hand. Worth
knowing: before #2515's escape hatch this *would* have been a hard stall
— `resolveAllowedColumns` returned `[]` for an undeclared source, so the
card could not be moved anywhere at all, by anyone.

**3. Re-home targets the HOLD column → correct, and progress survives.**
Under U11 the hold column **is** the Planning column, so "everything
lands in Planning" is the intended destination rather than a compromise.
Asserted with real step progress on the row.

**None of the three is worse than a caveat.** The reason all three are
survivable is the same single mechanism — the undeclared-source escape
hatch — which is worth knowing because removing it would silently
promote all three to hard stalls.

## Incidental

Fixed two fixture-level PostgreSQL column-name errors found while
writing this: `currentStep` → `current_step`, and `user_paused` is an
**integer** flag rather than a boolean. Both would have made a future
test here fail confusingly.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 20:31:15 -07:00
gsxdsm
a3a7f16977 Drift 4/4: reset reported a successful reset as a 409 "limbo" conflict — plus the audit verdict for every other site in the file (#2582)
## Drift 4/4 — a second live bug in the routes file, plus the audit for
the rest of it

**Stacks on #2571** (the P0). Merge that first.

### The bug

`POST /tasks/:id/reset` resolves its destination through
`resolveReboundColumnForTask` — the task's own workflow rebound column —
and then verified the outcome against the literal `todo`. **Twice.**

So on any workflow whose rebound column is not `todo` — Coding (Ideas),
any custom or renamed lineage — a reset that **succeeded** was reported
as a `409` "limbo state" conflict. The mover and its own verification
disagreed about where the card was supposed to land.

Both checks now compare against `resetColumn`, which is already in scope
two lines above the first one.

### Revert-proof, after I caught my own vacuous test

My first version of this test **passed with the fix reverted**. The
reset route demands `{ confirm: true }` and was 400ing before it ever
reached the column check, so `expect(status).not.toBe(409)` was
trivially true. That is the third time in this program a route/DOM
assertion has looked like coverage while checking nothing, and the
second time I have caught it in my own test.

With the confirmation sent, the reverted form fails: `expected 409 not
to be 409` — a correctly-reset card reported as limbo.

### Audit of the remaining sites in this file

| site | fires after #2515? | verdict |
|---|---|---|
| 2597/2607 manual retry | yes | **SAFE, by design.** Falls back to a
`todo` branch gated on the workflow declaring no `triage` column —
exactly the merged shape. Written for Coding (Ideas); the merge made the
default match it. |
| 4584 respecify | yes | **SAFE.** Already `column === "triage" \|\|
column === respecifyTarget`, and `respecifyTarget` resolves the intake
column. |
| 2887/2917 reset verification | **no** | **FIXED here** — false 409 on
a successful reset. |
| 1121 awaiting-planning enrichment | partially | **BROKEN, deliberately
not converted** — see below. |

### The one I chose not to convert, and why

`1121` filters on `column === "todo"`, so a workflow whose waiting lane
is named otherwise gets no enrichment and silently falls back to the
heuristic.

I converted it and **reverted**. Resolving each task's hold column needs
a per-task workflow read, and this is the board-load path whose own
comment exists because unbounded reads here *"turn a board load into
thousands of reads"*. My version did those reads for **every task before
the enrich limit applied** — trading a silent degradation for a
load-time regression on every board.

Converting it properly needs the hold column resolved per **workflow**
from data the board payload already carries, not per task from the
store. That is a real change with a measurable cost, not a rename. Left
with the cost written at the site rather than quietly skipped, and
flagged here so it is tracked rather than forgotten.

### Verification

`pnpm test:gate` (414 + 10 + 71), `pnpm lint`, dashboard typecheck
green. Route suites: 9 passed, including
`stranded-refinements-routes.test.ts` unchanged at 5.

### My drift set, final

| file | before | after | PR |
|---|---|---|---|
| `TaskCard.tsx` | 8 | 3 | #2558 |
| `ListView.tsx` | 5 | 3 | #2566 |
| `taskActivity.ts` (found underneath) | 1 | 1 | #2566 |
| `TaskDetailModal.tsx` | 4 | 3 | #2577 |
| `register-task-workflow-routes.ts` | 10 | 11 → 9 | #2571 + this |

Routes went 10 → 11 in #2571 (guards widened to accept resolved-intake
**or** `triage`, so a P0 fix could not reject anything previously
allowed) and back to 9 here. Every other survivor is the documented
no-metadata fallback: flags are absent during the pre-load window and
for a card stranded in a vanished lane, and a bare trait read would drop
the affordance in exactly those states. They retire with the load
window, not with a rename.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 20:31:02 -07:00
gsxdsm
131feb243c U8: the exit announcement was on a dead code path — move it to the handler the engine actually runs (#2578)
A merged behavior of mine has never executed. This fixes it and adds the
ratchet that would have caught it.

## The finding

`createDefaultNodeHandlers` chooses the prompt-node handler like this:

```ts
const promptLike = deps?.primitives
  ? createPrimitivePromptLikeHandler(deps.primitives, runCustomNode)
  : createPromptLikeHandler(seams, runCustomNode);
```

`executeWorkflowGraph` always passes `primitives:
this.createAuthoritativeWorkflowPrimitives(settings)`
(`executor.ts:6051`). **So `createPromptLikeHandler` — and with it every
`execute` / `step-execute` function in
`createAuthoritativeWorkflowSeams` — is unreachable for prompt nodes.**
Both objects are passed to the graph executor and only one is consulted.

The `NodeCompleted.exit` announcement added in #2507 was wired into that
seam. It type-checks, its tests pass (they call the seam object
directly), and it has never run in production. `runCodingSession` in the
primitives is the live twin, and that is where it emits now.

## How it was found — and why the negative is trustworthy

Instrumenting `createAuthoritativeWorkflowSeams.stepExecute` produced no
output for a run that demonstrably visits `steps#0:step-execute`. So did
instrumenting `createPromptLikeHandler`'s dispatch. A negative result
from instrumentation is worthless until the instrumentation is shown to
be observable, so: a `process.stderr.write` at module load of the same
file **did** appear, exactly once, in the same run. The two negatives
were real, not swallowed output.

This is also the answer to the open question I left in #2546 — the
pending-review routing move kept failing because the seam value it
depends on is never produced. **That move is still not landed here.**
This commit only relocates the announcement, so it stays small and
separately revertable; the routing move follows once its value
originates on the live path.

## The ratchet

A source assertion pins the dispatch rule: `deps?.primitives ?
createPrimitivePromptLikeHandler` and the executor's wiring of
`primitives`. Inverting or conditionalising that preference would
silently disable every behavior attached to the primitives path — the
same failure in the other direction — and **a seam-level unit test
cannot tell the two apart**, which is precisely how this survived review
twice.

## Red-green

Removing the emit fails 2 of the 4 new tests (`Tests 2 failed | 2 passed
(4)`). The other two are the regression floor: an ordinary completion
emits `success` with no `exit`, and the returned routing outcome is
unchanged — announcing must not reroute.

## Scope note

I did **not** delete the now-known-dead seam wiring in this PR.
`createAuthoritativeWorkflowSeams` is still passed to the graph executor
and its non-prompt entries (`stepReview`, `merge`) are reached through
other handlers, so deciding what is genuinely dead there is a deletion
audit of its own — and this program's rule is that deletions never ride
along with behavior changes. Filed as the next slice.

## Verification

- 4 new tests + exit-events + step-session + triage audit + ownership
ledger — **54 tests green**
- `pnpm test:gate` green (10 / 414 / 71); `pnpm lint` clean; `tsc
--noEmit` clean
- Changeset included (`patch`, `fix`)

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 20:30:54 -07:00
gsxdsm
2aa68867e5 U11 follow-up: usage-limit parking silently stopped covering the planning lane (#2567)
Second of the 39 audited `triage` sites from #2515's safety audit.
Unlike the first, this one is **real breakage**, not a proof of safety.

## The defect

The usage-limit pauser decides which tasks are on a rate-limited
provider by asking, per lane, whether the card sits in that lane's
column. The **planning** lane asked for the literal `triage`.

Now that Todo is merged into Planning, a card being planned on the
default workflow rests in `todo`. The branch resolves to an empty
provider list, so the card is not recognised as using the planning
provider — and is **neither parked when that provider hits its limit nor
resumed when it recovers**. It runs into the limit and fails.

Silent by construction: the detector reports nothing, it simply matches
no tasks.

## The test caught my own first attempt at testing it

The initial version asserted on the task that **triggered** the
usage-limit hit — and **passed against unfixed code**, because the
trigger is always parked directly without consulting `taskUsesProvider`.
Only a **bystander** card reaches the lane/column branch.

All three assertions now use a separate trigger, and the comment says
why, because the obvious test shape is the one that proves nothing.

## Why a paired literal rather than trait resolution

`taskUsesProvider` is a synchronous predicate over a task and settings,
with no IR in scope and no call site that could supply one without a
signature change reaching several callers.

Both ids name a pre-implementation column in every built-in — `triage`
for the split shape, `todo` for the merged one and for Coding (Ideas) —
so the pair covers the planning lane in all of them. Flagged for U12's
ratchet allowlist with that reason attached.

**Over-inclusion is the safe direction and is deliberate.** On a split
workflow a `todo` card is capacity-parked rather than actively planning,
so it may now be parked during an outage it was not using. Parking one
extra idle card is recoverable; failing to park a card whose provider is
rate-limited is not.

Regression direction asserted: widening the **column** match must not
widen the **provider** match — a planning card on a different provider
is still not parked.

## Audit progress

39 exclusive `triage` sites (from
`docs/solutions/architecture-patterns/u11-triage-literal-safety-audit.md`,
merged in #2515):

| status | sites |
|---|---|
| proven safe as-is | `spec-staleness.ts` — the guard is carried by
**status**, not column; the mechanical conversion was tried and is
*wrong* |
| confirmed safe by inspection | `mission-feature-sync.ts` (already
OR-pairs), `TaskContextMenu.tsx` (already trait-paired) |
| **fixed here** | `usage-limit-detector.ts` |
| remaining | 35, with the owners named in the audit |

Gate 309/309, lint clean.

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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 20:30:47 -07:00
gsxdsm
94e4b7453d U12 part 11: CONCEPTS.md taught the legacy enum as the model — and told operators column agents need a flag that no longer exists (#2545)
## U12 part 11 — CONCEPTS.md taught the legacy enum as the model, and
one entry was simply false

Docs only. No changeset: AGENTS.md excludes internal docs from
changesets.

### The one that is an error, not staleness

`CONCEPTS.md` → **Column agent**:

> Requires both the workflow-columns and graph-executor flags; with
either off, bindings are inert at execution time.

**That kill switch was removed.** `executor.ts` says so at the binding
site:

> the former workflowColumns kill switch was removed, so stale persisted
false values cannot silently disable custom-node, seam, or watcher
bindings

So the shared vocabulary document was telling operators that a shipped
feature depends on a flag that no longer gates it — and, worse, that a
stale persisted `false` would disable it. Anyone debugging "why isn't my
column agent running?" would have been sent to a flag that has nothing
to do with it. Corrected, with the history kept in one clause so it
stays legible to anyone who remembers the old behaviour.

### The one the plan named

`CONCEPTS.md` → **Task** defined the entity as moving "through columns
(triage, todo, in-progress, in-review, done, archived)" — teaching the
legacy enum as the model, in the document whose whole job is shared
vocabulary. The plan lists this entry explicitly as U12 scope.

It now says a Task moves through the columns **its workflow declares**;
that two Tasks on the same board may have entirely different column
sets; and that the six familiar ids are the **Default workflow's
choice** — which is why they saturate stored data — rather than a
property of the model.

### The same class, one doc over

`docs/dashboard-guide.md` restated a trait rule as an id list: "Eligible
existing tasks (triage, todo, in-progress, in-review)". The
implementation gates on traits — `isMutableLiveColumn` is `complete !==
true && archived !== true`. Now stated as the trait rule, with the
Default workflow's ids as an illustration rather than the definition, so
a renamed or custom workflow reads correctly against this doc.

### Left alone deliberately

The **Column (workflow-defined)** and **Default workflow** entries
already describe the workflow-scoped model accurately. Their references
to the legacy enum are about the Default workflow *specifically*, which
is exactly where naming those ids is correct — removing them would make
the docs less true, not more. This is the same judgement as declining to
delete `downgradeIrToV1IfPure` earlier in the unit: legacy vocabulary
describing a legacy-shaped thing is not drift.

### Verification

Docs only, no code paths touched. The factual claims were checked
against source rather than assumed: the kill-switch removal against
`executor.ts`, and the trait rule against
`TaskContextMenu.isMutableLiveColumn`.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 20:30:40 -07:00
gsxdsm
b0b9614fd5 U12 part 10: pin the R7 undeclared-column sweep — the repair three earlier PRs cited had no test of its own (#2543)
## U12 part 10 — the R7 sweep everything else leans on was itself
unpinned

`reconcileUndeclaredTaskColumns` re-homes a card resting in a column its
workflow no longer declares. It is the shipped answer to **R7**, and it
is the reason several earlier U12 deletions were safe — I cited it when
deleting the superseded `runWorkflowColumnsIntegrityPass` (#2500), and
again when arguing that a torn workflow switch leaves *recoverable*
state (#2512).

Its only coverage was **incidental**: two live PostgreSQL e2e suites
that exercise it in passing. A repair the rest of the unit leans on had
no test of its own — a guarantee everyone cites and nobody checks, which
is the exact shape this unit keeps finding.

### Six cases

The plan names three scenarios for U12; those are the three ways this
sweep can be wrong, plus I added the over-fire direction:

- repairs the stranded card to its workflow's **own** rebound target
(not a hardcoded legacy id)
- leaves a **user-paused** card alone
- leaves an **unresolvable-workflow** card alone
- is **idempotent** — a second run does not move the card again
- ignores a card already resting in a declared column
- repairs one stranded card **without disturbing** healthy or paused
neighbours

The leave-alone cases matter more than the repair. A sweep that
over-fires rewrites an operator's board, and this one runs at startup
against every task.

It also asserts `recoveryRehome: true` explicitly, because that flag is
load-bearing rather than incidental: the stranded card's *source* column
is undeclared too, so adjacency resolves to `[]` and every target is
rejected without it. Its absence once made this sweep a repair that
never repaired anything (#2462).

### Mechanism coverage — measured, and one case that isn't

Verified by mutation rather than asserted:

| mutation | result |
|---|---|
| delete the user-pause guard | **2 cases fail** |
| delete the already-declared short-circuit | **2 cases fail** |
| delete the unresolvable-workflow `continue` | still green |

That last row is stated at the assertion rather than hidden. The
unresolvable-workflow case pins the **outcome**, not the mechanism:
every mutation I could construct — dropping the `continue`, dropping the
try/catch so the throw reaches the outer handler — also ends in "no
move". So it is a regression guard on observable behaviour, not proof
the specific guard is reached, and I am not claiming otherwise.

### A decision I made

Store double rather than PostgreSQL. The sweep's decisions are pure
functions of the task list and the resolved IR, and a double makes the
"did **not** move" assertions exact rather than inferred from an absence
of change. It also keeps the suite off the slow lane, per the standing
rule against adding slow tests.

### Verification

`pnpm test:gate` (414 + 10 + 71), `pnpm lint`, engine typecheck green.
New suite: 6 passed.


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

## Summary by CodeRabbit

* **Tests**
* Added coverage for automatically restoring tasks stranded in
undeclared workflow columns.
* Verified paused tasks, unresolved workflows, and tasks already in
valid columns remain unchanged.
  * Confirmed repairs are idempotent and affect only the intended task.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 20:30:27 -07:00
gsxdsm
88c7502eae test(engine): prove the recovered-lease rebound AND its audit on a renamed board (#2539)
Test-only. Sixth E2E family. Closes the `mesh-lease-manager` ledger
entry.

## Two things to prove, and only one is where the card lands

The conversion note records the defect precisely:

> They were previously two independent `=== "todo"` comparisons that
could disagree, which is how **the audit came to claim a card landed in
`todo` when the workflow has no such column**.

1. the card rebounds to the renamed workflow's own rebound column
2. the unreachable-owner **audit** reports the column the card actually
reached

**(2) is the half that rotted silently, and it is the worse one.** The
audit is what an operator reads to find out where a recovered card went.
Confidently wrong is worse than absent — and on a renamed board it named
a column the workflow does not even declare.

## One thing deliberately NOT renamed

`decisionPath` keeps its legacy `lease-recovered-to-todo` wording. The
code explains why: it is a stable discriminator that existing queries
and dashboards match on, and renaming it would break them in order to
describe the same decision. The column actually used travels in
`newColumn`.

I've pinned that split with an explicit assertion so a future vocabulary
"cleanup" cannot quietly rename a field that **is not a column at all**.
Stating it here so it reads as a decision rather than an oversight.

## Mutation-verified

Forcing the legacy literal fails **exactly the three renamed cases**,
leaving the default-vocabulary floor and the fresh-lease negative green.

## Negative half

A lease renewed just now is not recoverable — "rebound anything with a
checkout" would tear live work off its owner.

## Fixture guard

The stale-lease seed writes lease bookkeeping through the admin client
and then **asserts the seed took effect**. A silently-dropped write
would make the recovery look correctly declined — the same trap that
produced a vacuous paused-park test earlier in this program, so it is
now guarded by default.

## Verification

- six live-E2E suites green together: **58/58**
- engine `tsc --noEmit` clean
- `pnpm test:gate` green (414 + 10 + 71)

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 20:30:19 -07:00
gsxdsm
30e0a8f291 U11 P0 audit: no hard stall in the recovery block — and one obvious fix is wrong (#2570)
Docs only. Answers the P0 question per site: **does it still fire, what
silently stops happening, is there a backup?**

## Headline: no hard stall

The alarming reading — *"the orphaned-planning-status sweeps stop
finding default cards, so a card whose planner died sits with
`status:"planning"` forever, invisible to discovery"* — **does not
hold.**

`triage.ts`'s `sweepStalePlanningStatuses` is the **periodic primary**
for that repair and already tests `column !== "triage" && column !==
"todo"`. It covers the merged column. The two self-healing sweeps
perform the same repair and are **redundant nets**, not the sole rescue.

That is the difference between a P0 and a cleanup, and it is only
visible by reading the **backup** path rather than the broken guard.
Recorded so nobody re-derives the panic.

## Self-healing block, by blast radius

| site | fires? | what stops | backup | verdict |
|---|---|---|---|---|
| `:12106`, `:12427` | no | clearing a stale `planning` status |
`triage.sweepStalePlanningStatuses` | redundant net lost — **cleanup** |
| `:2961/2981/3016` `recoverAdvancedTriageTasks` | no | re-homing a card
with a worktree + durable IR pin to its **pinned** resume column |
hold-release still releases it on capacity (real spec ⇒
`isUnplannedForExecution` false) | **degraded, not stuck** — fix first |
| `:12254` | no | a bounded priority nudge | none needed; the doc says
nudge, not rescue | **low** |
| `:12151`, `:9151` | **yes** | — | already OR-paired | **safe** |

**Second-order trap at `:3016`.** It skips when `resumeColumn ===
"triage"`, guarding against resuming a card into the column it already
occupies. Post-merge the pinned column is `todo`, which is **not**
skipped — so pairing the literal at `:2961` *without* also pairing
`:3016` produces a `todo → todo` move. **Repair the three together.**

## Two sites in the ownership split are already handled

- **`usage-limit-detector.ts:126`** (assigned to u8) — already fixed in
**PR #2567**. Real breakage: the planning lane stopped being recognised,
so a card being planned was neither parked when its provider hit a usage
limit nor resumed when it recovered.
- **`spec-staleness.ts:95`** (assigned to u7) — already proven safe
as-is, merged with #2515. **Its obvious fix is wrong.** I tried `||
task.column === "todo"` and it turned an existing test red: it breaks
the parked-preserved-progress path.

## The generalisation, which is the most useful thing here

**On the merged column, `todo` answers two different questions.**

After the merge `todo` is both the planner column *and* the
capacity-hold column. So any site that used `triage` to mean *"is being
planned"* **cannot simply be paired with `todo`**, because `todo` also
means *"is parked waiting for capacity"*. Those sites need **status or a
trait**, not a wider literal.

That is precisely the mistake a bulk conversion makes, and
`spec-staleness.ts` is the worked example: the guard was already asking
status, and widening the column would have destroyed the distinction.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 19:03:23 -07:00
gsxdsm
a68785a41d P0: two silent triage guards in the executor's ownership — one strands a card with nothing to rescue it (#2572)
P0 audit of the executor's assigned `triage` sites after the
Planning-column merge. **One of them can strand a card**, so leading
with that.

## The stall — `handleDepAbortCleanup`

`executor.ts` moved a dependency-aborted task to the **literal**
`triage`. The default coding lineage no longer declares that column.

A card that gains a dependency mid-execution has its work discarded and
is then parked in a column its own workflow does not define. Nothing in
the graph routes a card out of an undeclared column. The only rescue is
`reconcileUndeclaredTaskColumns`, which runs on the **next engine
start** — so between the abort and a restart the card is stalled with no
automatic recovery. It does not throw, so it would have surfaced as a
user report, not a red test.

Fixed to `resolveReboundColumnFor`, the helper the other ~16 executor
rebounds already use.

## The silent skip — `UsageLimitPauser.taskUsesProvider`

The planning lane was identified by the same literal. For a default card
the lane resolved to **no providers**, so when a provider hit a usage
limit during a *planning* session, the fan-out that pauses peers on that
provider skipped every default-workflow card and they kept hammering the
rate-limited provider.

Not a stall: the triggering task is still paused by the explicit
fallback below the filter. What was lost is blast-radius containment. A
planning session runs while the card is pre-implementation, and the
caller has already excluded `done`/`archived`, so that is exactly "not
the implementation column and not the review column" — which matches
`todo`, `triage`, `ideas`, and a renamed planner alike.

## Full audit table for my assigned sites

| Site | (a) Still fires for a default card? | (b) What silently stops |
(c) Action |
|---|---|---|---|
| `executor.ts:16395` `moveTask(id, "triage")` | **No** — writes an
undeclared column | Card parked where nothing routes it; rescue only at
next engine start | **Fixed** — `resolveReboundColumnFor` |
| `usage-limit-detector.ts:126` `column === "triage"` | **No** |
Usage-limit fan-out skips every default card; peers keep hitting the
limited provider | **Fixed** — pre-implementation predicate |
| `executor.ts:3409` `from === "todo" \|\| from === "triage"` | **Yes**,
via the `todo` arm | — | Unchanged; `triage` arm still live for
legacy-coding |
| `executor.ts:4951` `originColumn === "todo" \|\| === "triage"` |
**Yes**, via the `todo` arm | — | Unchanged |
| `executor.ts:4963` `originColumn === "triage"` double-hop | No, and
correctly so | Nothing — the extra hop exists only for shapes that
declare `triage` | Unchanged; still required by legacy-coding |
| `executor.ts:1110` `Type.Literal("triage")` | n/a | — | **Not a
column** — an agent ROLE in `spawnAgentParams` |

Counts for my ownership: **6 sites audited, 2 defects, 2 fixed, 3
correct as-is, 1 false positive.**

## Red-green

Reverting each fix fails its own test:

```
Tests  2 failed | 2 passed (4)
  × dependency-abort cleanup requeues to a DECLARED column
  × usage-limit fan-out … pauses a peer card sitting in the merged Planning column (id `todo`)
```

The other two are the regression floor and pass both ways by design: a
legacy workflow that **does** declare `triage` still fans out, and an
in-progress card is still **not** swept into the planning lane (the
guard must stay narrow — "any non-wip column" would have been the easy
wrong fix).

## Verification

- New audit suite + graph-boundary + step-session + ownership ledger —
**45 tests green**
- `pnpm test:gate` green (10 / 414 / 71); `pnpm lint` clean; `tsc
--noEmit` clean
- Changeset included (`patch`, `fix`)

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 19:03:10 -07:00
gsxdsm
ad3dc202f8 P0: a fresh project created every task into a column its workflow no longer declares (#2589)
Highest-severity finding of the post-merge audit, and it is the
**out-of-the-box** shape rather than an edge case.

## The defect

`createTask` resolves the intake column only as a by-product of
materializing the project's default workflow. A project that has never
**explicitly** set a default workflow has no persisted default row — so
that materialization returns nothing, `resolvedEntryColumn` stays
`undefined`, and the row falls through to the hard-coded `|| "triage"`.

Post-merge, that column does not exist in the default workflow.
Measured, three creates on one store:

| create | column |
|---|---|
| no default row persisted | **`triage`** ← broken |
| default explicitly `builtin:coding` | `todo` |
| explicit `workflowId` | `todo` |

`builtin:coding` is the **implicit** default via `DEFAULT_WORKFLOW_ID`,
and nothing writes a default-workflow row until an operator picks one.
So this was **every new task on a fresh project.**

## What it costs

Triage discovery resolves intake **by trait**, so `isAtIntakeColumn` is
false for a card sitting in `triage` while its workflow says `todo` —
**the card is never admitted for planning.** It isn't in the hold column
either, so hold-release ignores it. Only
`reconcileUndeclaredTaskColumns` eventually re-homes it.

A newly created task is invisible to planning until that sweep runs. Not
a permanent stall, but the first thing an operator does on a new project
is create a task.

## The fix — three parts, and missing any one leaves it half-fixed

1. `resolveDefaultWorkflowIntakeColumn` falls back to
`DEFAULT_WORKFLOW_ID` when no default row is persisted — the implicit
default every other resolver already assumes.
2. Both create paths consult it as a **last** resort before the literal,
so any path that already has an explicit column or a resolved entry
column is untouched.
3. **`isIntakeColumn` honours the same fallback.** Without this the card
lands in the right column but is classified *not*-intake and receives
`generateSpecifiedPrompt` instead of the bootstrap seed — and triage
admits a card only when its `PROMPT.md` reads as a seed, so it would sit
in Planning already looking "planned". FN-8587's failure mode by another
route.

`workflowId: null` ("No workflow") is excluded and asserted — there is
no workflow whose intake could be resolved, so that path keeps the
literal.

## Fixture drift, fixed with intent preserved

Seven tests asserted a created card lands in `triage`. None had their
assertion merely retargeted:

- **`move-task-if-planning`, `delete-task-if-planning`** — the mechanism
under test is the **live predicate**, not the column. Predicates and the
"advanced" column now name where the card actually rests.
- **`task-lifecycle-e2e`, `activity-log-parity`, `mission-store`** —
first-column and first-transition expectations.
- **`workflow-reconciliation-production-shape`** — the subtle one. Its
filler must occupy the **target** workflow's capped `triage` entry
column, but was created *before* the switch and so landed in the
**project default's** intake. It now names its column explicitly, which
makes the fixture independent of the project default — exactly the
coupling that let it drift.
- **`store-create-intake-column`** — the "lands in triage" guard now
names the invariant (the default workflow's *own* intake column) and
keeps a `not.toBe("triage")` so a regression back to the literal still
fails.

## Measured

Core package, against the 47-failure post-merge main baseline: **47
failed / 4413 passed — zero new failures.**

Three engine triage tests are red and are **not from this change**:
verified by stashing these edits and re-running against clean main,
where they fail identically. They arrived with #2515 and belong to the
triage-fixture owner.

Gate 414 + 10 + 71 green. Lint clean.

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

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

## Summary by CodeRabbit

* **Bug Fixes**
* New tasks now consistently start in the default workflow’s `todo`
intake column, including fresh projects without persisted workflow
settings.
* Bootstrap `PROMPT.md` content is now created consistently for all
supported task-creation paths.
* Task movement and deletion behavior now correctly respects current
columns and avoids acting on stale task data.
* Workflow reconciliation and activity tracking now reflect the updated
default task lifecycle.

* **Tests**
* Expanded coverage for intake-column resolution, task lifecycle
transitions, stale candidates, and workflow edge cases.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 19:03:03 -07:00
gsxdsm
beb33b5dd1 P0 STALL 3: rescue cards stranded in a column their workflow no longer declares (fixes 8 red tests on main) (#2591)
Based on `main`. **Fixes STALL 3 — and it needs no data migration.**

## The stall

#2515 removed `triage` from the default lineage while leaving the id
legal for stored rows, and shipped **no migration**. Planning discovery
resolves a card's lanes from its own workflow, and for a default card
`intake` and `hold` **both** resolve to `todo` — so a card *sitting* in
`triage` matched neither branch and was admitted by nothing.

`triage` was the default intake column before #2515, so **every existing
project has cards there.**

Nothing else rescued them. #2515's escape hatch makes an undeclared
source column resolve to the workflow's rebound target, but every path
that *uses* it (executor, agent-heartbeat, merger) is triggered by
**active work**, and a parked card has none. The card sat until an
operator dragged it by hand.

## Proof this is a real regression, not a stale test

**8 tests in `triage.test.ts` were RED on clean `origin/main`** —
verified by swapping main's `triage.ts` into this tree and re-running.
**All 8 pass with this change.** The sharpest:

```
expected "specifyTask" to be called 4 times, but got 0 times
```

Discovery was admitting zero triage cards.

## The fix

A card resting on a legacy pre-implementation id that its own workflow
no longer declares is **unowned by construction** — no lane's rules
apply to it. Admitting it to **planning** heals it through the normal
path: it gets planned, and finalize releases it to the workflow's hold
column, **re-homing the row as a side effect of ordinary work**. No
migration, no backfill, no operator action.

## The narrowing is the load-bearing part

My first version rescued **any** undeclared column, and it was wrong. A
card can also sit in a column its workflow genuinely owns while the
**selection** fails to resolve — the resolved default IR then doesn't
declare that column either. That version re-specified a parked Coding
(Ideas) `ideas` card, breaking **FN-7596's manual-intake rule** (an
ideas card is promoted by an *operator*, never auto-planned).

`triage.test.ts` caught it. The rescue is now scoped to the legacy
planner ids, so a workflow-specific column name is never second-guessed.
That distinction — healing #2515's orphans vs. overruling a workflow
about its own board — is the whole design.

## A user-pause hole this would have opened

`couldBeCandidate` screens `paused` but not `userPaused`, so a row
carrying `userPaused` alone slipped through. Harmless before (an
undeclared-column card was admitted by nothing) and **reachable the
moment admission widens**. Planning a card mutates its lifecycle state,
which the ratified safeguard forbids for a user-paused card — so the
guard is now explicit rather than inherited. Covered by a test and
mutation-verified.

## Cost

Resolution now derives roles **and** declared column ids from one
`resolveWorkflowIrForTask` call, replacing
`resolveTaskLifecycleColumns`. Same call, same `irCache`, same bounded
concurrency window — **cost unchanged**, no added read.

## Verification

- **Mutation-verified three ways**, each failing a different test:
remove the rescue; widen it back to any undeclared column; drop the
user-pause guard
- 8 previously-red-on-main tests now green
- 263 triage/scheduler tests green, merge gate green (482 + 10 + 71),
tsc clean, lint clean

## What this does NOT do

It does not re-home rows that are past the planning stage. Admission
still requires `isTaskStillInPlanningStage`, so a card that advanced
past planning in an undeclared column stays with self-healing's
advanced-recovery sweep rather than being re-specified here. If such
rows exist and are also stranded, that is a separate sweep and a
separate PR.

No changeset: `@fusion/engine` is private.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-07-29 19:02:55 -07:00
gsxdsm
a56253f426 P0: plan approve/reject rejects EVERY card on a merged planning column — operator-visible stall, cannot approve or reject (#2571)
## P0 — plan approve/reject is dead for cards on a merged planning
column

**This is the "card stuck with nothing to rescue it" case you asked to
hear about immediately.** Found auditing my files after #2515.

### What happens

#2515 removed `triage` from the merged default lineage — one
pre-implementation column, id `todo`, displayed "Planning". Four routes
guard with:

```ts
if (task.column !== "triage") throw badRequest("Task must be in 'triage' column ...")
```

On a workflow with no `triage` column that condition is **true for every
card**, so the routes reject all of them:

| route | effect on a merged-lineage card |
|---|---|
| `POST /tasks/:id/approve-plan` | 400 — **cannot approve** |
| `POST /tasks/:id/reject-plan` | 400 — **cannot reject** |
| `task_refine` route (×2) | 400 — refine blocked |

A card parked `awaiting-approval` can be **neither approved nor
rejected**. It is stuck, the operator is being asked for a decision they
have no way to give, and nothing throws to reveal it.

### Why it is the worst variant of this drift

Everything we have chased so far is a guard that silently **stops**
firing. This is a guard that silently starts firing on **everything** —
same root cause, opposite symptom, and worse, because the failure is
visible to the operator as a task that demands an answer and refuses
every one.

### The fix, and a deliberate choice

The guards resolve the workflow's own intake column through the existing
`resolveIntakeColumnForTask`, and they **widen rather than replace**: a
card is accepted if it is in the resolved intake column **or** in
`triage`.

That is on purpose for a P0. The fix cannot reject anything the route
previously allowed, so it carries no regression risk of its own.
Narrowing to the resolved column alone is a follow-up once the legacy id
is gone everywhere — not something to do under time pressure on a route
that gates operator decisions.

I found the value of that when a strict replacement broke 3 pre-existing
tests in `stranded-refinements-routes.test.ts`. The widened form passes
all of them **and** the new P0 cases.

### The convergence number goes UP, and I am not hiding it

Live-code `column === / !== "todo" | "triage"` in
`register-task-workflow-routes.ts`: **10 → 11**.

Each converted guard keeps the legacy id as an explicit second
condition, so a widened guard has two literals where it had one. The
metric counts id literals; it does not know the guard is now strictly
more correct. Reporting the direction that is true rather than the one
that looks better — and flagging that this file's number will only fall
once the widening can be removed.

### Revert-proof

Restore either bare literal and the matching case fails with **400 where
200 is expected**, on a `todo` card with `awaiting-approval`. A third
case pins that the guard still **narrows** — an `in-progress` card is
still rejected — so this cannot be mistaken for deleting the check.

### Verification

`pnpm test:gate` (414 + 10 + 71), `pnpm lint`, dashboard typecheck
green. New suite 3 passed; `stranded-refinements-routes.test.ts` back to
5 passed (it was 3 failed under the strict form).

### Still auditing

`TaskDetailModal.tsx` conversion is in flight on a separate branch.
`TaskCard.tsx` (#2558) and `ListView.tsx` + `taskActivity.ts` (#2566)
are already open — and note #2566 covers `isTaskAgentActive`, whose
planner-lane clause has the *silent* version of this same bug: planning
cards read as idle everywhere at once.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 11:11:50 -07:00
gsxdsm
3c46ecca14 Drift (unowned): the planner-activity signal was never written after #2515 — the three badge conversions were reading an empty field (#2594)
## Drift, unowned: the planner-activity signal was never being written

**Stacks on #2577.** Merge order: #2566 → #2577 → this.

### This is what made the other three PRs cosmetic

`addRecentPlannerActivityForFreshAgentLog` in `useTasks.ts` stamped
`recentAgentActivityAt` **only for cards literally in `triage`**. #2515
removed that column from the default lineage, so after that merge the
stamp never happened for a default-workflow card.

Every consumer downstream then had **no data to act on**, however
correctly it resolved its own column traits:

- the pulsing Planning badge (TaskCard, ListView)
- the agent-active row border
- the column header's executing count

So #2558 / #2566 / #2577 convert the *readers* of a field that nothing
was *writing*. They ask the right question of an empty value. This is
the fix that gives them something to read — and it was on nobody's drift
list. I found it chasing why a badge test would not go green.

### The decision, and why I did not thread metadata here

The hook processes SSE and has no resolved column metadata. The lane is
matched by id against **both** shapes — pre-merge `triage`, post-merge
`todo`.

Over-stamping a legacy hold-lane card is harmless: every consumer
additionally requires the column to be an **intake** lane before
rendering anything, so the extra timestamps are filtered downstream.
Threading board context into this hook to avoid a harmless over-stamp
would be a much larger change for no behavioural gain, so I widened
instead and wrote the reasoning at the site.

### Also converted

`Column.tsx`'s move-progress prompt. Unlike the same prompt in
TaskCard/ListView/TaskDetailModal, this component's `column` **is** the
drop target, so its own `columnFlags` are the target's traits — no
lookup needed. Worth noting because the same-looking regex meant three
different things across four files, which is exactly why these were
converted one at a time.

### Revert-proof

Restore `task.column !== "triage"` and the merged-column case fails:
`expected undefined to be '2026-07-28T12:00:01.000Z'` — nothing stamped,
badge has nothing to render. A companion case pins that the stamp still
**narrows**: an `in-progress` card is not planner activity.

### Verification

`pnpm test:gate` (482 + 10 + 71), `pnpm lint`, dashboard typecheck
green. `useTasks.test.ts` + `Board.test.tsx`: 210 passed.

### Audited and deliberately left

| site | verdict |
|---|---|
| `Column.tsx:550` `workflowMode \|\| column === "triage"` | Dead in
practice — `workflowMode` is true whenever lanes resolve, so the
disjunct only matters with no metadata at all. Not worth a change. |
| `taskSorting.ts:73` `column === "todo"` | Still correct for the
default (the merged column keeps that id); wrong only for a renamed
workflow's hold lane. Needs the sort to take flags — a wider signature
change than this PR's scope, and cosmetic (ordering) rather than a lost
affordance. |
| `worktreeGrouping.ts:77` | Same shape as above; grouping only, no lost
control. |

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 11:11:12 -07:00
gsxdsm
cf7b1a3d46 Drift review (unowned): gridlock detection + autopilot retries resolve the hold column — main 103→101 (#2561)
> **Based on `main`, not on my U7 stack** — merges in any order, no
dependency on #2517.

My assigned files (`triage.ts`, `replan-target.ts`) are at zero, so this
picks up two lifecycle-column literals **no unit's file list claims**.
Both ask *"is this card in the hold column?"* by the id `todo`, and both
are broken **today** for any workflow that renamed it.

## gridlock-detector — the worse of the two

`column !== "todo"` decides which cards count as **schedulable**, and an
empty schedulable set is an **early return**. On a renamed board the
detector concluded *"no gridlock"* at exactly the moment a real one
would be visible.

> A detector that goes quiet on the boards it cannot parse is worse than
one that is absent, because its silence reads as health.

**Converting only the `todo` half would have shipped a still-broken
detector**, and the test caught it. The `active` filter is equally
literal (`in-progress` / `in-review`) — and an empty active set is
*also* an early return. Two literals, one silence.

The `in-progress` half sits **outside the drift review's `todo|triage`
pattern**, which is precisely why a count-driven sweep would have left
it behind and declared the file done. Converted here rather than
deferred as out of scope. Worth flagging to the other workers: the
convergence metric is a good *tracker* but a bad *definition of done* —
an adjacent literal in the same predicate can preserve the whole bug at
a lower score.

## mission-autopilot

The retry compared against `todo` **and moved to the literal `todo`** —
so on a renamed workflow it relocated the card into a column the
workflow may not declare (R7) on **every retry**. Now resolves the hold
role; when the workflow declares none it leaves the card in place and
says so, because the error/status clear still runs, so the retry is not
lost — the card just stays in its own lane.

## Two fixture defects of my own, both caught by the tests failing
wrongly

**My first autopilot tests re-implemented the decision** and asserted on
the copy — proving only that the copy works. That is the anti-pattern
named in
`docs/solutions/store-fake-defects-that-masquerade-as-production-bugs.md`
(#2534) and in the #2527 ratchet review, and I had no excuse: the
constructor takes two stores and `handleTaskFailure` is public.
Rewritten to drive the real method.

**My first gridlock fixture failed on both vocabularies** — the detector
needs three preconditions and I supplied one. A test that fails on its
*no-regression* half is a broken fixture, not a discovered bug. The
"both halves failed" heuristic from that same doc is what flagged it.

That is eight fixture defects across this unit, every one caught by
reading *why* a test failed rather than making it pass.

## Revert proofs, each isolated to one literal

| Restored | Result |
|---|---|
| gridlock hold filter | **1 of 5 fails** (renamed case) |
| autopilot move target | **1 of 5 fails** (renamed case) |

Default-vocabulary halves pass either way — the correct signature for
conversions that change no existing behavior.

## Convergence

Measured against `origin/main` with a comment-stripped scan of `column
=== / !== "todo" | "triage"` in `packages/*/src`, excluding tests:

**103 → 101.**

(The gridlock `active` filter is a third site fixed here that this
pattern does not count.)

## Verification

| Check | Result |
|---|---|
| new suite | 5/5 |
| pre-existing gridlock + autopilot suites | 85/85, **no expectation
edits** |
| `tsc --noEmit` (engine) | clean |
| `pnpm lint` | clean |
| `pnpm test:gate` | green (414 + 10 + 71) |
| `pnpm check:changesets` | clean |

## Still unowned after this

`mission-feature-sync.ts` (1: a planning-lane check) and
`auto-claim-snapshot.ts` (1: `isRunnableAutoClaimCandidate`, a **pure
sync** predicate that needs the injected-lane pattern from #2551, not a
resolve). `notification-service.ts` has one more with a different
semantic — *"has progressed past"* — which needs its own thinking rather
than a mechanical swap. I will take these next unless someone claims
them.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 10:54:35 -07:00
gsxdsm
bbaa254dc3 test: add the missing debug to 27 logger mocks (206 → 4 failures) (#2573)
**Test-infrastructure fix.** 29 test files. No production code, no
altered assertions, no widened timeouts.

Now **3 commits** (#2584 merged into this branch): the logger-mock
sweep, a cron-runner follow-up from review, and the 4 residual failures
the sweep deliberately deferred.

**Whole branch: 764 tests, 0 failures** across the touched set.

---

## Commit 1 — the missing `debug` on 27 logger mocks

`createLogger`'s real shape is `{ log, debug, warn, error }`. 27 engine
test files mock `../logger.js` with logger-shaped literals that **omit
`debug`**, so any production path reaching `log.debug` threw:

```
TypeError: schedulerLog.debug is not a function
TypeError: runtimeLog.debug is not a function
TypeError: log.debug is not a function      (SelfHealingManager.start)
```

Measured, same commit, same 27 files:

| | Failed | Passed |
|---|---|---|
| before | **206** | 558 |
| after | **4** | 760 |

**202 failures fixed by one missing mock export.** Per-file: `notifier`
36→0, `plugin-runner` 56→0, `grok-runtime-routing` 14→0,
`self-healing-completion-fanout` 1→0. That last one also leaked an
unhandled rejection out of `startMaintenance`, which vitest warns "might
cause false positive tests" elsewhere in the file.

*A note on the number:* a full `engine-default` run went 283 → 106
across my two sessions, but `main` moved in between (U11 landed), so
that spread is **not** attributable here. 206 → 4 is the honest figure:
same commit, same file set, only this diff varying.

## Commit 2 — cron-runner's factory (greptile P1)

My regex required `log: vi.fn()`; `cron-runner.test.ts` uses `log:
cronLoggerSpies.log`, so the `createLogger` factory's returned literal
never matched and the logger production received still lacked `debug`.

**Measured before claiming a live fix, and the numbers don't support
that part:** `cronLoggerSpies.debug.mock.calls.length` is **0** across
all 155 tests, and the suite is 155 passed both before and after. The
described failure mode — `tick()` hitting `log.debug`, throwing, and
being swallowed by its own error handler — is **not reachable today**,
because no test exercises those three branches (`cron-runner.ts:377`,
`:385`, `:410`). The fix is defensive, not curative. The real gap it
surfaced is **missing coverage** for schedule dedupe / scope mismatch /
lost atomic claim, which I did not write blind to close a thread.

## Commit 3 — the 4 residuals

**`notification-service` (3):** messages moved to DEBUG in production
(`:580`, `:846`) while tests asserted `schedulerLog.log`.

The token case needed more than a relocation. It asserted
`expect(schedulerLog.log).not.toHaveBeenCalledWith(containing("new-token"))`.
Moving only the *positive* assertion to `debug` would leave the secrecy
check watching a channel the message no longer uses — a token could leak
through `debug` and the test would still pass. The negative now runs
across all four channels. **Verified it bites:** interpolating the token
into the debug line fails the test.

**`openclaw-runtime-integration` (1):** `../pi.js` mock missing
`wrapToolsWithOutputBudget` (same class as #2547); this suite exercises
a non-pi runtime, exactly where that wrapper applies.

**Not swept repo-wide, and the measurement is why.** 37 `pi.js` mocks
omit that export. Patching 30 moved the set from **11 failed to 10** —
thirty files of churn for one test. Reverted. Commit 1 earned its
27-file diff with 202 fixes; this one earned nothing, and a no-op sweep
is just future merge conflicts for other workers on this program.

---

## Why none of this is appeasement

AGENTS.md forbids making a red test pass by loosening it. This does the
opposite: the mocks were **wrong** — they claimed to stand in for
`createLogger` while missing part of its interface. Nothing was relaxed;
stubs were completed, and the one assertion I did move got **stronger**
(four channels instead of one).

## Also deliberately not done

Extending `scripts/check-mock-completeness.mjs` to catch this class.
Measured first: a naive rule over relative intra-package mocks flags
**147** factories of which **146 are green** — almost pure false
positives. The barrel heuristic works because `cliSrc` gives a tight
import surface; that doesn't transfer. A gate that noisy gets ignored,
which is worse than no gate.

## How this was found

While characterizing U9's review lane. These files were pre-existing
baseline noise under mutation runs — and that noise is exactly what made
my own safeguard baseline (#2511, corrected in #2520) report two false
verdicts. **A red suite does not merely lack coverage; it makes every
nearby measurement untrustworthy.**

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 10:50:46 -07:00
gsxdsm
d2ce1ba8b5 U11: resolve the scheduler's event-handler columns by trait (10 live sites, sync resolution) (#2518)
Based on `main`. Ten live `"todo"` sites in `scheduler.ts` now resolve
the column by trait.

## Four groups, converted together

They fail **independently**, and a half-conversion is indistinguishable
from a working system:

| group | sites | failure mode |
|---|---:|---|
| **Wake triggers** | 4 | **Latency** — snapshot invalidation,
mission-failure tracking, engine requeue tracking, move-to-backlog wake.
The wake doesn't fire and the card waits up to a poll interval. Exactly
why it would go unnoticed indefinitely. |
| **Parked wakes** | 2 | Latency — unpause and planning-finished, keyed
on hold OR intake. |
| **Dependency** | 3 | **Not latency.** After a blocker completes or is
soft-deleted, the query returns nothing, so the dependent is *never*
unblocked and waits on a blocker that already finished. |
| **Agent link** | 1 | `rollbackRunningAgentsForQueuedTodoTask` passes a
synthetic `{ column: "todo" }`. Wrong here **drops a running agent's
task link** — the worse direction of that safeguard. Resolved
`parkedColumns` is now passed through too, rather than letting the
helper fall back to its legacy default. |

## Resolution is synchronous, deliberately — the part worth reading

My first cut used the async resolver and made the `task:updated`
listener `async` to suit it. **That broke 5 pre-existing tests, and the
tests were right:** introducing a new `await` *before* a listener's
existing synchronous work defers everything after it to a microtask and
reorders handlers relative to a synchronous emitter.

A conversion must not change event ordering. It now uses the store's
sync IR path (`resolveTaskWorkflowIrSync`), so **no new suspension point
is introduced anywhere**.

That's the fifth time in this program a change that looked like a move
quietly altered behavior — and the first time the existing suite caught
it before review.

## Verification

- **Mutation-verified:** forcing the resolver back to the literals fails
**4 of the 6** new tests
- 110 tests green across all 8 scheduler suites (6 new)
- Fail-soft to the legacy pair: an unresolvable workflow behaves exactly
as before rather than losing the wake
- merge gate green (309 + 10 + 71), tsc clean, lint clean

## Measured

10 of my unit's 68 remaining code sites converted.

`scheduler.ts` now has **one** `"todo"` literal left in live code:
`isRunnableQueuedOverlapCandidate`, which is **exported but has no
production caller** — its only consumer was the legacy dispatcher
deleted in #2505. That's a **deletion, not a conversion**, so it is
deliberately not in this PR.

No changeset: `@fusion/engine` is private.

🤖 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**
* Scheduling now correctly recognizes workflow-specific hold and intake
columns, including renamed columns.
* Tasks entering a hold column reliably trigger scheduling and wake-up
behavior.
  * Dependency recovery now finds blocked tasks in renamed hold columns.
* Planning, unpausing, task completion, deletion, and requeue flows now
respect each workflow’s configured parked columns.
* Prevented unnecessary scheduling for moves between unrelated workflow
columns.

* **Tests**
* Added coverage for renamed hold-column scheduling, wake-up, and
dependency-unblocking scenarios.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-29 10:44:12 -07:00
gsxdsm
fb7ab6df26 test: re-green self-healing, worktree-pool and DB-corruption assertions (#2592)
**Test-only.** Three files, two commits. No production changes.

| File | Before | After |
|---|---|---|
| `self-healing-db-corruption` | 5 failed / 1 passed | **6 passed** |
| `self-healing` | 1 failed / 411 passed | **412 passed** |
| `worktree-pool` | 2 failed / 57 passed | **59 passed** |

All three are the same underlying story in different costumes: **the
assertion is watching a channel production stopped using**, or a step
that aborts before it can log at all.

## Commit 1 — the fake store was missing the health refreshers

`surfaceDbCorruption` *refreshes* health before reading the snapshot
(`FNXC:IncompletePgPorts 2026-07-26-20:45`, so PG connectivity is
re-checked instead of trusting an always-healthy sentinel). The fake
carried **neither** refresher, so the async branch fell through to
`this.store.refreshDatabaseHealth()` — undefined — and the step threw
before reaching dispatch. **Every assertion in the file was measuring
zero calls against a step that had already aborted.**

Both stubs are **no-ops on purpose.** Production ignores the refresh
return and reads `getDatabaseHealth()` immediately after, so the
snapshot mock stays the single source of truth. My first attempt
delegated them to `getDatabaseHealth`, which consumed a *second* value
per pass from the test that queues three `mockReturnValueOnce` snapshots
(one per `runMaintenance`) and broke its corruption → clear → corruption
ordering. Faithful beats convenient.

## Commit 2 — two more debug-level assertions

- **`self-healing`**: `"auto-archive: archived …"` is emitted at DEBUG
(`self-healing.ts:2747`); the test asserted `.log`. The mock already had
`debug` (from #2573), so only the target was stale.
- **`worktree-pool`**: both checkout-failure cases assert on
`console.error`, which is *correct* — `createLogger`'s `debug` writes
there. But debug is **gated on `FUSION_DEBUG`** (`logger.ts:43`), unset
under vitest, so the line was never emitted. One test is literally named
*"logs checkout -- failure at debug level"* while asserting a channel
debug could not reach.

Fixed by enabling `FUSION_DEBUG="worktree-pool"` for the suite and
deleting it in `afterEach` so the flag can't leak into sibling files.
**Deliberately not** fixed by re-pointing the assertions at another
channel — that describes whatever the code happens to do rather than the
behavior the test names.

## Verified each actually guards

A test that merely stops failing can still assert nothing, so every fix
was mutation-checked:

| Mutation | NEW failures |
|---|---|
| `surfaceDbCorruption` returns early | **5** |
| remove the auto-archive debug line | **1** — that test, only it |
| remove the checkout-failure debug line | **2** — both cases, only them
|

## Known residual, stated rather than hidden

`self-healing-db-corruption` **still exits non-zero** with 9 unhandled
`this.store.listTasks is not a function` rejections from
`openSurfacingCycle` (`self-healing.ts:7737`). These **predate this
change** — identical count before and after. The maintenance pass opens
one shared surfacing cycle up front, independent of which steps
`stubMaintenance` stubs.

I tried to clear them and backed it out, twice:
- adding `listTasks: async () => []` lets the cycle open, but then
*other* unstubbed sweeps run for real — an orphaned-planning-segment
audit fires and breaks 3 assertions expecting `recordRunAuditEvent`
never to be called;
- stubbing the four `surface-*` siblings didn't help either, because the
cycle is opened by the **pass**, not by the steps.

Making that file honestly green needs a fake complete enough for the
whole maintenance registry — a bigger change than the bug in front of
me, and one that would bury the fix above. Flagging it rather than
shipping a half-sweep.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 10:43:14 -07:00
gsxdsm
21497b23db P0: approved plans never released after #2515 + triage.ts 11 -> 0 (#2549)
Rebased onto post-#2515 `main`. **This PR is the fix for a P0 stall**,
not just a conversion.

## Stall 1 — approved plans were never released

`recoverApprovedTask` opened with a bare `task.column !== "triage"`.
#2515 merged Todo into Planning on the default lineage, so every default
card now sits in `todo` and **this guard rejected all of them**. An
approved plan whose finalize was interrupted was never released, and
nothing else owns that card. Callers: `triage.ts:1296` (stuck-kill
recovery) and `in-process-runtime.ts:1460`.

`triage` stayed a legal id, so nothing threw — the guard just stopped
matching.

I verified the fix **mechanism** rather than assuming it.
`resolveLifecycleColumns` on the IR #2515 actually shipped returns:

```
{ intake: "todo", hold: "todo", wip: "in-progress", review: "in-review", complete: "done", archived: "archived" }
```

so the converted guard admits default cards. The new regression test
asserts the **return value**, because on a merged lineage the card is
already where the release would send it — "no move issued" is what
*both* the broken and the fixed code do, so only the outcome
discriminates.

**Mutation-verified:** restoring the literal `!== "triage"` fails 2 of 5
tests.

## A defect of my own, found while auditing — same shape as the P0

`clearStaleSpecifyingStatuses` is a board-wide startup sweep with no
single task to resolve lanes against, and I had resolved **both** its
queries from the default workflow. Post-#2515 that workflow's `intake`
and `hold` are the **same** column, so both queries collapsed onto
`todo` and **nothing ever swept `triage`**. A legacy or Coding (Ideas)
card holding a stale `planning` status would then occupy a planning
admission slot permanently — exactly the failure the 2026-07-04 note
above that function warns about.

Now queries the **union** of the legacy planner ids and the resolved
lanes, deduped by task id. Querying extra columns is free here: the
sweep only reads, and every row is filtered on `status === "planning"`
before anything is written.

Caught by `triage.test.ts`, **not by my own tests** — worth recording,
since it is the same collapse the P0 is about.

## Rebase note

The discovery conflict was resolved **in favour of `main`**. Main's
version is strictly better than mine: it resolves lanes with the
**async** `resolveTaskLifecycleColumns` (so it is not subject to the
sync-resolver limitation below), keeps the two admission branches
disjoint for a merged column, and bounds concurrency. My sync version
was dropped.

## Measured

| file | comparisons before | after |
|---|---:|---:|
| `packages/engine/src/triage.ts` | **11** | **0** |

## Known red, NOT from this PR

8 tests in `triage.test.ts` fail on **clean `origin/main`** — confirmed
by swapping main's `triage.ts` into this tree and re-running (same 8).
They are reporting the upgrade stall, not stale expectations: a card
*sitting* in `triage` is admitted by nothing after #2515 (`expected
"specifyTask" to be called 4 times, but got 0 times`), and #2515 shipped
no data migration re-homing those rows. Left untouched here — the fix is
a data migration, not a conversion. Reported to the coordinator
separately.

## Verification

- merge gate green (414 + 10 + 71), tsc clean, lint clean
- mutation-verified as above

## Separate finding — affects every worker

`resolveTaskWorkflowIrSync` **cannot resolve a task's selection in
production.** `getTaskWorkflowSelectionImpl` is `return undefined`
unconditionally and `getTaskWorkflowSelectionAsyncImpl` is *"always
PostgreSQL path"*, so the sync resolver **always** returns the DEFAULT
workflow IR. `moves.ts` already hit this and fixed it by going async.
Consequence for `resolvePlannerLanes` here: correct for default-lineage
cards (the default IR is exactly what comes back — which is why Stall 1
is genuinely fixed) and **inert for custom workflows**. Not papered
over; the async path is main's discovery code, and converting the
remaining event-listener sites needs the handler-reordering problem
solved first.

No changeset: `@fusion/engine` is private.

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

---

## P0 audit table — every `triage` site in my assigned files

(a) does it still fire for a default-workflow card after #2515? (b) if
not, what silently stops happening? (c) fix.

| site | (a) still fires? | (b) what silently stops | (c) disposition |
|---|---|---|---|
| `triage.ts:613` wake handler | **yes** | — OR-shaped (`todo \|\|
triage`), still matches | converted anyway |
| `triage.ts:651` evacuation guard | **yes** | — OR-shaped, still
matches | converted anyway |
| `triage.ts:741` stale-planning sweep | **yes** | — OR-shaped, still
matches | converted anyway |
| `triage.ts:1088` `recoverApprovedTask` | **NO** | **STALL 1** —
approved plan never released; nothing else owns the card | **fixed +
regression test + mutation-verified** |
| `triage.ts:1396` advanced-recovery discovery | **NO** | that recovery
never matches a default card | fixed by the same conversion |
| `clearStaleSpecifyingStatuses` (mine) | **NO** | **my own defect** —
both queries collapsed onto `todo`, `triage` never swept; stale
`planning` holds an admission slot forever | **fixed** (union of legacy
+ resolved lanes) |
| `replan-target.ts:177` / `:185` | **NO** | **STALL 2** — see #2552 |
fixed in #2552 |
| `spec-staleness.ts:95` | **NO** | narrow: a Planning card with null
status and `currentStep > 0` now skips staleness where it previously did
not | **recorded, not fixed** — see below |
| discovery (`isAtIntakeColumn`) | **NO** | **STALL 3** — a card
*sitting* in `triage` is admitted by nothing | **reported, not fixed** —
needs a data migration |

**Why `spec-staleness.ts:95` is not fixed here.** The guard already
returns `false` for `status === "planning"` and `needs-replan`, so an
*actively* planning card is still covered by status. The `column ===
"triage"` arm only added coverage for a planner-lane card with **no**
status — and post-merge that case is genuinely ambiguous, because `todo`
is now both the planning lane and the hold lane, so a card with progress
there may legitimately be a released card that *should* skip. Guessing
either way is a behaviour change without evidence, so I recorded it
rather than picking one.
2026-07-29 10:30:18 -07:00
gsxdsm
d1cbb8ce90 U11: rank assigned work by lifecycle role (1 -> 0), plus two documented non-conversions (#2563)
Based on `main`. Continuing with unassigned work in my area
(scheduling/ranking core).

## Measured (drift-review tracking)

| file | comparisons before | after |
|---|---:|---:|
| `packages/core/src/assigned-task-ranking.ts` | **1** | **0** |

## What was wrong

`tierForTask` identified the two **actionable** tiers by literal id —
`in-progress` → `in_progress`, `todo` → `ready_todo` /
`partial_blocked`.

The file's own comment already recorded half of this:

> Only treating default `todo`/`in-progress` as titled hid assigned work
as a bare count

But the fix that followed was a **floor, not a fix**: unrecognised
columns fall to `other` so work stays *visible*, while a renamed hold
column loses `ready_todo` and `partial_blocked` entirely. Work that is
genuinely ready to start then ranks **below everything already in
progress**, so an agent reading its Wake Delta sees ready work buried.

Nothing errors and nothing disappears — the ordering is just wrong,
which is how it survived a comment that noticed the adjacent problem.

`partial_blocked` is the sharper loss: it's the **only** tier
distinguishing "ready" from "waiting on a dependency" for hold-column
cards, and it was unreachable for any renamed workflow.

## Two sibling files deliberately NOT converted

Checked before assuming work existed:

**`live-agent-count.ts` — already trait-driven.** Its literals are the
else-branch of `flags ? traits : literals`, and the source says why:
*"The literal fallback is fixture-only; board/store callers always
supply flags/IR."* Converting a fixture-only fallback would be churn.

**`task-priority.ts` → `sortTasksForDisplayColumn` — dead.** No
production caller. The dashboard has its own independent implementation
in `app/components/taskSorting.ts` with a richer signature
(`doneSortMode`, `isArchivedColumn`), and that's the one `Lane.tsx`
imports. Core's copy is reached only by its own tests and the barrel
export.

That's the **third dead export** this unit has found by checking
reachability before converting (after the legacy dispatcher and
`isRunnableQueuedOverlapCandidate`). Deletion is a separate concern from
conversion and is not in this PR.

## Verification

- **Mutation-verified:** not threading `roles` through to `tierForTask`
fails **4 of 6** new tests
- 13 tests green (6 new + the pre-existing ranking suite)
- merge gate green (414 + 10 + 71), tsc clean, lint clean

No changeset: `@fusion/core` is private.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-07-29 10:26:46 -07:00
gsxdsm
bad35775a1 Drift 3/4: Task Detail intake affordances from traits — the UI half of the #2571 approve/reject stall (4→3) (#2577)
## Drift conversion 3 of 4 — Task Detail, the UI half of the #2571 stall

**Stacks on #2566.** Merge order: #2558 → #2566 → this. (#2571 is the P0
and is independent — merge it first regardless.)

### Convergence number

Live-code `column === / !== "todo" | "triage"` in `TaskDetailModal.tsx`:
**4 → 3**

All three survivors are the documented no-metadata fallback, same shape
as TaskCard and ListView: `workflowMoveMetadata` is `null` until the
detail payload resolves, and a bare trait read would drop these controls
during that window.

### This is the UI half of the P0

`isAwaitingApproval` and the standalone Delete button were both gated on
`task.column === "triage"`. On the merged lineage (#2515) that is false
for every card, so a task parked `awaiting-approval` **loses its
Approve/Reject controls in the one surface that shows them**.

#2571 fixes the routes that *reject* those actions. This fixes the UI
that stops *offering* them. Either half alone leaves the operator stuck
— one with buttons that 400, the other with no buttons at all.

### Three conversions

| site | was | now |
|---|---|---|
| `isAwaitingApproval` + standalone Delete | `column === "triage"` |
resolved column's `intake` |
| `requiresExecutionModeReplan` | `todo \|\| in-progress` | `hold \|\|
countsTowardWip` |
| move-progress prompt | source column ids | **target** column's flags |

The replan rule is "this card may already hold a plan or a live
execution context" — which the traits state directly;
`todo`/`in-progress` was the Default workflow's spelling of it.

The move prompt is the mistake I made first in TaskCard, where its
regression test caught that the site tests the move **destination**, not
the card. Carried the lesson here rather than repeating it.

### Tested through a pure seam, and why

`requiresExecutionModeReplanForTest` is exported so the rule can be
asserted as a function of (column id, flags).

Asserting it through the modal means booting async detail loading to
observe one boolean — and an earlier DOM-level attempt at exactly this
class of assertion (in #2566, ListView) **passed with the conversion
reverted**, because the text it matched also appears in a column header.
I am not repeating that. A seam discriminates; that DOM test did not.

Revert-proof: restore `column === "todo" || column === "in-progress"`
and the merged-column case fails, because that column is `intake + hold`
and carries no `countsTowardWip`. The suite also pins that the rule
still **narrows** (a complete lane needs no replan) and that the legacy
fallback is unchanged when flags are absent.

### Verification

`pnpm test:gate` (414 + 10 + 71), `pnpm lint`, dashboard typecheck
green.

**No new failures**: `TaskDetailModal.rendering.test.tsx` reports the
same 28 pre-existing failures with and without this change, diffed by
test *name* against a stashed clean tree.

### Drift set status

| file | before | after | PR |
|---|---|---|---|
| `TaskCard.tsx` | 8 | 3 | #2558 |
| `ListView.tsx` | 5 | 3 | #2566 |
| `taskActivity.ts` (found underneath) | 1 | 1 | #2566 |
| `TaskDetailModal.tsx` | 4 | 3 | this |
| `register-task-workflow-routes.ts` | 10 | 11 | #2571 (P0, widened on
purpose) |

Survivors are no-metadata fallbacks except the routes, where the guards
deliberately accept resolved-intake **or** `triage` so a P0 fix cannot
reject anything previously allowed. Those retire together once the
legacy id is gone board-wide.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 10:26:21 -07:00
gsxdsm
c92bce2f8c test: delete 2 project-engine-manager tests for the deleted cross-project cap (#2575)
**Test-only.** One file, 2 obsolete tests + 1 dead import removed. No
production change.

`project-engine-manager.test.ts` has been **red on main: 2 failed / 44
passed** → now **44 passed**.

## The failures

Both threw `TypeError: Cannot read properties of undefined (reading
'acquire')`, because both reach `(manager as any).globalSemaphore` — a
private field that no longer exists.

`project-engine-manager.ts:88` records why (`FNXC:CapacityModel
2026-07-28-20:10`, *"drop the cross-project cap"*):

> The shared cross-project semaphore, its mutable limit and the
`concurrency:changed` subscription are **DELETED**. Capacity is two
numbers per project; a machine-wide cap was a third limiter with its own
separate authority (a central-DB singleton row), and reconciling it
against the per-project gates is exactly the multi-limiter arbitration
this simplification removes.

So both tests assert residual-slot accounting on a shared pool that was
**deliberately** removed — not a regression.

## Why deleted rather than repaired

There is no shared semaphore left for them to describe. Reconstructing
one inside the test would assert a capacity model the engine no longer
has — a test that passes while describing fiction, which is worse than
the red it replaces.

Also drops the now-dead `ScopedAgentSemaphore` import (these were its
only uses). Lint does not flag unused imports here, so it would
otherwise have sat as quiet dead code.

## What I did NOT take, and why

`workflow-graph-optional-step-fix.test.ts` — the other red file adjacent
to this lane, 5 failures. Its failures are **U11 column-vocabulary
drift**: the replan rebound now resolves to `todo` where the test
expects `triage`, and one case gets a hard-cancel pause-abort log
instead of the Plan Review replan message.

That is the U11/U12 owner's semantics to settle. Picking whichever
column makes the assertion pass could silently encode the wrong
lifecycle target — and per the graph-entry contract doc, a rebound
landing in a column the workflow does not declare is precisely the
failure mode that "does not fail a test; it disables a recovery path in
production." Flagging it rather than guessing.

## Running tally of this cleanup thread

| File | Before | After |
|---|---|---|
| 27 logger mocks (#2573) | 206 failed | 4 failed |
| `merge-error-recovery` (#2559) | 10 failed | 0 |
| `reviewer` (#2547) | 2 failed | 0 |
| `project-engine-manager` (this) | 2 failed | 0 |

Every one was a test describing behavior that had moved or been deleted,
or a mock that had drifted from its real shape — none was a product
defect. That pattern is worth naming: on this repo a red non-blocking
suite has mostly meant *stale tests*, which is exactly what makes it
easy to ignore, and exactly why it silently corrupted my own safeguard
measurements in #2511.

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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 10:22:46 -07:00
gsxdsm
1c9f6c546b P0: default Planning cards read as ADVANCED after #2515 (FN-8596 stranding re-opened) + 6 red tests repaired (#2552)
Rebased onto `main` (post-#2515) and **upgraded from a conversion to a
P0 stall fix**.

## What changed since review

Greptile's P1 on this PR said the `plannerColumn` seam was unused —
*"every current production caller omits `plannerColumn`, so this default
still compares against `triage`."* That was correct, and **#2515 turned
it from an unused seam into a live stall.**

## The stall

#2515 merged Todo into Planning on the default lineage (one
pre-implementation column, id `todo`, display "Planning"). `triage`
stayed a legal id, so nothing throws — the bare `column === "triage"`
guards in `hasAdvancedPastPlanning` just **stopped matching for
default-workflow cards**.

A default card in `todo`, status cleared to null by the stale-status
sweep, carrying execution stamps from a previous pass, now returns
**ADVANCED**. So `isTaskStillInPlanningStage` is false and nine guarded
call sites refuse planning updates, finalize, delete and handoff:

`triage.ts` 3108 / 3117 / 3160 / 3438 / 3937 — `self-healing.ts` 12126 /
12448 / 12454

The file's own FNXC note at `:150` already records what that costs:

> Nobody owned the card and it sat indefinitely.

This is that same FN-8596 stranding, re-opened by the column merge.
Confirmed empirically — the rescue test fails on pre-fix code.

## The fix is an asymmetry, and that's the point

The two guards are **not the same rule**:

1. the FN-8596 **arrival-order rescue** — a stamp predating arrival in
the planner lane means replanning, not advancement
2. **"the planner column itself is never advanced"**

Rule 1 must recognise the merged Planning column. **Rule 2 must not** —
on the merged lineage `todo` is *also* the released/hold lane, so making
it blanket "not advanced" would strand the release path instead: a
released card with steps would read as still-planning and
`hasAdvancedPastPlanning(t) || releasedToTodo` would stop distinguishing
anything.

Rule 1 is already gated on the stamp predating arrival, so a released
card later claimed by execution keeps its newer stamp and still reads as
advanced.

**Closed via the default** (`mergedPlanningColumn = "todo"`) rather than
by wiring call sites — the stall closes everywhere at once, with no
call-site change and nothing to collide with another worker's slice.
Dedicated-planner workflows (Coding (Ideas), and every workflow still
declaring `triage`) are byte-identical.

## Second commit: 6 tests left RED on main by #2515

Verified pre-existing by stashing every local change and re-running —
same 6 failures on a clean branch. `resolveReplanTargetColumn` reads the
IR rather than a literal, so it **self-healed** to the correct
post-merge answer (`todo`); the expectations were the stale half.
Updated to the post-merge truth, not loosened — each still pins one
exact column.

## Audit table for this file (P0 sweep)

| site | still fires for a default card? | what silently stopped |
disposition |
|---|---|---|---|
| `replan-target.ts:177` `inPlannerLane` | **NO** | FN-8596 rescue —
planning writes no-op, card strands | **fixed** (rule 1) |
| `replan-target.ts:185` never-advanced | NO | nothing — must stay
dedicated-planner-only | **deliberately unchanged** (rule 2) |
| `resolveReplanTargetColumn` | yes (IR-driven) | — self-healed to
`todo` | tests repaired |
| its two `return "triage"` fallbacks | n/a | reachable only for
workflows declaring neither column | **recorded, not fixed** —
column-policy decision, has its own covering test |

## Verification

- **Mutation-verified both directions:** dropping the merged lane from
rule 1 fails **2** tests; wrongly extending rule 2 to the merged lane
fails **1**
- 50 replan-target tests green (7 new)
- merge gate green (414 + 10 + 71), tsc clean, lint clean

## Separate finding — affects every worker

`resolveTaskWorkflowIrSync` **cannot resolve a task's selection in
production.** `getTaskWorkflowSelectionImpl` is `return undefined`
unconditionally and `getTaskWorkflowSelectionAsyncImpl` is *"always
PostgreSQL path"*, so the sync resolver **always** returns the DEFAULT
workflow IR. `moves.ts` already hit this and fixed it by going async.
Any conversion built on the sync resolver is inert for **custom**
workflows — harmless for default cards, since the default IR is exactly
what comes back. Reported to the coordinator for the other workers; not
actionable in this PR, which uses no sync resolution.

No changeset: `@fusion/engine` is private.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-07-29 10:15:54 -07:00
gsxdsm
969c2cdf1d capacity part 4: drop the central global_concurrency table (migration 0037) (#2555)
Final piece of the cross-project cap removal. Enforcement (#2509),
settings/API/UI (#2529) are merged; this removes the storage.

Nothing read the table. `global_max_concurrent` held the deleted
machine-wide cap; `currently_active`/`queued_count` were written only by
`acquireGlobalSlot`/`releaseGlobalSlot`, measured earlier in this
program to have **no production caller**, so those counters were
fiction. Live “N running (all projects)” telemetry comes from
`CentralCore.getLiveRunningAgentCounts` and is unaffected.

Dropped rather than left unread: a lingering table with
plausible-looking counters invites a future reader to trust it — the
same trap as a readable-but-ignored settings key.

## The trap this hit, because the first attempt looked correct

`schema-applier.ts` warns that *“migrations are registered here
explicitly (not auto-discovered from the migrations dir), so a new .sql
file that is not wired through a version constant + bookkeeping check
silently never runs.”*

My first pass added the `.sql`, updated the drizzle model and bumped the
baseline — **and the table was still present in a fresh database**. It
was caught only because the test asserts the table is *gone*
(`to_regclass(...) IS NULL`) rather than merely unreferenced; an
absence-of-reference assertion would have passed while the table
survived.

Now registered properly: `DROP_GLOBAL_CONCURRENCY_VERSION = "0037"`,
explicit path constant, applied-check, bookkeeping insert.

The historical `0000` baseline is deliberately **not** rewritten — a
fresh database CREATEs the table then drops it, converging with upgraded
databases without editing history, which is how every prior migration
here behaves.

Also removed: the drizzle model, the `centralTableNames` entry, and the
`replacesCentralSeed` special case in the SQLite migrator (a legacy
SQLite `globalConcurrency` table now has no destination and is simply
not migrated — correct, since its cap is deleted and its counters were
never written).

## Verification

`pnpm lint` clean · core `tsc` clean · `pnpm test:gate` green (414 + 10
+ 71) · `schema-applier` 75/75 · `sqlite-migrator` 43/43 · full core PG
suite **1044 passed / 3 failed** — the same 3 pre-existing
(`central-archive-secrets` log-prefix,
`workflow-settings-project-identity` legacy fallback ×2).

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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 10:15:42 -07:00
gsxdsm
3da5358b33 test(U9): add a core unit-gate so dependency gating and FN-5819 block merges (#2569)
**U9, PR10.** Two `package.json` lines. No test or production changes —
this only decides *when* existing tests run.

## The gap

Two U9 safeguards are well covered but sat in **no blocking gate**.
Their proof lives in `packages/core/src/__tests__/task-merge.test.ts`,
and core's only gate job is `test:pg-gate` (two PG tests). A regression
in either surfaced in non-blocking full-suite — after the merge.

## What's now gated, each verified by mutation delta

**`task-merge.test.ts`**

| Invariant | Mutation | NEW failures |
|---|---|---|
| Safeguard 3 — dependency gating | `getTaskCompletionBlocker` drops the
unresolved-dependency reason | **5** |
| FN-5819 — exception bounded to a live group | drop `group.status ===
"open"` | **1** |
| FN-5819 — exception bounded to shared members | widen
`isSharedBranchGroupMemberIntegration` to every task | **4** |

Both FN-5819 directions matter. This is the **only** scoped exception to
`autoMerge:false`, so its *narrowness* is the invariant — not merely its
existence. A test that only proves the exception works would pass while
the exception swallowed every task.

**`legacy-adoption.test.ts`**

| Invariant | Mutation | NEW failures |
|---|---|---|
| FN-8492 — orphaned pending results REWRITTEN to failed, never DELETED
| delete instead of rewrite | **2** |

That one matters because deletion *silently satisfies* the merge gate:
the gate blocks on pending/failed results, not on an enabled step with
no result, so deleting lets a task merge with its review skipped.

## Implementation

Adds `packages/core` → `test:unit-gate`, a curated **non-PG allow-list**
mirroring `engine-core`'s discipline (explicit membership, not a glob),
run as a third parallel job in the root `test:gate` block alongside the
engine and PG jobs.

**Gate fires — verified, not assumed:**
- drop the dependency reason → `pnpm test:gate` **exits 1**
- drop the FN-5819 open-group bound → **exits 1**
- restored → **exits 0**

## Cost: no measurable increase

| | Runs |
|---|---|
| baseline | 13.07s, 14.95s |
| with the job | 12.20s, 12.58s |

It runs in parallel with the existing jobs and finishes well inside
them, so the delta sits inside run-to-run variance. **I am not claiming
a speedup** — the honest reading is "no measurable cost", and the
variance band here is wider than the change.

## Reversible call made rather than asked

A new `test:unit-gate` script rather than widening `test:pg-gate` or
adding a glob. `test:pg-gate` carries PG setup these pure unit tests do
not need, and a glob would admit all of core by default — which
AGENTS.md explicitly forbids ("tests never graduate into the gate by
default"). Membership stays explicit so the next addition has to state
its evidence.

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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 10:08:29 -07:00
gsxdsm
4ee6800a8f test(U9): gate the review-lane leniency guard (prose rejection never becomes APPROVE) (#2564)
**U9, PR9.** Config only — one line added to the `engine-core`
allow-list, plus its justification.

The merge half of U9's safeguards now fires in blocking CI (#2526).
**This is the review half, none of which did.**

## What's admitted

`workflow-step-verdict-parsing.test.ts` holds
`proseSignalsClearApproval`'s leniency guard: **a prose REJECTION must
never be promoted to APPROVE.** Removing the
REVISE/RETHINK/negated-approval disqualifiers fails **11** of its cases.

This is a **fail-open** defect on the path to an irreversible merge — a
review saying *"looks good, but this must be fixed before merging"*
would read as an approval. That belongs in the gate, not in a
non-blocking run hours after the merge.

Measured across 3 runs:

| | Files | Tests | Wall |
|---|---|---|---|
| before | 19 | 414 | 6.16 / 6.25 / 6.21s |
| after | 20 | 482 | 6.28 / 6.55 / 6.33s |

**+~0.2s** against a ~60s ceiling.

**Gate fires — verified, not assumed:** removing the disqualifiers →
`pnpm test:gate` exits 1 (11 failed / 471 passed); restored → exits 0.

## What is deliberately NOT admitted, and why

`reviewer.test.ts` holds the sibling family — *"a provider outage is not
a review verdict"*. I verified by mutation that it genuinely guards
this: removing the escalation branch fails **5** tests covering
"escalates a rate limit as `ReviewerProviderError` instead of an
`UNAVAILABLE` verdict", "does not burn the reviewer fallback retry
budget on a provider outage", and "escalates as transient once the
network retry budget is exhausted". That budget exists to bound *bad
reviews*; spending it on an outage fails tasks that have nothing wrong
with them.

It is green in `engine-default` but **fails 72 cases under
`engine-core`**, because that project resolves `@fusion/core` through
the **reduced** `index.gate.ts` barrel/bundle and the suite reaches
exports it does not carry (`__vite_ssr_import_0__.has…` TypeError).

Admitting it would mean widening the gate barrel — which trades away the
bundle's entire reason for existing (FN-7669 measured the barrel import
phase as the gate's dominant wall-time cost).

**I tried it, measured the 72 failures, and backed it out** rather than
either shipping a red gate or — the tempting version — loosening the
test until it passed under the reduced barrel. The reason is recorded in
the config next to the allow-list so the next person doesn't rediscover
it. Widening the barrel for this suite is a real option, but it is a
gate-performance decision with its own measurement, not a side effect of
a test-coverage PR.

## Review-lane characterization status

By-name coverage search performed first in every case, per the lesson
from #2520:

| Invariant | Verdict |
|---|---|
| FN-8492 orphaned pending results rewritten, never deleted | covered
(NEW=2) |
| FN-7720 bypass writes `skipped` | covered (NEW=1) |
| FN-7720 bypass never fabricates a verdict | **was vacuous** — fixed in
#2541 |
| Provider outage escalates, never becomes a verdict | covered (NEW=5),
outside the gate — see above |
| Prose rejection never promoted to APPROVE | covered (NEW=11) — **now
gated** |
| testMode never issues real AI calls | **was permanently red** — fixed
in #2547 |

Still uncharacterized, stated rather than implied: branch-group member
integration and promotion sequencing (the FN-5819 scoped exception).

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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 10:01:26 -07:00
gsxdsm
41031dbe2c Drift review (unowned): auto-claim candidacy resolves hold + completion roles — three literals, two opposite failures (#2565)
> **Based on `main`** — independent of my U7 stack and of #2561; merges
in any order.

Third unowned drift-review site. `isRunnableAutoClaimCandidate` is the
single source of truth for *"may an agent claim this task?"* (FN-6873),
and it carried **three** lifecycle literals that fail in **opposite
directions**.

## The two failures

**`column === "todo"` gated candidacy** on the hold role. Keyed on the
literal, a renamed workflow's candidate set was **permanently empty** —
agents were never offered its work, and nothing anywhere reported it.
Silence, not an error.

**`dependency?.column === "done" || "archived"` gated dependency
satisfaction**, and this is the more dangerous half: a dependency that
finished in a renamed **complete** column was never recognised as done,
so the dependent stayed **blocked forever**.

One makes work invisible; the other makes it permanently ineligible.
Both are silent.

## Roles resolve per task, not per pass

The non-obvious part: **a dependency may sit on a different workflow
from the claimant.** A single per-pass answer is wrong for one of them
on any mixed board — so the map is keyed by task id, and the dependency
check reads the *dependency's* roles, not the claimant's.

Asserted directly: a dependency completed in `done` (default vocabulary)
satisfying a claimant waiting in `drafting` (renamed).

## Shape

Both callers already have the store and are async, so they resolve for
real rather than taking the injected-lane fallback the *synchronous*
predicates needed (#2551). The predicate itself stays synchronous — a
resolved-roles map is passed in — because it runs inside two
`filter`/`flatMap` bodies.

Tasks absent from the map keep the legacy ids, so a partially-resolvable
board degrades to today's behavior instead of silently emptying the
candidate set.

**Type narrowing preserved.** The two callers take `Pick<TaskStore,
"listTasks">`, which is what makes them testable without a real store.
Rather than widening to the whole `TaskStore`, they now take
`Pick<TaskStore, "listTasks"> & WorkflowIrResolverStore` — the minimal
additional shape resolution needs.

## Revert proofs, isolated per literal

| Restored | Result |
|---|---|
| hold literal only | **3 of 6 fail** |
| dependency-completion literals only | **1 of 6 fails** |

The three default-vocabulary cases pass under both. Splitting the proof
matters here: it confirms the two halves are **independently**
load-bearing rather than one masking the other — a single combined
revert would have shown 3 failures and told me nothing about the
dependency half.

## Convergence

Measured on `main`, comment-stripped scan of `column === / !== "todo" |
"triage"` in `packages/*/src` excluding tests:

- this file alone: **103 → 102**
- with #2561: **103 → 100**

The `done` / `archived` literals fixed here sit outside that pattern and
are not counted — same caveat as #2561's gridlock `active` filter. Two
PRs now where the real fix is larger than the metric shows.

## Verification

| Check | Result |
|---|---|
| new suite | 6/6 |
| pre-existing auto-claim suite | 17/17, **no expectation edits** |
| `tsc --noEmit` (engine) | clean |
| `pnpm lint` | clean |
| `pnpm test:gate` | green (414 + 10 + 71) |
| `pnpm check:changesets` | clean |

## Remaining unowned in my area

`mission-feature-sync.ts` (1, a planning-lane check) and
`notification-service.ts` (1, *"has progressed past"* — a different
semantic needing its own thinking, not a mechanical swap). Taking those
next unless claimed.

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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 10:01:14 -07:00
gsxdsm
8578a1d27d U8 PR5: thread the implementation exit to the step seam, and declare the stepwise pending-review park (inert) (#2546)
Follows **#2519** (U8 PR4). Both halves are inert — **no behavior
change** — and this removes the blocker PR4 documented.

## What was blocking

PR4 could only land its IR half because the pending-review ending could
not reach a graph edge on the **default** workflow. Three links in the
chain:

| Link | Problem |
|---|---|
| `runGraphTaskStep` | awaited the memoized implementation pass and
**discarded** its result |
| `RunTaskStepResult` / `RunSingleStep` | had nowhere to carry an exit |
| `stepExecute` seam | flattened every ending to `step-done` /
`step-failed` |

All three are fixed. The outcome stays `failure` (the step genuinely did
not complete) while the **value** now names the ending — which is what
`runForeach` propagates upward, since it returns a failing instance's
value as the foreach node's own. Every other ending keeps `step-failed`
byte-identically.

One design note: the exit is a property of the **pass**, not of a step.
A single memoized pass serves every foreach instance, so all instances
report the same ending — correct, because the ending is what stopped the
whole session.

With the value surviving, the stepwise IR declares the same
`review-handoff` park node and `steps --outcome:review-pending-->
review-pending-handoff --success--> end` edge the plain-`execute` shape
got in PR4, inherited by the final-review and Ideas variants that clone
it.

## A bug my own threading introduced, and what caught it

The first threading commit covered **one of the two** paths out of
`runProjectedGraphTaskStep`. The early-return branch carried the exit;
the main path goes through `runTaskStep` in `step-runner.ts`, which
builds its own result and dropped it — i.e. it worked on the path I
happened to read, and not on the path the default workflow actually
takes.

**FN-5436's regression test caught it, not code review.** That is the
second time this test has stood between this unit and a silent
regression, which is worth recording somewhere durable:
`executor-step-session.test.ts > FN-5436: pending-review skip on
no-fn_task_done exit` is the load-bearing test for this area.

## Why the seam flip is still not here

With the threading complete I applied the behavior half again — flip the
execute seam to return `review-pending`, delete the inline
`handoffTaskToReview`, add a named compat classifier for user-authored
graphs. **FN-5436 still failed**: the card did not reach `in-review`, so
something between the seam value and the park node is not routing under
that harness. I have not isolated whether that is the mock store's IR
resolution (it exposes no `getWorkflowDefinition`, so the run resolves
the built-in through a different path), a foreach aggregation detail, or
the park node's own seam.

I stopped rather than keep guessing, and reverted the behavior edits so
this lands green and inert. Shipping a half-routed move is exactly the
failure this unit exists to remove — a lifecycle transition that
silently does not happen. The alternative on offer was to relax
FN-5436's assertion, which would have been appeasing a test that is
telling the truth.

### What the instrumentation showed (done after opening this PR)

I ran the bounded next step rather than leaving it as a note. Two facts,
both measured:

1. **The IR is correct.** Resolving
`BUILTIN_STEPWISE_FINAL_REVIEW_CODING_WORKFLOW_IR` at runtime shows the
node and the edge survive the final-review variant's edge rewiring:

```
EDGES [{"from":"steps","to":"browser-verification","condition":"success"},
       {"from":"steps","to":"review-pending-handoff","condition":"outcome:review-pending"},
       {"from":"steps","to":"end","condition":"failure"}]
HAS NODE true
```

That matters because the variant does `template.edges = [ ... ]` (a
wholesale replacement) and filters outer edges touching `review` —
`review-pending-handoff` is not `review`, so it survives. Worth knowing
before anyone adds another node near it.

2. **The `stepExecute` seam is never invoked in that harness**, even
though the run terminates at `steps#0:step-execute` and the
implementation session demonstrably runs (`"Agent finished without
calling fn_task_done but Step 0 is blocked on pending review"` is in the
task log). A `console.log` at the seam's value computation produced no
output. So the exit is threaded correctly and the IR can route it, but
under this harness the value never originates.

3. **Nor is `createPromptLikeHandler`'s returned handler.**
Instrumenting its dispatch (`node.id` + resolved seam) produced nothing
either — so the node is not reaching the prompt-like path at all.

**Control experiment, because a negative result from instrumentation is
worthless until you prove the instrumentation is observable.** A
`process.stderr.write` at module load of the same file appears exactly
once in the same run, so writes from that module *are* captured under
this harness and the two negatives above are real, not artifacts of
swallowed output.

That narrows the remaining work to one question — what actually drives
`steps#0:step-execute` in this run, if neither the prompt-like handler
nor the `stepExecute` seam does — and rules out the IR, the foreach
propagation, the threading, and the instrumentation as suspects.

**Next step, now much narrower:** find the handler registration this run
resolves for a foreach instance node (the graph executor's handler map,
not the seam table), then flip the seam, delete the inline handoff, and
update the three ratchets that will correctly fire — PR3's routing pin,
the out-of-band adjacency check, and PR1's ownership ledger
(`runImplementation` 3 → 2; `handleGraphFailure` 0 → 1 for custom graphs
only).

## Verification

- `executor-step-session` + exit-events + ownership ledger +
graph-boundary — **56 tests green**
- `builtin-workflows` + `builtin-coding-workflow-ir` — green. The
layout-completeness contract required a layout entry for the new node in
all four stepwise-derived workflows; placed off the main line, because a
park is an exit and not a stage.
- `pnpm test:gate` green (10 / 309 / 71); `pnpm lint` clean; `tsc
--noEmit` clean
- Changeset included (`patch`, `internal`)

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 09:54:39 -07:00
gsxdsm
3cef9c226e Drift 1/4: TaskCard planning affordances from traits, not "triage" (8→3 measured; one site needed a new wire fact, one conversion was wrong and the tests caught it) (#2558)
## Drift conversion 1 of 4 — TaskCard.tsx

Taking the dashboard surfaces from the drift review. This is the
board-card one; ListView, TaskDetailModal and
register-task-workflow-routes follow separately so each stays
revertable.

### Convergence number

`task.column === / !== "todo" | "triage"` in `TaskCard.tsx`: **8 → 3**

The three survivors are **one documented fallback**, not scattered
checks. `getTaskColumnFlags` (Column.tsx) returns `undefined` when a
card's column is absent from the resolved metadata and is not the
rendering column — the pre-load window, and a card stranded in a lane
its workflow dropped. Converting to bare trait reads would have removed
every planning affordance in exactly those states, so the legacy ids
survive **once**, at the role helpers, plus the move prompt resolving
its own target. They retire with the load window, not with this change.

I'd rather report 8 → 3 with the reason than 8 → 0 with a regression
behind it.

### Why this file was urgent

Every planning affordance was gated on `task.column === "triage"`. Land
U11 — merged column keeps id `todo`, `triage` deleted — and each
comparison silently becomes false: **delete button, awaiting-approval
controls, planner badge, step list all vanish from planning cards.**

### One site needed a new fact on the wire, not a renamed comparison

`showStartAction` was `intake === true && column !== "triage"`. That
hardcoded id was standing in for *"an intake column that does not
auto-triage"* — a distinction that lives in trait **config** (`intake`
with `autoTriage: false`) and was invisible to every client.

It also **inverts** under U11: with `triage` deleted, `column !==
"triage"` is vacuously true, so a Start button would appear on **every
planning card**. `describeColumns` now derives `manualIntake`
server-side and the gate reads it. Renaming the comparison would have
shipped the inversion.

### One conversion was wrong, and the tests caught it

I first converted the move-progress prompt to the card's *own* column
role. The original tests the move **destination** — moving a card *back*
into a pre-implementation lane is what risks discarding step progress.
`confirms preserving progress before moving` failed immediately on an
`in-progress → todo` move. It now resolves the target column's flags.

That is the argument for red-green per site rather than pattern-matching
the comparison: the regex looks identical at both sites and means
different things.

### Revert-proof

New `TaskCard.u11-merged-column.test.tsx` renders cards in the
**post-U11 shape** — id `todo`, traits `intake + hold`, no `triage`
anywhere — and asserts Delete, the planner badge and the step list still
appear; that Start does **not** (auto-triaging); and that a
manual-intake lane does get it. Revert any converted site and the
matching case fails, because these cards are not in `triage` and never
will be again.

### Fixture updates, and why they are not weakening

- Start-affordance cases now pass `manualIntake`, which the server
supplies for a manual intake lane.
- "omits the Start button for the triage column even when intake is
flagged" → "for an **AUTO-triaging** intake column". The rule was never
about the id; the title said it was.

### Verification

`pnpm test:gate` (414 + 10 + 71), `pnpm lint`, both dashboard typechecks
green. **No new failures**: `TaskCard.test.tsx` reports the same 2
pre-existing failures with and without the change, verified by diffing
failing test *names* against a stashed clean tree rather than comparing
counts.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 09:54:16 -07:00
gsxdsm
3681a9f9a5 test(U9): re-green merge-error-recovery.test.ts (10 stale tests deleted, replacement contract covered) (#2559)
**U9, PR8.** Test-only, two commits (deletion and new coverage
deliberately separate).

`merge-error-recovery.test.ts` has been **red on main: 10 failed / 23
passed**. Now **24 passed**.

## Commit 1 — the 10 failures test a feature that no longer exists

All 10 assert that `ProjectEngine` creates recovery follow-up **tasks**
and dedupes them by parent/branch. Evidence this was deliberate, not a
regression:

- `project-engine.ts` contains **zero** `createTask` calls.
- The string the dedupe tests assert on — `"follow-up already exists"` —
exists **only in the test file**; no production code emits it.
- `project-engine.ts:4801` documents it outright
(`FNXC:AutostashRecovery 2026-07-26`): *"This used to file an automated
recovery follow-up card via the shared follow-up engine; that engine was
deleted ... So the card is replaced by a durable log entry AND an
operator comment on the parent."*

Deleted rather than repaired — there is nothing left for them to assert.

## Commit 2 — cover the contract that replaced them

The production comment is explicit that `record.label` *"must never be
dropped from the message or truncated"* — it is the handle `git stash`
recovery needs, and the parent may already be `done`, so the notice is
the only trace of real uncommitted work.

**That invariant had no working assertion.** The file was red, so every
claim it made was inert.

The new test asserts one log entry + one comment for a `live` orphan (a
`subsumed` record stays silent), and that label, short sha, detecting
task and source phase all survive into the comment, with the label in
both the log message and its detail field.

| Mutation | Result |
|---|---|
| replace the label with `(omitted)` | 1 failed / 23 passed — this test
|
| notify on non-live orphans too | `NEW-failures=1` — this test |

## A tooling bug this uncovered, which matters beyond this PR

The new test originally reported **zero** new failures under mutation
while passing normally — i.e. it looked vacuous.

It was not. A thrown assertion left the engine running, which **crashed
the vitest worker**, and a crashed run emits no parseable `FAIL` lines —
so my mutation harness parsed zero failures and printed **NOT COVERED
for a guard that had just correctly failed**.

Two fixes:
- The test stops the engine in a `finally`, so a failure reports as an
assertion instead of killing the worker.
- The harness now treats *non-zero exit with zero parsed failures* as
**INCONCLUSIVE**, never as a coverage verdict, and prints the crash
signature.

This is the **second** time a blind spot in my own tooling manufactured
a false "uncovered" result — after the `|project|` regex that matched
nothing for `@fusion/core`. Both had the same shape: the measuring
instrument reported success without checking anything, which is
precisely the defect class this program is chasing. Worth stating
plainly rather than quietly fixing.

## Why this file matters to U9

Its 10 pre-existing failures are what corrupted my own safeguard
measurements in #2511 — an absolute-count mutation run credited them to
the mutation. **A red file in the merge lane does not merely lack
coverage; it poisons the measurement of everything near it.**

## Wider context, measured

`engine-default` on clean `main` is **283 failed / 9062 passed across 28
files**. This PR clears one of those files. I did not attempt the rest:
most are outside the merge/review lane and plausibly owned by other
workers on this program. Also measured and abandoned: extending
`check:mock-completeness` to relative intra-package mocks — the naive
rule flags **147** factories of which **146 are green**, so it would be
almost pure false positives; the barrel heuristic does not transfer.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 09:54:04 -07:00
gsxdsm
67904f8a2c U11: merge Todo into Planning on the default lineage (+ the migration mechanism, and a measured safety audit that cuts the work list 32%) (#2515)
**Merges Todo into Planning on the operator's real default workflow.**
Held from merge pending the `triage` literal audit below — see *Gating*.

## The board change

`builtin:coding` → `BUILTIN_STEPWISE_FINAL_REVIEW_CODING_WORKFLOW_IR` →
clones `BUILTIN_STEPWISE_CODING_WORKFLOW_IR`. That IR now declares
**five** columns, and `plan`, `plan-review`, `plan-replan` and `start`
all live in the merged Planning column:

```
columns: todo="Planning", in-progress, in-review, done, archived
  start -> todo      plan       -> todo
  plan-review -> todo  plan-replan -> todo
  parse -> in-progress            (first implementation node)
```

The id stays `todo`, the display name becomes "Planning". That is the
cheaper half: `todo` was already the hold column, so every trait lookup,
task row, stored selection and the 121 `column === "todo"` guards keep
their meaning, and **no stored row needs re-homing**. Promoting `triage`
instead would have produced the same board while making those guards
workflow-*dependent* — live for Coding (Ideas), silently dead for
Coding.

`builtin:legacy-coding` keeps its six-column shape, per the operator's
decision. It exists to be the old thing.

## Entry contract, before and after each IR edit

| | result |
|---|---|
| before the default-lineage edit | **15 passed** |
| after the edit | **13 passed, 2 failed** |
| after reading both | **15 passed** |

Neither failure was routed around. One was a genuine expectation change
(two planning entry points became one); the other was my own
`mergeTodoIntoPlanning` helper throwing *"source IR is not the
split-column shape this merge transforms"* — because production **is**
the merged shape now. I **deleted** the helper rather than making it
tolerant: a transform that has silently become a no-op asserts nothing.

## The safety argument, proven not asserted

Entering at `start` is exactly what dragged cards backward in the three
earlier reverted attempts. `merged-planning-start-node-no-move.test.ts`
proves against the **real** boundary controller and **real** default IR
that entering `start` performs no move (`moveTask` is never *called*),
reaches no hold→wip capacity seam, and **still moves on a genuine
crossing** so the no-op is same-column rather than a disabled boundary.
Removing the controller's same-column short-circuit turns exactly the
two no-move tests red.

## The migration mechanism

A card can outlive its column. `resolveAllowedColumns` derives targets
from graph adjacency, and an undeclared source has none — so it returned
`[]` and **every** move was rejected with "Valid targets: none",
including the one that would rescue the card. An undeclared source now
resolves to the workflow's rebound target. Escape hatch, not relaxation:
declared columns are untouched, and it offers the rebound target *only*,
so a stranded card gets back **into** the lifecycle rather than a free
jump past review.

## A real regression this surfaced

`isDefaultWorkflowColumns` matched the legacy **six** ids as a set. The
merged default declares five, so the match stopped firing and the
default board fell through to neighbor-only adjacency, which **drops
legal moves and invents an illegal one**:

| edge | effect |
|---|---|
| `in-progress → done` | **dropped** — the mission-validation cross edge
|
| `in-review → todo` | **dropped** — review work back to planning |
| `todo/done → archived` | **dropped** — the FN-4892 direct-archival
edges |
| `done → in-review` | **invented** — a backward edge no rule allows |

Adjacency now derives from lifecycle **roles**. The load-bearing
assertion: the legacy six still reproduce `VALID_TRANSITIONS`
**verbatim**. Applied only when a workflow declares the full role set,
so custom boards keep neighbor adjacency.

## Failure accounting (core package, vs a 49-failure baseline)

| stage | failed | new |
|---|---:|---:|
| after the merge | 65 | 18 |
| after the escape hatch | 52 | 5 |
| after role-derived adjacency | 53 | 4 |

The 4 remaining are 3 `builtin-workflows` expectations encoding the
pre-merge shape and 1 create-intake expectation naming `triage` on
`builtin:coding`.

Two `schema-applier` and two `workflow-reconciliation-production-shape`
failures appeared in intermediate runs and are **not mine** — both files
pass in isolation (75/75 and 7/7). I re-ran each before attributing
them, which is why the earlier "priority" flag on the reconciliation
pair was withdrawn.

Gate: **309/309**. Lint clean.

## Gating: the `triage` audit
(`docs/solutions/architecture-patterns/u11-triage-literal-safety-audit.md`)

Program tracking cited **58** `triage` comparisons. Measured with the
same pattern:

| | count |
|---|---:|
| raw comparisons | 87 |
| inside comments | 1 |
| **not a lifecycle column at all** | **15** |
| column comparisons | 71 |
| OR-paired with `"todo"` in the same expression | 32 |
| **exclusive `triage` — the real work list** | **39** |

**15 do not compare a column.** `role === "triage"`, `surface ===
"triage"`, `sessionPurpose === "triage"`, `entry.agent === "triage"`
name the planning **agent**. Converting them would be actively wrong,
and the failure — a planning agent that can't resolve its prompt
template — would look nothing like a column bug.

**One site changes an operator-visible affordance**, which is why
per-site review beat a sweep:

`TaskCard.tsx:1927` — `taskColumnFlags?.intake === true && task.column
!== "triage"`. The literal is a **narrowing**, not a match. After the
merge a Planning card has `intake === true` and `column === "todo"`, so
the narrowing stops applying and **Start begins rendering on default
Planning cards where it previously did not.** A sweep would have
"converted" the literal and shipped the new affordance silently.

These guards do not go **dead**, they go **workflow-dependent** —
`triage` stays live for legacy-coding, Ideas, every linear built-in and
any user workflow (R11) — which is harder to detect than dead.

Work list and ownership are in the audit doc.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 09:39:20 -07:00
gsxdsm
82baaa0b67 test(U9): give the FN-7720 "no fabricated verdict" invariant a real assertion (#2541)
**U9, PR6.** Test-only, one file, no production change. Found while
characterizing the reviewer lane (U9 is "review *and* merge"; PRs 1–5
covered merge).

## A test named for an invariant it does not assert

`store-bypass-review.test.ts` has a case called *"rewrites the failed
step to skipped with bypass audit metadata **and no fabricated
verdict**"*, containing `expect(result?.verdict).toBeUndefined()`.

Its fixture sets `verdict: undefined`. **The assertion is vacuous.**
Deleting `delete bypassed.verdict;` from `store.ts` leaves the whole
suite green.

Measured: `NEW-failures=0` across `store-bypass-review`,
`task-merge-bypass`, `task-merge`, `legacy-adoption`.

I explicitly confirmed the suite **runs rather than skips** — 9 tests
via `pgDescribe` against the shared PG harness. A skipped suite produces
exactly the same misleading zero, and that is the failure mode I hit
earlier in this unit with a regex that matched nothing.

## Why it matters

FN-7720 is explicit that a bypass writes status `skipped` and **never
fabricates a reviewer verdict**. The invariant only has teeth when the
failed step *carries* a verdict — which is the actual risk case: a
reviewer says `REVISE`, an operator bypasses, and the verdict rides
forward onto a `skipped` step. Every downstream reader then sees a
reviewer verdict attached to a step no reviewer passed.

The production code is **correct**. It was simply unasserted.

## The added case is two-sided

With `verdict: "REVISE"` seeded, it asserts:
- the bypassed step has **no** verdict (not carried forward), and
- `bypassedFromVerdict` preserves `"REVISE"` (not silently lost from the
audit trail)

so it fails if the clear is removed *and* if the audit field is dropped.
A one-sided version would pass against a bypass that simply discards all
verdict history.

| Mutation | NEW failures |
|---|---|
| remove `delete bypassed.verdict` | **1** — this test, and only it |
| drop `bypassedFromVerdict` | **1** — this test, and only it |

## Reviewer-lane characterization so far

By-name coverage search done **first** this time, per the lesson from
#2520:

| Invariant | Verdict |
|---|---|
| FN-8492 orphaned pending results REWRITTEN to failed, never deleted |
**covered** — `legacy-adoption.test.ts`, NEW=2; one case is literally
named "NEVER deletes an orphaned entry" |
| FN-7720 bypass writes status `skipped` | **covered** — NEW=1 |
| FN-7720 bypass never fabricates a verdict | **was vacuous** — fixed
here |

Still to characterize, and stated rather than implied: review verdicts
routing as graph outcomes, and provider-outage hold-in-place (no
fabricated verdict on outage). Those are the next PR.

## Note on this shared checkout

Earlier in this unit I used `git stash` to isolate a measurement and,
because my tree was already committed-clean, the `pop` targeted the
operator's stash entry. It failed safely on an untracked-file conflict
and both entries are intact — but that was luck. I no longer use stash
here; isolation is done by editing and restoring files directly, with
`git status` asserted clean afterwards.

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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 09:35:54 -07:00
gsxdsm
69790dc3e7 test(U9): revive two permanently-red testMode guards in reviewer.test.ts (#2547)
**U9, PR7.** One test file, +15 lines, no production change.

## Two safety tests that could never pass

`reviewer.test.ts`'s `vi.mock("../pi.js")` is missing
`wrapToolsWithOutputBudget`, which `wrapCustomToolsForPluginRuntime`
(`agent-session-helpers.ts:104`) calls as the outermost tool wrapper.
Both test-mode-forcing cases therefore threw:

```
No "wrapToolsWithOutputBudget" export is defined on the "../pi.js" mock
```

They have been **permanently red on main** — dead enforcement on the
invariant that **testMode never issues real AI calls**.
`reviewer.test.ts`: 83 passed | 2 failed → **85 passed**.

Found while characterizing the reviewer lane for U9: they surfaced as
pre-existing baseline failures under an unrelated mutation run. This is
exactly why the delta harness records a baseline — under the old
absolute-count method these two would have been silently credited to
whatever mutation was running.

**Not a product bug.** testMode forcing works correctly; its guard did
not.

## Verified the revived tests actually guard something

A dead test can also be a vacuous one, so passing again is not
sufficient evidence. Mutating `isTestModeActive` in
`model-resolution.ts` to ignore `settings.testMode` fails **exactly
these two** (`NEW-failures=2`). Both assert
`expect(mockedCreateFnAgent).not.toHaveBeenCalled()` — no live agent
spawn.

## Why the existing gate didn't catch it

`pnpm check:mock-completeness` runs in the merge gate and passes. It
inspects only the `@fusion/engine` and `@fusion/dashboard` **barrels**,
under `cli/` and `dashboard/` test dirs — never a relative intra-package
mock like `"../pi.js"`. So the whole class of engine-internal mock drift
is outside it.

**Deliberately not fixed here.** Extending the checker is its own change
and I want the violation count measured before proposing it, rather than
opening a PR that turns out to touch dozens of files. That's the next
PR.

## Also observed, stated rather than buried

Mutating `useMockRuntime` in `agent-session-helpers.ts` produces **no**
failure in this file — the reviewer path routes through model resolution
instead. That downstream seam has its own coverage question which I have
not answered; flagging it rather than implying this PR closes it.

## Scope note

This was initially committed onto #2541's branch. I split it onto its
own branch so each PR stays independently revertable — #2541 is now one
commit (the FN-7720 verdict assertion) and this is one commit.

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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 09:32:34 -07:00
gsxdsm
c2705f292f U11: delete the dead isRunnableQueuedOverlapCandidate export (scheduler.ts now has zero live todo literals) (#2542)
Based on `main`. **Pure deletion — zero production callers.**

`isRunnableQueuedOverlapCandidate`'s only consumer was the legacy
pull-from-todo dispatcher deleted in #2505. The three remaining
references were all in tests.

This was `scheduler.ts`'s **last `"todo"` literal in live code**, so
removing it rather than converting it is what actually finishes the file
— converting a dead predicate would have added a trait lookup nothing
calls, and reported U11 progress for a site that cannot execute.

## Why deleting its tests does not lose coverage

Worth checking, because the function carried a real invariant — *"a busy
merge lane must not block unrelated dispatch"* — and its own doc comment
claims it's a shared contract with self-healing and repair paths.

Two facts settle it:

1. **The overlap logic is still live**, implemented inline inside
`runHoldReleaseSweepPass` (`activeScopes`, `overlapIgnorePaths`,
`getFilteredFileScope`). The behavior didn't die with the predicate;
only this copy of it did.
2. **`scheduler-overlap-starvation.test.ts` exercises that live path**
through `scheduler.schedule()`, including *"does not defer ready work
behind queued overlap blocked by an active lease"* — the same invariant
the deleted test asserted, against code that actually runs.

The doc comment's claim that self-healing *"must use this same
predicate"* is **stale**: no self-healing path imports it. That claim
outlived the coupling it described.

## The three test references were not equal

Treating them identically would have been wrong:

- **Two were incidental trailing assertions** in tests about other
subjects (stuck-loop exhaustion parking; transient merge-error
classification). Only the assertion line is removed — each test keeps
its real subject.
- **One test's entire subject was this function** (*"does not block
unrelated executor dispatch when merge lane is busy"*), so it goes with
it; its invariant is covered on the live path per (2).

## Measured

`scheduler.ts` **2,840 → 2,820 = −20**, and it now holds **zero `"todo"`
literals in live code**.

Combined with #2505's −929, `scheduler.ts` is down **949 lines** across
this unit — all genuine removal, not relocation.

## Verification

582 tests green across the reliability-interactions suite and four
scheduler suites; merge gate green (414 + 10 + 71); tsc clean; lint
clean.

No changeset: `@fusion/engine` is private.

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


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

## Summary by CodeRabbit

* **Refactor**
* Simplified internal task scheduling logic by removing obsolete overlap
coordination checks.
* Preserved existing task progress, parking behavior, logging, and
review handling.

* **Tests**
* Updated reliability checks to align with the streamlined scheduler
behavior.
* Continued validating transient errors, non-progress handling, and
correct task dispatch without changing the end-user experience.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-29 09:25:16 -07:00