21ef60047efcb5993ee255ca3ec5ae0feef836b1
11277 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
21ef60047e |
fix(engine): a second complete lane is terminal too — restore the scheduler's dependency reconciliation (main is red) (#3065)
## main is red, and this is the fix ``` FAIL src/__tests__/scheduler-renamed-hold-events.test.ts > dependency unblocking (failure mode is a card that waits forever) > finds dependents resting in the renamed hold column when a blocker completes AssertionError: expected [] to include 'drafting' ``` Not in the thin merge gate's `engine-core` allow-list, so CI stayed green and only the non-blocking full suite sees it. The test file is unchanged since #2518; #3051 converted the guard underneath it. ## What broke #3051 turned the guard into `to === parked.complete || to === parked.archived`. `resolveLifecycleColumns` answers **first match per role** — the right shape for a move *target*, the wrong shape for *"did this card just reach a finished lane"*, which is a membership question. Two consequences, both silent: 1. **A board with more than one complete-trait column reconciles nothing** when a blocker finishes in the second one. The test file's own header flags this path specifically: *"This one is NOT latency: a dependent never gets unblocked, so it waits on a blocker that is already done."* 2. The legacy `done`/`archived` ids stopped matching at all — the failing assertion. ## Fix `resolveTaskParkedColumnsSync` gains `terminal`, a membership set: legacy `done`/`archived` seeded, then **every** complete- and archived-trait column from the task's own IR. Seeding legacy ids is safe in the direction that matters here. This is an **inclusion**: a superset makes the reconciliation run on a move it would otherwise ignore — one extra query, and it cannot wrongly withhold work. Seeding a **refusal** is the bug (`node-override-guard.ts` documents that one); this is not that. Same sync IR path and same fail-soft legacy default as the single-column answers, so event ordering and unresolvable-workflow behaviour are unchanged — the constraint the sync resolver's own header sets. The two sibling guards in the same listener (dispatch-oscillation reset at what is now line 1097, and the scheduling wake at 1114) had the identical arity defect and convert with it. ## Measured | | result | |---|---| | before | `scheduler-renamed-hold-events`: **1 failed / 9 passed** | | after | **11 passed** (one new case) | | `src/__tests__/scheduler*` | **14 files / 143 tests pass** | | `tsc --noEmit -p packages/engine` | clean | | census `--strict` / `check-lane-wiring` / `check-fnxc-future-dates` | clean, no baseline movement | **Proved it is main's red, not my branch's:** I checked out `origin/main:packages/engine/src/self-healing.ts` over my unrelated fleet branch and re-ran — identical failure. Then branched this fix straight off `origin/main`. **Mutation-tested.** Restoring `to === parked.complete || to === parked.archived` fails **both** the pre-existing case and the new second-complete-lane case. The new test is not vacuous: the second complete lane is invisible to first-match resolution, so it cannot pass against the old guard. ## Not done here I did not convert the remaining `to === parked.review` / `from === parked.wip` single-column comparisons in this listener. Review is genuinely two roles (`mergeBlocker` + `humanReview`) and wip has its own limit-setting semantics — both want the same membership-vs-target judgement applied deliberately rather than swept in behind a red-fix. Flagged, not guessed. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
998d75da3b |
refactor(core): resolve the hand-off archive guard by role (fleet) (#3054)
## Census | | column guards | |---|---| | before (this branch) | **122** | | after | **118** | Baseline re-recorded in the same commit, as the ratchet requires. (Four of the delta land with #3052; this PR carries `moves.ts`.) ## What changed `handoffToReviewImpl` refuses a hand-off from an archived card. Against the literal `archived`, a board whose archive lane is renamed **never matched** — so an archived card could be handed to review, and the invariant `HandoffInvariantViolationError` exists to protect was silently unenforced. The IR is resolved at the guard rather than 28 lines below where `handoffTarget` already reads it; the later read now **reuses** it instead of resolving twice. The hoist is safe because this function has already awaited `readTaskRowAsync` above — no new tick boundary. That's the specific hazard blocking the scheduler cluster, so I checked it here rather than assuming. Absent or trait-free IR keeps the legacy id: unconverted boards are byte-identical. ## Fleet intelligence: the backlog is now essentially fully triaged I worked down the census top-files list and verified each before writing. **Every remaining cluster is claimed, fallback-by-design, or documented-blocked:** | cluster | guards | status | |---|---|---| | `self-healing.ts` | 51 | **claimed** — checked out in another worktree (`convert/self-healing-lane-cluster-u7`) | | `scheduler.ts` | 12 | **blocked**, documented at line 907 — `task:moved` prologue is synchronous; hoisting reorders this listener against every other subscriber | | `notification-service.ts` | 5 | **blocked**, documented — needs the wedge-episode contract serialised first; the second site needs gate-placement judgement in `handleTaskUpdated` | | `executor.ts` | 4 | **claimed** (`fleet/executor-lifecycle-roles`) | | `restart-recovery-coordinator.ts` | 4 | **trait-fallback arms** — the census counts these as already converted | | `taskRevert.ts` | 2 | **blocked**, documented — would classify a *neighbour* task with the modal's own flags (the wrong-row shape, worse than the literal) | | `project-store-ops.ts` | 2 | **blocked**, documented — the dead SQLite twin; its first statement throws under PostgreSQL | | `github-tracking-state.ts`, `planner-overseer.ts`, `async-mission-store-queries.ts`, `register-task-workflow-routes.ts` | 2 each | **trait-fallback arms** | | `auto-merge-finalization.ts` | 2 | one is the `catch`-block degraded fallback; the other is a reason string | So the mechanical conversions are done. What's left needs either a design change (scheduler's event payload, notification's episode contract) or per-row lane data that doesn't exist at the call site yet (`taskRevert`). **That's the useful signal for the fleet**: further census reduction isn't a matter of more conversion passes. Forcing these would produce exactly the "conversions that break the code and improve the number" the learnings doc is named for. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
af470f7c05 |
convert(engine): self-healing lane cluster 56 -> 38 guards (repo 126 -> 108) (#3049)
## Census before / after
```
before after
self-healing.ts column guards 56 38
repo-wide COLUMN guards (backlog) 126 108
```
`self-healing.ts` was the largest single cluster by a wide margin — 56
guards against 12 in the next file. Baseline re-recorded in the same
commit; `--strict` green.
## Converted: 15 guards across 11 sweeps
Existing helpers only — `resolveProjectColumnsForRoles` with
`TERMINAL_ROLES` / `REVIEW_ROLES` / `countsTowardWip` / `hold` /
`archived`, the same shape this file already uses. No new helper, no new
resolution pattern.
What each was silently doing on a renamed board:
| sweep | behaviour before |
| --- | --- |
| `archiveStaleDoneTasks` | **both** guards inert, so every card counted
as an active dependent and the sweep archived **nothing at all** |
| `reconcileDependencyBlockingLeases` | no holder matched, so a stale
file-scope lease blocking an unmet dependency was never cleared |
| `reconcileCompletedBlockedTasks` | work whose blocker had cleared
stayed parked instead of advancing |
| `reconcileInReviewUnmetDependencies` | a card sat in review with unmet
dependencies and no rebound |
| `reclaimStaleActiveBranches` | archived cards were eligible for branch
reclaim |
| `reconcileInReviewBranchRebind` | the rebind list was empty |
| `autoReboundPausedScopeDecayDetailed` | no card was ever seen as
executing |
| `detectStalledCards` | finished cards counted as stall candidates |
| `recoverApprovedStrandedAiMergeCommit`,
`recoverDriftedAgentTaskLinks`, `cleanupStaleTempMergeWorktrees` | same
shape |
**Reused rather than duplicated:** `recoverWedgedActiveMerge` already
resolves `wedgedReviewColumns` via `resolveReviewColumnsFor` three lines
above the site I was converting, so the site now uses it instead of a
second resolution of the same question.
## One site I converted and then reverted
`clearStaleBlockedBy`'s memo closure carries an FNXC note stating the
literal is **deliberate**: the closure only decides whether to re-log an
already-logged blocker, so a renamed board costs a duplicate log line —
not a wrong lifecycle decision — and restructuring a sweep's control
flow to convert a logging decision is the wrong trade.
I read that note *after* editing the line. Restored.
Worth flagging separately: **it has the reasoning but no
`DELIBERATE-LITERAL` marker**, so the census keeps counting it and it
re-appears in the backlog as if unexamined. That is a marker gap, not a
conversion gap — the next person will make the same mistake I did.
## Not converted — flagged, not guessed
Ten of the twenty-four remaining sites are in **sync predicates with no
resolution seam**:
- `classifyPausedAbortWorkflowRecovery` (3)
- the `start()` task-moved listener (5) — compares event `from`/`to`
columns inside a sync callback
- `isWorkspaceOwnerLive` (1)
- `isPhantomExecutorBinding` (1)
Converting these means threading a flags parameter down from every
caller — precisely the unwired-optional-parameter shape this program
keeps finding inert (five were live on `main` at once per
`unwired-lane-parameter-guard`). They need a decision about *where the
resolution lives*, not a guess from me.
The other **14** are in async sweeps with a seam available and are
ordinary follow-on work in this same file.
## Verification (measured)
- self-healing suites — **816 passed / 41 files**
- `tsc --noEmit`, `eslint` — clean
- `pnpm test:gate` — green
- `lifecycle-column-census --strict`, `check-lane-wiring`,
`check-sql-column-literals`, `check-inert-flag-seams`,
`check-fnxc-future-dates` — green
No changeset: `@fusion/engine` is private.
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **Bug Fixes**
* Self-healing workflows now continue functioning when workflow columns
are renamed.
* Improved recovery for stalled, blocked, paused, or disconnected
workflow states while preserving existing filters and actions.
* Temporary merge worktrees and drifted agent links are cleaned up more
reliably.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
|
||
|
|
0fd3e38628 |
test(engine): PR #3051's scheduler conversion is inert — live-PG refutation (#3058)
## Escalation — a conversion on main changed nothing
**#3051 ("scheduler.ts 12 → 2 lifecycle-column guards") is inert.** It
widened `resolveTaskParkedColumnsSync` from `{hold,intake}` to the full
role set and replaced ten handler literals with `parked.review` /
`parked.wip` / `parked.complete` / `parked.archived`. The census fell by
ten. The behaviour did not change, on any board.
Everything rests on one line in that helper:
```ts
const l = resolveLifecycleColumns(store.resolveTaskWorkflowIrSync(taskId));
```
`resolveTaskWorkflowIrSync` resolves through the sync workflow
**selection** reader, which answers `undefined` for every task under
PostgreSQL — the shipped backend. The resolver takes its `!workflowId`
branch and returns the **default builtin IR**.
Note the shape precisely, because the obvious reading is wrong and this
PR corrected itself on it mid-run: the helper does **not** get
`undefined` and fall through to `?? legacy.review`. It gets a **real IR
that resolves real traits** — the default board's. So `parked.review` is
`"in-review"` for every card on every board, the `?? legacy` arms are
dead code, and the helper answers with full confidence. It looks
resolved at every level except the one that decides the answer.
## Evidence (live PostgreSQL, 3/3 passing)
For a card bound to a **stored** renamed workflow and sitting in that
board's review column (`checking`, carrying `human-review` +
`merge-blocker` + `merge`):
| | sync path (what every converted arm uses) | async resolver | board
actually declares |
|---|---|---|---|
| review | `in-review` | `checking` | `checking` |
| wip | `in-progress` | — | `building` |
| complete | `done` | — | `shipped` |
The async arm is in the test on purpose: it attributes the failure to
the **sync path** and nothing else.
The **control** is the point of the whole thing — on the default board
the sync answer is *correct*, by coincidence rather than resolution.
That is why every default-board scheduler test passes either way, and
how ten inert conversions read as a fix.
## Why this is worse than leaving the literals
A conversion that changes nothing is worse than an unconverted literal,
because **the literal was counted and this is not.** Ten guards left the
backlog, the file now reads as converted, and the next reader has no
reason to look again.
Two further signals that this was not a deliberate trade-off:
1. The FNXC block still standing directly above the handler (unmodified
by #3051) **contradicts the code beneath it** — it says these ten arms
cannot be converted this way and names `resolveTaskParkedColumnsSync` as
the hazard-avoidance device, not the fix.
2. #3051's own added note asserts the fix as fact: *"so on a renamed
board PR monitoring never started or stopped, failure bookkeeping never
recorded, and terminal cleanup never ran."* Those failures are real.
This conversion does not fix them.
## Scope — driven vs argued, stated in the file
- **Driven:** the roles the sync path yields for a real card on a real
stored renamed board, against a real PostgreSQL store, versus the async
resolver on the same card.
- **Not driven, and the file says so rather than substituting a spy:**
the `parked.review` arm's own side effect. Its only outputs are four
dispatch-oscillation fields that do not round-trip through `updateTask`
on this store (measured: writing `dispatchStormCount: 3` reads back
`undefined`), so there is no persisted observable. The behavioural half
is carried by the sibling
`workflow-scheduler-parked-columns-live-e2e.pg.test.ts`, which drives
the **same helper** on the hold role through to persisted state.
## The real unblock
Unchanged from the note already in the file: carry the resolved lanes
**on the `task:moved` payload**, so no listener resolves at all. That
removes the class rather than one instance, and it is the only option
that survives the synchronous-prologue constraint — these listeners run
in the same tick as a synchronous emitter, which is why an `await`
cannot simply be added.
## Verification
`test:gate` exit 0 · live-PG E2E surface **174/174** · census exit 0 ·
`pnpm lint` clean. Test-only; no production file touched.
## Recommendation
Do not revert #3051 — the widened helper is harmless and the note it
added is useful once the resolution is real. **Restore the ten guards to
the census**, or land the payload change. Either way the count must not
read as paid.
Related: **#3055, #3050 and #3049 are all converting `self-healing.ts`
concurrently** — three PRs, one file, 51 guards. Worth de-conflicting
before any of them merges.
|
||
|
|
740fea38c2 |
fleet: restart-recovery-coordinator.ts 4 → 1 (dead fallbacks deleted, not converted) (#3059)
Claiming `packages/engine/src/restart-recovery-coordinator.ts`. ## Census before/after | File | Before | After | |---|---:|---:| | `packages/engine/src/restart-recovery-coordinator.ts` | 4 | **1** | ## These were deletions, not conversions All three sites were fail-soft fallbacks behind an **optional** `reviewColumns` parameter: ```ts return (reviewColumns ? reviewColumns.has(task.column) : task.column === "in-review") ``` Production never took that branch — `self-healing.ts:13646-13649` supplies the resolved set at every call site. So the correct change is to make the parameter required and delete the literal, not to swap it for a role lookup. ## The trap this hit, which would have shipped a crash **Making the parameter required produced ZERO tsc errors.** That looked like proof the fallback was unreachable. It is not: the engine `tsconfig` covers `src` and not `__tests__`, so the type-checker cannot see the callers that actually relied on the default. Running the tests surfaced them immediately as `TypeError: Cannot read properties of undefined (reading 'has')`. This is the same class as finding 2 in `docs/solutions/best-practices/proving-a-code-path-actually-runs.md` — a negative result from a checker that cannot see the thing it is being asked about. Anyone converting a `src`-only-typechecked package should assume tsc is blind to test call sites. The blast radius was also one site larger than grep suggested: the `isRecoverableMissingWorktreeReviewFailure` **combiner** threads the set to all three inner predicates. Its own comment already names why — *"a caller cannot convert the outer question and leave one of the three inner ones on the legacy id — the half-conversion shape this program keeps finding."* Tests now pass the set production always passes, preserving exactly what each case asserted. ## Remaining 1, flagged not guessed `L149` uses a different shape (`isReviewColumn ?? task.column === "in-review"`) whose callers I did not establish. Absence from grep is not proof of no caller, so it stays counted. ## Verification - census: 4 → 1 - `restart-recovery-coordinator` + `self-healing` — **424 tests green** - `tsc --noEmit` clean; `pnpm lint` clean 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
06717ac3fa |
refactor(engine): resolve replan-target's advancement test by role (fleet, 4 sites) (#3052)
## Census
| | column guards |
|---|---|
| before | **126** |
| after | **122** |
`replan-target.ts`: **4 → 0**, and it drops out of the top-files list.
Baseline re-recorded in the same PR, as the ratchet requires.
## What changed
`hasAdvancedPastPlanning` asked "has this card moved past planning" as
four literal comparisons — `in-progress`, `in-review`, `done`,
`archived`. It now asks the same question in roles, from lanes the
**caller** resolves.
## Caller-resolved is the whole point
The module's sync twin `resolvePlannerLanes` reads
`store.resolveTaskWorkflowIrSync`, which returns the **default workflow
IR for every task under PostgreSQL**. Converting through it would have
improved the census while answering about a board the card isn't on —
the second failure shape in the learnings doc, already proven at this
exact seam by
`workflow-planner-lanes-sync-vs-async-live-e2e.pg.test.ts`.
The only production caller is `async`, so it uses
`resolvePlannerLanesForTaskAsync`.
**The caller's own inert resolution is fixed too**, not just the four
arms: `releasedToTodo` compared against `resolvePlannerLanes(...).hold`
— the sync twin — so it read `todo` on every board regardless of
vocabulary. One async resolution now supplies the planner column, the
merged-planning column and the forward lanes.
## Flagged, not guessed
The archive lane is a **separate argument** rather than a fifth
`PlannerLanes` role. Adding the field surfaced a genuine divergence
between the sync and async twins — `_workflow-vocabulary-fixture` models
no archive lane, so they disagree there — and that fixture backs **37
test files**. That divergence deserves its own change with its own
evidence; forcing it through a conversion PR would have meant editing a
37-file fixture to make my own change pass.
## Two larger clusters I did NOT claim, with reasons
I went by census size first and verified before writing:
- **`self-healing.ts` (56 guards, 44% of the backlog)** — already
claimed. Three branches hold it, one checked out in another worktree
(`convert/self-healing-lane-cluster-u7`). I'd drafted four sibling role
helpers before checking; reverted rather than collide.
- **`scheduler.ts` (12 guards)** — blocked by design and already
documented at line 907 by a prior fleet worker. The `task:moved` handler
is `async` but its **prologue is not**: no `await` between entry and the
terminal-blocker branch ~55 lines down, so hoisting a resolution turns
the prologue into a microtask and reorders this listener against every
other synchronous subscriber ("verified, not assumed"). Lazy resolution
doesn't help — the *condition* needs the lanes. Unblocking needs the
emitter to carry resolved lanes on the payload, which is a design change
rather than a conversion.
`restart-recovery-coordinator.ts`'s 4 sites are the trait-fallback arms
the census already counts as converted — converting those would delete
the legacy fallback, not add resolution.
## Measured
| check | result |
|---|---|
| replan + planner-lane suites | 11 files, **102 tests green** |
| triage suites | **374 tests green** |
| five gates + strict census | green; `tsc` clean |
| unconverted callers | byte-identical — absent lanes fall back to
`LEGACY_PLANNER_LANES`, absent `archivedColumn` keeps the legacy id |
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
107a1e790a |
fix(core): the review lane was resolved for two stall signals and literal for the other two (#3053)
## Claim Largest **unclaimed** census cluster. `self-healing.ts` (56) is the capacity worker's file and `scheduler.ts` (12) is blocked (below), so I took **@fusion/core** — 16 bare guards across 12 files, untouched by any open PR. ## Census before / after ``` before: COLUMN guards (the backlog): 126 after: COLUMN guards (the backlog): 126 ``` **Unchanged, and that is the honest result — not a failed conversion.** The repo's sanctioned device is an optional *resolved* parameter whose default stays the legacy literal (the exemplar is `restart-recovery-coordinator.ts`, watched by the unwired-lane-parameter guard). The literal survives as the default arm, so the counter cannot see the conversion. **This matters for the fleet phase.** The census is not a progress meter for this pattern. A worker driving the number down has only two ways to move it, and both are wrong: 1. **Delete the fallback** (make the parameter required) — prior review explicitly argued against this; `cli-active-count-lanes.test.ts` deliberately covers the no-argument path. 2. **"Convert" with `resolveTaskWorkflowIrSync`** — that reader returns `undefined` unconditionally under PostgreSQL, the shipped backend. It drops the count while behaving *exactly* like the literal. That is the inert-conversion class, and `merge-queue-ops-2.ts:53` already carries a flag note saying so. I measured the split across all 126: **18 are fallback arms of already-converted seams; 108 are bare guards.** The headline number conflates them. ## What changed `reads.ts` states the invariant in its own words — > RESOLVED BEFORE THE FIRST SIGNAL, because two adjacent signals must not disagree. — and then called two of the four stall signals with the literal: | signal | before | |---|---| | `getInReviewStallReason` | resolved (`reviewColumns`) | | `getInReviewStalledSignal` | resolved (`reviewColumns`) | | `detectStalledReview` | **literal `"in-review"`** | | `hasFreshAgentLogActivitySinceTaskUpdate` | **literal `"in-review"`** | On a renamed board `stalledReview` returned `undefined` for every card, and the fresh-activity gate answered `false` — so `executingTaskIds` stayed empty and the board showed Stalled / Merge stalled *while a merger was visibly streaming*, the precise regression that function's own FNXC note says it was restored to prevent. Both now take an optional resolved `reviewColumns`. All four hydration passes pass the set **they already had in scope one line away**; two needed only a hoist, one reused the per-row map, one was resolving the same set inline twice. ## Mutation evidence | Mutant | Result | |---|---| | baseline | 11 passed | | revert the detector guard to the literal | **2 failed** | | make the parameter a widening (`reviewColumns ? true`) | **2 failed** | The second matters: it proves the new parameter is a real gate and not a change that merely makes every card eligible. Both arms are asserted, since the literal default is load-bearing for every caller outside `reads.ts`. ## Flagged — do not guess - **`scheduler.ts` (12 guards).** All 12 sit inside *synchronous* listeners (`task:moved`'s sync prologue; `task:updated` is sync outright). The only sync resolver available, `resolveTaskParkedColumnsSync`, is already used at lines 929/1130/1157 and is **inert under PostgreSQL** — my own live-PG E2E proves it always returns the default board. "Converting" these with it would drop the census by 12 and change nothing. The existing note at 908–926 names the real unblock: carry resolved lanes on the event payload so no listener resolves at all. Left alone. - **`restart-recovery-coordinator.ts` (4)** — already the optional-parameter device with all three production callers passing resolved answers. Not backlog. - **`reads.ts:358`, `audit-ops.ts:208`, `task-id-integrity.ts:444`** — `"archived"` here is the *cold-storage tier*, not the board column. Trait resolution would be wrong. ## Verification `test:gate` exit 0 · full `@fusion/core` unit suite **4880 passed** · typecheck exit 0 · `pnpm lint` clean · lifecycle-column census exit 0 · FNXC date ratchet exit 0 · lane-wiring census exit 0. **One unrelated failure to report, not appeased:** `src/__tests__/postgres/pg-test-harness-template-concurrency.pg.test.ts` fails under the full suite and **passes in isolation on both my tree and the untouched baseline** — a pre-existing full-suite concurrency flake in the PG harness. Not mine, not in the merge gate. I did not quarantine it: it is another worker's harness, and AGENTS.md warns that quarantining a concurrency test can mask a real product race. Flagging for its owner. |
||
|
|
24f5ffaffa |
fleet: scheduler.ts 12 → 2 lifecycle-column guards (#3051)
Claiming `packages/engine/src/scheduler.ts` from the census work order.
## Census before/after
| File | Before | After |
|---|---:|---:|
| `packages/engine/src/scheduler.ts` | 12 | **2** |
Measured with `scripts/lifecycle-column-census.mjs` (kind `column`
only).
## The file already had the right shape — it just under-answered
`resolveTaskParkedColumnsSync` already resolves a task's lanes from its
own workflow, **synchronously on purpose**: these run inside
`task:moved` / `task:updated` listeners, and its own comment records why
an `await` is forbidden there — it would defer everything after it to a
microtask and reorder handlers relative to a synchronous emitter. It
also already fails soft to the legacy ids.
But it only returned `{hold, intake}`, so every *other* lane question in
the same listeners was still asked with a literal. Widening it to the
full role set converted ten sites with no new abstraction, no new
resolution per site, and no change to the event-ordering contract.
## What was silently broken on a renamed board
- **PR monitoring never started** (`to === "in-review"`) and **never
stopped** (`from === "in-review"`) — a card's PR either untracked, or
tracked forever with its buffered comments never drained.
- **Terminal cleanup never ran** (`to === "done" || "archived"`).
- **The wip → hold failure bookkeeping never recorded** (`from ===
"in-progress"`).
None of these throw. They just stop happening — which is why the census,
not a red test, is what found them.
## Remaining 2, deliberately not converted
`L1097` (`task.column === "in-progress"`) and `L1171` (`task.column !==
"in-review"`) sit outside the listener where `parked` is in scope. They
need their own resolution, and resolving per call there is a different
cost profile than one-per-event; I flagged rather than guessed, per the
fleet rule.
## Verification
- census: `scheduler.ts` 12 → 2
- `scheduler-workflow-cutover` + `scheduler` — 42 tests green
- `tsc --noEmit` on `@fusion/engine` clean; `pnpm lint` clean
Behaviour on an unresolvable workflow is unchanged: the widened helper
keeps the same fail-soft legacy defaults the narrow one had.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
c9516dbd09 |
fix(engine): resolve archiveStaleDoneTasks lane guards by role (fleet: self-healing 56→51) (#3047)
Fleet phase. Claimed **`packages/engine/src/self-healing.ts`** — the largest cluster at **56 of 126** total sites. Verified unclaimed first: no open PR touches the file and no active worktree held a branch on it. ## Census before / after | | total | self-healing.ts | |---|---|---| | before | **126** | **56** | | after | **121** | **51** | `census --strict` exits 0; baseline re-recorded in this commit so the retired allowances cannot be regrown into. ## What converted, and why each role `archiveStaleDoneTasks` asked "has this card finished?" by comparing column ids, so on a renamed board it treated every finished card as live and archived nothing — the sweep was inert on exactly the boards this program exists to support. - **active-dependents scan** and **temp-worktree age gate** → `TERMINAL_ROLES` (complete ∪ archived): both ask "is this card done with, in any sense?" - **staleness filter** → `complete` **alone**: this sweep *archives* finished cards, so an already-archived card is not a candidate. Using the terminal pair here would have made the sweep consider its own output. **Union, not per-task, deliberately.** Over-inclusion is free at these sites because the per-card check still discards, and the union needs no per-task workflow selection — the failure mode `resolveWorkflowIrForTask` has, where a card with no recorded selection silently resolves to the built-in board. Recorded in `docs/solutions/workflow-learnings/project-union-versus-per-task-lanes.md`. ## The half-converted state is the interesting part Converting only the first two guards made `archiveStaleDoneTasks` **register as a converted sweep** — the existing ratchet suite grew from **36 to 38 tests** — and it then failed for still carrying `t.column !== "done"`. That is the failure mode worth naming: a partial conversion is worse than none, because the function now *looks* converted (it calls the resolver, it reads as role-aware) while one guard still pins it to the legacy vocabulary. Finishing the function turned it green. I would not have caught it from the diff. ## Verification - `self-healing` suites — **807 pass** (41 files) - `tsc --noEmit` — **0 errors** - `census --strict`, `check:lane-wiring`, `check:fnxc-future-dates`, `check:inert-flag-seams`, `check:sql-column-literals` — all exit 0 ## Flagged, not guessed — the remaining 51 Deliberately left, each for a stated reason rather than an omission: 1. **Move-transition matrices** (~1489–1504): `from`/`to` pairs encoding a legal-transition graph (`in-progress → todo|in-review|done|archived`). These are the *shape* of the lifecycle, not a lane lookup; converting them needs a transition-role model that does not exist yet. Guessing here would encode a wrong graph. 2. **`getLiveTaskColumn` comparisons** (~1398, 5313–5342): compared against a normalizing accessor that manufactures `"archived"` for soft-deleted rows. Those are protocol values, not column ids — converting them changes what the sentinel means. 3. **Sites without store access** in scope (several module-level predicates): need the resolved set threaded in as a parameter, which is a seam change per call site, not a substitution. 4. **`todo` requeue targets** (1927, 6181, 6303, 12060–12064): these pick a destination, so they want the single `intake`/`hold` answer from `resolveLifecycleColumns`, not a set — different arity, and several are inside sweeps whose rebound semantics I would be changing rather than preserving. Each is a real conversion; none is a one-line substitution, and doing them blind is how a guard count drops while behaviour gets worse. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
eb0ee4ae98 |
fleet: executor.ts 7 → 4 lifecycle-column guards (3 converted, 4 flagged out of scope) (#3048)
Claiming `packages/engine/src/executor.ts` from the census work order.
## Census before/after
| File | Before | After |
|---|---:|---:|
| `packages/engine/src/executor.ts` | 7 | **4** |
Measured with `scripts/lifecycle-column-census.mjs` (kind `column`
only), not grep.
## Converted (3)
**L17258 — the completed-task watchdog never armed on a renamed board.**
It required the card to sit in a literal `in-progress`. This does not
error; the watchdog simply never fires, which is the silent-guard class
this program exists to remove. The branch immediately above already
resolves the same lane through `resolveWipTargetForTask`, and there is
even an FNXC note there saying `latestColumn` must come from that
resolved value — so the comparison now asks the same resolver rather
than an id.
**L14940 (×2) — the duplicate-handoff finalize never ran on a renamed
review lane.** `fromColumn`/`toColumn` are parsed out of the store's
rejection message (`Invalid transition: 'X' → 'Y'`), so they carry
whatever ids that workflow declares. Comparing them to the literal
`in-review` meant a renamed lane never matched and
`finalizeAlreadyReviewedTask` was skipped, leaving the card
mid-transition with nothing to complete it. Now resolves the task's own
review role, falling back to the legacy literal when the workflow cannot
be read — so behaviour is unchanged wherever the vocabulary is
unreadable.
## Flagged, not converted (4) — per the fleet rule that behavior changes
are out of scope
**L3557 / L3581 / L3632 / L3642** are branch conditions inside the
**synchronous** `store.on("task:moved")` listener. Resolving a task's
workflow requires an `await`, which is not available in a sync
listener's condition. Moving the test into the deferred body would widen
the branch to every non-forward move and then re-narrow it — a
**behaviour change to the planning-evacuation path**, not a vocabulary
conversion. Converting them properly means making the listener async,
which wants its own commit and its own test.
I flagged rather than guessed, which is why this is 7 → 4 and not 7 → 0.
## On test coverage, stated plainly
Both converted sites are pure resolver swaps in `async` contexts,
verified by tsc, the census delta, and the existing executor suites (48
tests green). I did **not** add new fixtures: this is the file where I
twice wrote tests that passed against the *unconverted* code —
`recoverCompletedTask`'s seven early-return guards make negative
assertions succeed trivially — and reverted both times rather than claim
coverage I did not have. A fixture that genuinely drives L17258 needs a
satisfied `workflowStepResults` so the run does not divert into graph
re-entry; that is worth doing, and it is worth doing honestly rather
than as a green-looking placeholder.
## Verification
- census: `executor.ts` 7 → 4
- `tsc --noEmit` on `@fusion/engine` clean; `pnpm lint` clean
- `executor-graph-boundary`, `executor-task-done-summary`,
`executor-triage-column-audit`, `executor-step-session` — 48 tests green
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
4878bda197 |
fix(core): the mission bootstrap duplicate was archived into a lane the board does not declare (#3046)
## Invisible to both censuses
`archiveDefinedFeatureBootstrapDuplicate` writes `tasks.column`
**directly** rather than through `moveTask`:
```ts
.set({ column: "archived", updatedAt: … })
```
- the **lifecycle census** reads comparisons — an assignment isn't one
- the **move-target census** reads `moveTask` call arguments — this
never calls it
So on a board whose archive lane is renamed, the duplicate landed in a
column that workflow doesn't declare: a card in a lane the board can't
render, from a path that runs during ordinary feature bootstrap.
## Reuses the helper this class already has
`archivedLanesFor(taskId)` was added for the guards further up the same
file. It returns the legacy id when the task has no resolvable workflow,
so an **unconverted board is byte-identical**. No new resolution
machinery — the two `<> 'archived'` guards become `notInArray(column,
[...lanes])` and the write targets the resolved lane.
A board declaring several archive lanes is arbitrated by taking the
first, the same choice `resolveLifecycleColumns` makes. Multiple archive
lanes aren't a shape the builtin lineages produce.
## Measured
| check | result |
|---|---|
| mission-store PG suite | **36 → 38**, all green |
| new pair | differential — `filed` collides with no legacy id, and the
default-lineage control still lands in `archived` |
| mutation (hardcode the target back) | fails the renamed case |
| SQL literal gate · `tsc` | green |
## How this was found
Measuring the literal-column-**write** population for #2839: 51 raw
sites, of which 20 are the four builtin workflow IRs declaring their own
columns (correct by definition) and several more are archive-*entry
record* fields rather than board columns. This is the one I verified is
a real board write on a live path.
Worth noting the measurement itself was wrong twice first — my glob was
`packages/*/src/**/*.ts`, which requires a subdirectory and silently
skipped every top-level file in `src/` (including this one), and my
script printed only the first 14 findings so the grouping was over a
truncated list. Same scope-blindness class as #3000 and #3002, this time
in a throwaway scanner.
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
8b82e77fbf |
chore(core): delete liveParentFilter — no caller, and it carried a legacy lane literal (#3042)
Found while enumerating archive-exclusion sites for #3041. ## Unambiguously dead `liveParentFilter` has exactly **one** reference in the repo: its own definition. - not exported from `index.ts` or `index.gate.ts` - no test imports it - no production code calls it It nonetheless contained `column != 'archived'`, so it was one of the 22 sites the SQL column-literal gate tracks. ## Why delete rather than convert Converting it would mean adding lane resolution to code nothing runs — risk with no behaviour. That's the same argument #3041 makes for *not* converting the other two dead sites; deleting is the version of it that also removes the literal. ## The gate it documents is not being deleted Its docblock describes the document/artifact visibility gate (VAL-CROSS-015). That gate is real and still enforced — by the inline conditions inside `listLiveTaskDocuments` and `listLiveArtifacts`, which is presumably why this helper was never wired up in the first place. Only the unused composition goes. ## Measured | check | result | |---|---| | SQL literal population | **22 → 21**; the gate ratcheted its own baseline down and asked for the commit, included here | | `taskstore-remaining.test.ts` (archive-lineage suite) | **27 tests green** | | six gates + `tsc` | green | ## Not deleted, deliberately `listLiveTaskDocuments` and `listLiveArtifacts` are referenced **only** by that test file. That's a weaker signal than zero references — someone may have written them ahead of a consumer. Their literals stay counted, which is the honest state for code whose intent I can't read from the repo. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
511f5b7e2b |
fix(core): archived tasks leaked into the live feed on a renamed board (#3041)
From my own #2839, re-measured today. Of that issue's SQL-literal sites, this is the one that decides what a **live view** shows. ## The defect `listTasksModifiedSinceImpl` backs the SSE watcher and modified-since polling — the incremental feed the dashboard applies to its task list. Its `includeArchived: false` branch excluded the literal `archived`: ```ts conditions.push(sql`${schema.project.tasks.column} != 'archived'`); ``` On a board whose archive lane is named anything else, that predicate matches **every** row and excludes nothing. Archived cards arrive in the live feed and reappear on the board. Nothing errors, and a full refetch filters archived rows by another path — so the symptom is archived work that comes back until the next reload. That gets reported as *"the board is flaky"*, not as a bug. ## The fix `resolveProjectColumnsForRoles` seeds the legacy ids before adding resolved ones, so the set is never empty and an **unconverted board excludes exactly `archived` as before**. The literal stays as the resolution-failure fallback, where excluding nothing would be worse than excluding the legacy id. ## Surface enumeration — three of four sites are dead Four sites share this invariant. Verified rather than assumed: | site | status | |---|---| | `reads.ts:558` (SSE / modified-since) | **live** — converted here | | `liveParentFilter` | **no references anywhere** in `packages/` or `plugins/` | | `listLiveTaskDocuments`, `listLiveArtifacts` | referenced **only** by `taskstore-remaining.test.ts` | That's why this PR converts one site rather than four — the other three are production-dead, and converting dead code would add risk for no behaviour. ## Measured | check | result | |---|---| | new PG suite | **4 cases** — legacy control, the renamed defect, a live-lane negative, and the forensic `includeArchived: true` read | | mutation (force the legacy fallback) | fails **exactly** the renamed case; the other three hold | | six gates + `tsc` | green | The negative case is the one that matters most: resolving the archive role must not start excluding **live** work, or the board silently stops updating for real tasks — a worse failure than the leak this fixes. ## One process note I corrupted this file mid-session by mutation-testing it while uncommitted: a failed restore left a half-applied block, and a later `git checkout --` discarded the fix entirely. Both were caught by re-grepping for the symbol rather than trusting the restore. The reliable pattern is **commit first, then mutate, then `git checkout` to restore** — which is how the proof above was actually run. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
35729699b8 |
fix(dashboard): Lane and ListView sorted every column with the LEGACY role defaults (wrong card order on renamed boards) (#3016)
`sortTasksForDisplayColumn` takes four role answers and defaults each to the legacy id. Its own header names the callers that never supplied them: > *"defaults to the legacy id so the callers that do not resolve flags (Lane, ListView) keep today's behaviour exactly."* On a renamed board, today's behaviour is the **wrong order**, silently: | lane | what is lost | |---|---| | hold | priority-then-FIFO queue order — an urgent card is no longer visibly next | | complete | completion-date ordering | | review | the merging card no longer floats to the top | Nothing throws, nothing logs. The cards are simply in the wrong order — which is exactly why this survived every existing test in these files: their fixtures use the built-in ids, where the defaults happen to be right. `Board.tsx` already resolves these from `column.flags`. Mirrored here rather than answered a second way, including its `complete && !archived` done-like rule. ## Reverted All **3** new `Lane` cases fail. Each picks inputs where the role order and the generic fallback **disagree**: - hold — equal priority, so role order is created-at and the fallback is task-id - complete — `columnMovedAt` DESC vs task-id ascending - review — a `merging` card, which the fallback ignores entirely **My first draft asserted urgent-first and passed with the fix reverted.** The generic sort also puts urgent first, so the assertion discriminated nothing. Recording that because it is the second time this shape has caught me: an assertion that is *true* is not the same as an assertion that is *load-bearing*. ## Coverage I do not have `ListView`'s identical wiring has **no component test**. Its harness stubs `fetchBoardWorkflows` with a never-resolving promise, and `listColumns` derives from the resolved workflow — so a renamed board is not drivable there without reworking that stub, which several other tests in the file depend on. The call site is covered structurally by the lane-wiring ratchet (baseline 19 → 17) and by the helper's own unit tests, but that is a structural guarantee, not a behavioural one. I would rather say so than imply the two callers are equally proven. ## Verification Lane + ListView + taskSorting + Board **357 passed** · `pnpm test:gate` 161 + 13 + 487 + 71 · lint · lifecycle census `--strict` · lane-wiring · fnxc-dates (TZ=UTC) · changesets — green. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Fixed task sorting in lanes and list views after workflow columns are renamed. * Preserved correct ordering for completed, on-hold, archived, merge-blocked, and review tasks. * Ensured task ordering reflects each column’s configured role rather than its previous identifier. * Maintained consistent ordering across board and list views. * **Tests** * Added coverage for renamed workflow columns and their expected task ordering. <!-- end of auto-generated comment: release notes by coderabbit.ai --> |
||
|
|
d4add985fe |
test(engine): the unwired-parameter guard has been red on main — its list is stale by one (#3033)
## The unwired-parameter guard has been red on `main` ``` A parameter LEAVING this list is the goal; one arriving is a regression — update the list only to shorten it. expected [ …(16) ] to deeply equal [ …(17) ] ``` Reproduced on clean `origin/main`, so it is not a branch artifact. `packages/engine/src/scheduler.ts isWipColumn` is **supplied at both production call sites now** — `self-healing.ts:4754` and `:5825` pass `isWipColumn: completedWipColumns.has(blocker.column)` and the blocked-lane equivalent, wired by #2975/#2987. The list was not shortened in the same change. So this is the good direction: a parameter got wired. The assertion just was not told. ## Shortened, not re-recorded I diffed the computed set against the recorded one rather than regenerating: ``` DEPARTED (wired since): packages/engine/src/scheduler.ts isWipColumn ARRIVED (new): (none) ``` Exactly one departure, nothing arrived — so removing that single line is the whole fix, and it follows the file's own instruction (*"update the list only to shorten it"*). Re-recording wholesale would have silently absorbed any arrival too, which is the one thing this ratchet must not do. ## How it was found While measuring an unrelated change to the sibling census. **This suite is outside the merge gate**, which is why a red assertion sat unnoticed — the same reason #2969's 15 red agent-action tests survived, and worth noting as a pattern rather than a one-off. ## What I abandoned to get here, and why it belongs in this PR's story I was trying to remove two false positives from the sibling `check-lane-wiring` census — `bucketForTask(task: TaskItem)` and `otherBucketSecondaryLabel(task: TaskItem)`, both flagged only because `TaskItem` declares `columnFlags?`, both reading it off the entity internally. The rule I tried was the sibling guard's own documented one: a **required** parameter is enforced by the compiler, so it is not this census's question. It measured perfectly — 15 sites → 13, removing exactly those two and retaining every genuine entry. Then it failed `lane-wiring-census-named-types.test.ts`: ```ts export type MergeContext = { completeColumns?: ReadonlySet<string> }; export function canMerge(task: string, context: MergeContext): string { … } ``` A **required** parameter with a named options type is a shape that census deliberately covers — `canMerge(task, {})` really can omit the lane member. My "exact" rule was exact only against the current tree, and it broke a tested contract. I dropped it rather than edit their test to match my change. The two false positives therefore stay baselined, and the cost stands as previously recorded: a genuinely new unwired call in those two TUI files would be masked. I do not have a rule I can prove safe, and three attempts at this class have now traded false positives for worse false negatives. ## Verification (measured) - both guard suites — **18 passed / 0 failed** (was 1 failed) - `check-lane-wiring`, `lifecycle-column-census --strict`, `check-fnxc-future-dates` — green - `eslint` — clean (one pre-existing warning, no errors) Test-only; no product file touched. No changeset. |
||
|
|
e9b24b69e8 |
fix(dashboard): the duplicate banner judged the canonical by legacy lane ids (#3032)
Found by applying the check I proposed on #3028: **re-test each `ALLOWED_OMISSIONS` entry — does the omission still fail once the excuse is removed?** It found a stale excuse on its first run. ## The entry's blocker was never tested ``` "…TaskDetailModal.tsx::isNearDuplicateCanonicalInactive" reason: "…Correct supply needs a fetch — a data change. See the note at the site." ``` The reasoning gets the hard part right: passing `detailColumnFlags` would answer about the **modal's** task, not the canonical, and would type-check while reading as a conversion. Rejecting that is correct. Then it concludes the seam needs a fetch — without checking what is in scope. - `columnFlagsByTaskId` is **already a prop of this component** (declared `:367`, destructured `:727`, used for the fan-out map at `:3718`), keyed by task id. - The canonical is `tasks.find((c) => c.id === nearDuplicateOf)` — drawn from the same loaded set the map covers. If the banner can render at all, the canonical is in `tasks`. So `columnFlagsByTaskId?.get(canonical.id)` is the canonical's own flags, no fetch. **`Column.tsx:307` already does exactly this**, with a comment making the same point about not reusing the row's flags — a sibling call site of the same function, solved. ## What was broken The banner's "this duplicates X" warning stayed up when the canonical had landed in a **renamed** complete lane, because `isNearDuplicateCanonicalInactive` fell back to the legacy ids and never saw it as finished. Same user-visible symptom #2997 fixed for the card chip; this is the modal. ## Verification | state | result | |---|---| | clean | seams gate exit 0; 123/123 across `TaskDetailModal.rendering` + `Column.neardup-flags-arrival` | | revert the supply | **gate exit 1** — `isNearDuplicateCanonicalInactive() — supplied by 10/11 call sites; omitted at TaskDetailModal.tsx:1 (of 2)` | That mutation is the point: with the allow-list entry present, this exact omission passed silently. It is now defended by the gate rather than excused by it. `tsc -p tsconfig.app.json` 0 errors in the file, lint clean, FNXC gate exit 0. ## The general point This is the second allow-list entry in two PRs whose stated blocker was wrong — #3028 removed the other one (*"needs a published-API change"*; the SDK is `private: true` and every consumer was in-repo). An `ALLOWED_OMISSIONS` entry is a deferral **carrying a gate's authority**. It reads as settled, it lives inside the checker, and it turns "nobody tested this" into "someone tested it and concluded no". A stale baseline *number* invites a recount; a stale *paragraph* invites agreement. Both entries this gate carried were stale, and the note at this call site had even been revised once — the revision corrected which flags were wrong to pass, and left the untested "needs a fetch" conclusion standing. Worth a periodic sweep of the remaining entries as they accumulate; with these two gone the list is empty, so the cheapest time to institutionalise it is now. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
a460a9bbc0 |
fix(plugins,dashboard): the dependency graph drew every card with the LEGACY lane vocabulary (#3029)
## The third producer of unflagged cards — the one a host-side fix could not reach #3025 fixed the two producers that go through `renderTaskCard`. `GraphTaskNode` is a third: it imports `TaskCard` **directly** through the plugin's interop shim, so that fix bypassed it and every role helper inside a graph card kept reading the legacy ids. The same component also called the stuck predicate without its flags: ```ts const isStuck = isTaskStuck(task, taskStuckTimeoutMs, lastFetchTimeMs); // no columnFlags ``` so `isWipColumnRole` fell back to the literal and **no card in the graph could ever be stuck on a renamed board**. Because `isStuck` gates `isActive`, a wedged card rendered with the **active** styling — the graph reported *"running"* about a task that had not moved in hours, while the main board showed the same card as stuck. That asymmetry between two views of one task is the defect, and it is what the new test pins. ## One cause, so one fix Both symptoms came from the same gap: `PluginDashboardViewContext` exposed `tasks` and nothing about the board's vocabulary. It now carries `columnFlagsByTaskId` — the same per-task map `renderTaskCard` already uses, **two lines away in the same object literal**. ## I filed this twice as blocked on a public-API change. It was not. ``` packages/dashboard @fusion/dashboard private: true packages/plugin-sdk @fusion/plugin-sdk private: true plugins/fusion-plugin-dependency-graph @fusion-plugin-examples/dependency-graph private: true ``` No published surface anywhere in the path — three in-repo private packages and a hand-written `.d.ts`. **#3026 landed the general form of that mistake while I was still making it**: a deferral's stated blocker is a claim, and mine decayed unchecked until I finally measured it. ## Two type decisions worth reviewing - **`Partial<TraitFlags>`** in the plugin-facing type, not the dashboard's `ExecutorColumnFlags` — that module's own header restricts it to `@fusion/core` and `react` imports so external plugin builds can consume it. Same runtime object either way. - **`MainContentProps.columnFlagsByTaskId` widened** from `{complete, archived, intake, hold}` to the flags the map really carries. It is built from `workflow.columns.find(...).flags`, so the four-flag declaration was a narrower view than the value — and `countsTowardWip`, which every wip predicate needs, was invisible through it. That narrow type is why threading this looked impossible at first. Absent still means legacy, matching how the host treats remote rows and off-board columns: the degraded answer is the documented literal, never *"this board has no wip lane"*. ## Revert proof Dropping the 4th argument: ``` AssertionError: expected 'graph-task-node graph-task-node--acti…' not to contain 'graph-task-node--active' Tests 1 failed | 26 passed (27) ``` The paired case (a fresh legacy `in-progress` card still reads active) passes both ways by design — it guards against over-detection, so I am not counting it as coverage. The gate agrees independently: `plugins/fusion-plugin-dependency-graph/src/GraphTaskNode.tsx: 1 -> 0`, baseline re-recorded 16 → 15 in the same commit. ## Verification (measured) - plugin suite — **185 passed / 20 files** - dashboard `dashboard/` + `plugins/` suites — **48 passed / 6 files** - `tsc --noEmit` clean in both packages; `pnpm lint` clean - `lifecycle-column-census --strict`, `check-lane-wiring` (15, none added), `check-sql-column-literals`, `check-inert-flag-seams`, `check-fnxc-future-dates` — green No changeset: all three packages are `private: true`. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
25b3c06d2d |
fix(plugins): compound-engineering pipelines stalled forever on a renamed board (#3022)
Closes #3020 — which I filed **instead of** fixing, on a rationale that turned out to be wrong. I said the plugin had no scaffolding for faking `CePipelineStore` + `taskStore` together. It does: `_harness.ts` already builds a real `PluginContext` over a live PostgreSQL layer. The gap was **two missing readers on its task-store stub**, not missing infrastructure. I checked the harness only after filing. ## The defect `TERMINAL_COLUMNS` is `{in-review, done}`, and the reconciler advances a pipeline only when **every** current-stage board task is in that set. On a board whose review and completion lanes are renamed that's false for every task, permanently: - the pipeline never advances a stage - it never creates its outbound task - it sits `running` indefinitely Nothing errors, so it reads as work that hasn't finished. Unlike the display defects in this family (#3014, #3017), the CE flow actually **stops**. ## Shape The decision is extracted to an exported `isStageTerminalColumn` because it *is* the whole decision. Left private it could only be reached through a pipeline-state + links + board-tasks fixture, and the half that needed proving is that a renamed board resolves to its own lanes through this store. It uses `resolveReviewColumns` rather than re-deriving the union — that helper is the documented review **set** (`mergeOrchestration ∪ mergeBlocker ∪ humanReview`), so a board splitting those across a merge lane and a human lane is covered without this site drifting from it. ## Two things my first attempt got wrong **The fixture spelled traits in camelCase** — `{ trait: "humanReview" }`. Trait **ids** are kebab-case (`human-review`, `merge-blocker`, `wip`); the camelCase names are the resolved **flags**. Those columns therefore resolved to *no roles at all*, silently, because an unknown trait isn't an error. `complete` is spelled identically in both vocabularies, which is exactly what made the first run look like *"complete works, review is broken"* rather than *"the fixture is wrong"* — I nearly went debugging the production union. **The harness extension is additive** and inert until a test seeds it, so all 24 existing plugin suites see the previous shape. ## Measured | check | result | |---|---| | new suite | **4/4** | | reverting to the literal-only gate | fails **exactly 2** — the renamed-terminal case, and a board declaring a NON-terminal column named `done` — while the legacy control and the WIP/intake negative still pass | | plugin suite | **24 files, 184 tests green** | | `tsc` + all five gates | clean | That second row is the one that matters: the `done`-without-`complete` board is the only shape where a real resolution and a legacy fallback disagree, so it's what separates the fix from a lucky agreement. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
b2d4813410 |
test(dashboard): cover the dock's renderTaskCard, the second producer #3025 fixed (#3027)
**Stacked on #3025** (its commit is the parent here) — that PR fixes two producers of a dock/plugin-rendered `TaskCard`, and this adds the coverage for the second one. ## The gap #3025 correctly fixes **both** producers, which is the Surface Enumeration discipline working. Its test covers only `MainContent`. Measured by deleting the identical line from `useRightDockController`: ``` MainContent.graph-popout 6 passed RightDock 33 passed TaskCard.host-inventory 1 passed ``` All green with the dock's wiring gone. I checked every suite that touches that hook; none observes the prop. Its own test comment says: > REVERT CHECK: drop `taskColumnFlags` from **either** `renderTaskCard` and this reads "none". For this producer that is not true, and it is the producer that draws cards into the **right dock**, where an operator actually sees them. So the pair could quietly become a single again with every test still green. ## The test | state | result | |---|---| | #3025 as merged | 2/2 pass | | delete the dock's `taskColumnFlags={…}` line | **1 failed / 1 passed** | Driven through the real `renderTaskCard`, captured off the `renderProps` the controller hands `RightDock` — it is not on the returned controller object. `RightDock` itself is stubbed so the assertion cannot fail for unrelated dock plumbing. The paired negative asserts an unresolved task receives `undefined` rather than a fabricated object. That direction matters: inventing flags would make a card claim traits its board never declared, which is worse than the legacy fallback it replaces — the same *report, don't guess* reasoning as #2999's `!target` refusal. ## One harness note My first mock replaced `../RightDock` wholesale and the hook died on `No "readStoredRightDockOpen" export is defined on the mock` — the controller imports its persistence helpers from that module. Spreading `importOriginal()` and overriding only the two components fixes it. Recorded in the file because the next person stubbing this module will hit the same thing. **Verified:** 2/2, `tsc -p tsconfig.app.json` 0 errors in the new file, lint clean, FNXC gate exit 0. If #3025 lands first this rebases to a single test commit; if the two are taken together the stack applies as-is. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
43463f1a53 |
fix(dashboard): plugin- and dock-rendered cards resolved no column traits at all (#3025)
`renderTaskCard` is how a plugin view or the right dock draws a real task card. **Both** producers built a `TaskCard` without `taskColumnFlags`, so every role helper inside that card fell back to the legacy id — archive/revert affordances, progress, the elapsed-time indicator, the planning badge — for every plugin view on every board. Both already had the per-task map in scope. `MainContent` uses it **two lines away** for the near-duplicate canonical lookup; `useRightDockController` reads `input.columnFlagsByTaskId` for the same purpose. The card was simply never given it. ## One affordance, two producers Fixed together rather than one-plus-a-follow-up — that's what the Surface Enumeration rule is for. Finding the second producer is the only thing that makes this an invariant fix rather than a repro-shaped one. ## Measured | check | result | |---|---| | new case in the existing MainContent plugin-host harness | the rendered card must carry its resolved traits; dropping the argument from either producer reads `"none"` | | mutation on MainContent's producer | fails exactly that case | | `dashboard/__tests__` + `useRightDockController` | **35 tests green** | | census · inert-seam · lane-wiring · FNXC | green; lint and `tsc` clean | ## Not done — and my earlier reason for it was wrong `fusion-plugin-dependency-graph` still calls `isTaskStuck` without flags, exempted in the inert-seam gate by #3002. I filed #3003 claiming supply needed a **published-API change**. That's wrong: `PluginDashboardViewContext` is dashboard-internal and `@fusion/plugin-sdk` is `private: true`. I checked this time instead of asserting it — which is how I found the actual obstacle. The real blocker is that the plugin compiles against **different dashboard type declarations** than the dashboard source does: it sees a `PluginDashboardViewContext` without the field I added and an `isTaskStuck` accepting only three arguments. I built the full chain (context field → host supply → plugin consumption), hit those three errors, and reverted the plugin half rather than guess at the type plumbing inside a behaviour fix. So the exemption stands with a corrected reason, and #3003 is updated. That's the second filing rationale of mine to turn out wrong on inspection this session — after #3020, which I ended up fixing in #3022. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
f8155cafd7 |
fix(cli): the node-override guard saw only the FIRST wip lane (#3023)
Follow-up to #3019, which merged with an incomplete fix. I found this while sitting down to write the test that PR was missing. ## The guard still never fired, one lane over #3019 wired `fn_task_update`'s guard like this: ```ts const nodeOverrideLifecycle = await resolveTaskLifecycleColumns(store, task.id); wipColumns: nodeOverrideLifecycle?.wip ? new Set([nodeOverrideLifecycle.wip]) : undefined, ``` `resolveTaskLifecycleColumns` → `resolveLifecycleColumns`, whose per-role accessor is **first match** (`workflow-lifecycle-traits.ts:353`): ```ts const first = (flag) => resolved.find((c) => c.flags[flag] === true)?.id; ``` The guard's contract is **every** column carrying the trait — its own resolver uses `columnsWithFlag(ir, "countsTowardWip")`. So on a board with a build lane beside a verify lane, a task sitting in the **second** wip lane still slipped the mid-flight check, and an operator could still repoint the node of a running task. That is the defect #3019 set out to close. Interchangeable on any single-wip-lane board, which is exactly why it read as correct — the same arity trap #2975 removed from the surfacing family. ## The fix Use `resolveNodeOverrideLanes`, the guard's own resolver, which `task-update.ts` and `branch-and-pr-entities.ts` already call. All three callers now resolve identically and the V1/unresolvable fallback lives in one place. Needed a one-line re-export from `@fusion/core`. **Mutation:** forcing the resolver to first-match (`.slice(0, 1)`) fails the new case, 1 of 32. The new test names **two** wip lanes, because that is the only shape that separates the two resolutions — a single-wip-lane test passes against both, which is why #3019's gap was invisible and why I would have written a useless test if I had not read the implementation first. ## A gate constraint worth recording My first version passed the resolved object straight through: ```ts validateNodeOverrideChange(task, normalizedNodeId ?? null, overrideLanes) ``` Identical at runtime, and it turned the lane-wiring gate **red**: `check-lane-wiring` matches an object-literal argument and cannot see through a variable, so the correct call reads as UNWIRED. #3019's header records hitting the same constraint — and it is what pushed that PR toward resolving the lanes inline, which is where the first-match bug entered. So the gate's shape requirement steered a correct instinct into a subtly wrong implementation. The fix here spells both keys explicitly, satisfying the gate without the bespoke resolution. Worth someone deciding whether the census should follow a variable to its initializer — but that is a change to a shared ratchet, and I have noted it at the call site rather than making it. **Verified:** 32/32 core guard suite, `tsc` 0 errors for both packages, lane-wiring gate exit 0, FNXC gate exit 0, lint clean. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
5659ccace9 |
test(cli): pin the node-override error contract on a renamed board, which is what #3019 actually changed (#3024)
## What #3019 actually changed, pinned — and a correction to my own claim I described #3019 as closing a hole where an operator could re-route a running task on a renamed board. **That was wrong.** `TaskStore.updateTask` runs the same guard with its own resolved lanes (`resolveNodeOverrideLanes`) and throws, so the change was refused either way. This test is how I found out: I wrote it to cover #3019's wiring and it passed against a tree with that wiring removed. A test that passes with the change reverted is not a test, so I went looking for what was really refusing — and it was the store. ## But the two paths *are* distinguishable, which my correction then got wrong in the other direction In correcting myself on #3019 I said the paths were externally indistinguishable and no test could separate them. Also wrong. Measured both ways: | | `details.error` | | --- | --- | | pre-check fires (wired) | `"task-in-progress"` — machine-readable reason code | | pre-check misses (unwired) | `"Cannot change node override for KB-001 while it is in progress…"` — the store's thrown prose | So on a **legacy** board a caller could branch on `task-in-progress`; on a **renamed** board it silently got a sentence instead. That is a real API inconsistency, visible only to whoever was parsing it — the kind of thing nobody notices until it breaks. That is what these cases pin, and it is the honest description of #3019's value: an error-contract fix, not a security fix. ## Revert proof With #3019's wiring removed: ``` Expected: "task-in-progress" Received: "Cannot change node override for KB-001 while it is in progress. …" Tests 1 failed | 1 passed (2) ``` Verified by actually reverting, not by reading the source — which is the discipline that caught both of my wrong claims above. The paired case ("still allows the override once the card leaves that wip lane") passes both ways by design; it guards against over-refusal, so I am not counting it as coverage of the contract. ## Also closes the gap I named in #3019 That PR shipped with `check-lane-wiring` as its only regression proof, and I said a behavioural test was owed. The two are complementary and fail for different reasons: **the ratchet** fails if the argument stops being passed; **this** fails if it is passed and the contract still degrades. ## Verification (measured) - **2 passed / 0 failed** - `tsc --noEmit` clean; `eslint` clean (one pre-existing warning, no errors) - `check-fnxc-future-dates`, `lifecycle-column-census --strict`, `check-lane-wiring` — green Tests only; no product file touched. No changeset. ## Note on the harness, for whoever writes the next one of these Seeding a card into a renamed lane has two traps, both inherited from `merge-blocker-renamed-review-lane.test.ts` and both recorded in this file's header: the real API is `createWorkflowDefinition` + `selectTaskWorkflow` (the plausible `saveWorkflowDefinition?.()` does not exist and the optional call swallows it silently), and moving a card takes `moveTask`, not `updateTask({ column })`. Both are guarded here by asserting the card really is in `building` before the subject runs. |
||
|
|
6f936f2de7 |
fix(cli): the node-override guard never fired on a renamed board, so mid-flight changes were allowed (#3019)
## The node-override guard never fired on a renamed board
`fn_task_update` called the guard with no options:
```ts
const validation = validateNodeOverrideChange(task, normalizedNodeId ?? null);
```
so `wipColumns` fell back to its documented default of
`{"in-progress"}`. On a board whose WIP lane is named anything else,
`wipColumns.has(task.column)` is false, the mid-flight check passes, and
**an operator can change the node override on a running task** —
precisely what that guard exists to refuse, in its own words:
> "Is this task executing right now?" — keyed on the literal, a renamed
board let an operator change the node override MID-FLIGHT on a running
task, which is exactly what this guard exists to refuse.
That note is attached to the `wipColumns` option added for this purpose.
The CLI simply never passed it.
## Two assumptions in the guard's own docs that did not hold
```
Both callers supply them. An omitted set keeps the legacy id, which is what a caller
without cheap IR access (a CLI tool, a route with only a task row) still gets.
```
1. **"Both callers"** — this is a *third* one, and it was in
`check-lane-wiring`'s known-unwired baseline the whole time.
2. **"a CLI tool … without cheap IR access"** — this handler is async
and has already awaited `store.getTask`, so one more resolve costs
exactly what `resolveTaskLifecycleColumns` already costs elsewhere **in
this same file** (the linked-lineage label at ~1239). The assumption was
reasonable in general and wrong here.
Passed present-but-conditionally-valued rather than as a conditional
argument: an omitted set still keeps the documented legacy default, and
only that shape is visible to `lane-wiring-census`, which matches an
object-literal argument and cannot see a ternary.
## Coverage — stated rather than implied
**There is no new unit test.** The regression guard is the ratchet
itself, and it is a real revert-proof: with the wiring removed,
```
[check-lane-wiring] call sites not passing a resolved lane argument INCREASED:
packages/cli/src/extension.ts: 1 unwired now, baseline allows 0
```
Verified by actually reverting it, not by assuming. Baseline re-recorded
19 → 18 in the same commit, so the allowance cannot be regrown into.
A behavioural test would need a custom workflow definition persisted
*and* selected inside the integration harness to get a card resting in a
renamed WIP lane. That is worth doing and I would take it as follow-up
harness work — but it is not part of this fix, and I would rather name
the gap than let "85 passed" imply coverage I did not write.
## Verification (measured)
- **85 passed** across `extension.test.ts`,
`extension-experiment-finalize.test.ts`,
`task-list-board-columns.test.ts`
- `tsc --noEmit`, `eslint` — clean
- `check-lane-wiring` (18, none added), `lifecycle-column-census
--strict`, `check-inert-flag-seams`, `check-fnxc-future-dates`,
`check:changesets` — green
Changeset included (`patch`): `packages/cli` is the published
`@runfusion/fusion` and this changes guard behaviour operators rely on.
|
||
|
|
5bdb8a1102 |
fix(dashboard): planner activity was never stamped on a renamed intake lane (#3017)
## How this was found — by re-testing a claim of mine The learnings doc records "named legacy-id collections" as **measured and clean**: 48 declarations, all fallback vocabularies, builtin column lists, or already-converted seams. #3014 disproved that conclusion — `TIME_INDICATOR_COLUMNS` was in that population and was a live defect. So I re-measured over the shape that actually matters: **collections used as a membership gate against a column.** Nine exist. | site | verdict | |---|---| | `columnRoles.ts` ×2, `useSessionFiles.ts` | the no-flags fallback *inside* the role helpers — correct by design | | `branch-group-ops.ts` | seeds the legacy pair then unions resolved lanes — already converted | | `DocumentsView.tsx` | marked `DELIBERATE-LITERAL` fallback chain | | `TaskCard.tsx` ×2 | fixed in #3014 | | `plugins/…/reconciler.ts` | plugin with no trait source — same class as #3003 | | **`useTasks.ts`** | **no flags path anywhere in the file** | ## The defect `useTasks` stamps `recentAgentActivityAt` only for cards in `{triage, todo}`. The note at that set argues over-stamping is harmless because every consumer re-checks for an intake lane before showing anything. That's true, and it **only protects against false positives**. On a board whose intake and hold lanes are renamed, the pair matches nothing — so no stamp is ever written, and a correct downstream role check has nothing to filter. The planning border and pulsing badge never appear while the planner is actively working the card. ## The supplier ships with the seam An optional resolver with no caller is the first failure shape in the learnings doc, and my own gate would flag it — so `App` supplies it in the same commit. `useBoardWorkflows` moved above `useTasks` to make that expressible; it depends on `projectId` alone, nothing about tasks, so reading it first is safe. Remote rows deliberately get **no** flags — they belong to another store, and local board-workflow metadata must never be applied to their ids. That's the rule the footer index already follows. ## Measured | check | result | |---|---| | `useTasks` suite | 124 → **126**, all green | | reverting the gate to the legacy pair | fails exactly the renamed case; the negative (renamed WIP is not planning) still passes | | `App.test` + `useTasks` together | **269 green** | | gates | all five green; lint and `tsc` clean | ## One observation I could not reproduce The `App`+`useTasks` pair failed once, on a single unnamed test, and passed on **four** subsequent runs including three consecutive. The captured output showed jsdom URL-parse noise from `MissionManager` fetches rather than an assertion failure, and the same pair is green on unmodified `main`. I'm not quarantining another file's test on one unreproducible observation, but recording it rather than letting a green rerun bury it — if it resurfaces in CI, this is the prior sighting. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
f0875a79c6 |
fix(dashboard): finished cards on a renamed board never showed a completion date (#3007)
The **fifth** instance of the async-memo shape #2998 documents, and the one that survived #3001's sweep. `lifecycleDates` gates its `completed` value on `isCompleteColumn || isArchivedColumn` — both derived from the async `taskColumnFlags` prop — while listing neither: ```js }, [task.createdAt, task.executionCompletedAt, task.archivedAt, task.column, locale, lifecycleNowMs]); ``` First paint runs with the flags undefined, the role helpers fall back to the legacy ids, and on a board whose complete lane is named anything but `done` that answers false. The flags arrive, `task.column` has not changed, nothing recomputes, and the card renders **no "Completed <date>" line at all**. ## Why #3001's sweep called this covered That PR recorded `mergeSignature` as *"the last live site … nine persistent candidates, seven covered transitively or by a dependency that already carries the flags."* This memo was presumably in the covered pile, and the reasoning is nearly right: it **does** list a dependency that changes — `lifecycleNowMs`. But that value is driven by a timer scheduled with `millisecondsUntilNextLocalMidnight` (FN-8561, so compact date labels turn over at the viewer's midnight). **A dependency that changes once a day is not coverage for a value that must be correct on first paint.** The card shows no completion date for the rest of the session. That distinction is worth adding to the doc's property 2: *does a listed dependency change* is the wrong question — *does it change when the resolved value arrives* is the right one. ## Verification | state | result | |---|---| | clean | 2/2 pass | | revert the dep fix | **1 failed / 1 passed** | The control case (a `done` board) passes either way by design, so a failure in the renamed case means "renamed board", not "nothing renders". **One trap worth recording**, since it nearly cost me the finding: my first `completedLine()` used `time[datetime]:last-of-type`. When only the *Created* line renders, that selector returns **that** element — so the pre-resolution absence assertion silently passed against the wrong node. The test now matches on the element's own `Completed` label. A positional selector cannot express "this specific line is missing". `tsc -p tsconfig.app.json` 0 errors, lint clean, 8/8 across all three renamed-lane TaskCard suites. ## Note `main` is currently red on the FNXC gate for an unrelated reason (#2994's impossible-hour stamps landing after #2995); fixed in #3006. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
3b55e2c96c |
fix(dashboard): the time indicator was gated on a hardcoded legacy lane set (#3014)
## This is a correction to #2996, and that's why it exists #2996 fixed the **subscription**: `wantsLiveTimeIndicator` kept a pre-load answer, so the card never joined the shared ticker. I described it as making renamed-lane cards *"show their live elapsed-time indicator."* It made them **eligible to**. They still rendered nothing, because a second gate rejects them first — and I didn't look past the seam I'd just fixed. ```ts const TIME_INDICATOR_COLUMNS = new Set<ColumnId>(["in-progress", "in-review", "done"]); ``` Both the `timeIndicator` memo and the `chipFarRight` layout test `task.column` against that set directly, so a card in a renamed WIP, review or completion lane returns `null` whatever its resolved traits say. ## Why no check saw it The census counts **comparisons** against legacy ids. This is a `Set` literal — a **definition**. Nothing in the backlog ever pointed here, which is the same blind spot that hid `BLOCKER_ESCALATION_COLUMNS` until someone read the code rather than the report. ## The fix The gate becomes a role question, with the legacy set kept as the **no-flags fallback** and marked `DELIBERATE-LITERAL`. A card whose traits haven't resolved — first paint, or a lane its workflow no longer declares — behaves exactly as before. ## Measured | check | result | |---|---| | test written first | red for the right reason — control and negative passed, only the renamed case failed (`expected false to be true`) | | after the fix | 3 passed | | reverting the memo gate to the raw set | that case fails again | | five `TaskCard` suites | **418 tests green** | | gates | all five green; lint and `tsc` clean | ## The negative case Resolving traits must not put a live timer on every lane. A card in the renamed **intake** lane hasn't started, so it stays out — otherwise the fix trades a missing indicator for a running clock on work that hasn't begun. ## Worth noting for the pattern Two of my last four findings came from re-examining my own merged work rather than from new code: this one, and the `bounded` heuristic correction in #3012. Fixing one seam and declaring the symptom gone is its own failure mode — the user-visible behaviour needed *both* halves, and I only checked the half I'd touched. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
86c5a89169 |
fix(dashboard): dropping into a renamed intake lane reset progress without asking (#3011)
The **sixth** instance of the async-memo shape #2998 documents — and the only one that **loses work** rather than mis-rendering. `handleDrop` gates the "Preserve Progress?" confirmation on the lane's role: ```js const shouldPrompt = hasStepProgress && isPreImplementationColumnRole(columnFlags, column); if (shouldPrompt) { const keepProgress = await confirm({ … }); … } ``` …but its `useCallback` deps were `[addToast, allTasks, column, confirm, onMoveTask, tasks, t]` — no `columnFlags`. The board resolves workflow traits after first paint, so the DOM keeps the closure built during the pre-load render, where `columnFlags` is `undefined` and the helper falls back to `LEGACY_PRE_IMPLEMENTATION_COLUMN_IDS`. A renamed intake lane is not a member. **Result: a card with completed steps dropped into that lane moves with `shouldPrompt === false`.** The user is never offered "Keep Progress", and the steps are reset silently. ## How this was found It is the last unverified candidate from the derivation-aware scan I posted on #2998, where I explicitly declined to file it as a bug without checking. Checking it is what turned it from a scanner hit into this. ## Severity, stated honestly `allTasks` and `tasks` are in the dep list and change identity on any task-list refresh, so the stale closure is rebuilt within seconds on a busy board. The exposure is the quiet gap right after the traits land — **bounded**, like the near-duplicate chip (#2997), not permanent like the ticker (#2996) whose only refreshing dependency fired at local midnight. Bounded still matters here because the cost is not a wrong pixel: it is completed steps discarded without a prompt, and the window is exactly when someone has just opened a board and starts dragging. ## Verification | state | result | |---|---| | clean | 2/2 pass | | revert `columnFlags` from the deps | **1 failed / 1 passed** | The paired negative asserts a non-pre-implementation lane still moves **without** prompting — a fix that prompts everywhere turns the dialog into noise that gets clicked through, costing the same progress it protects. The observable is `confirm`, not `onMoveTask`: whether the user was *asked* is the contract, and asserting on the move alone passes either way. `tsc -p tsconfig.app.json` 0 errors in the new file, lint clean, FNXC gate exit 0, 4/4 across both `Column` flags-arrival suites. ## Running tally of this shape ticker (#2996) · near-duplicate chip (#2997) · fan-out trait index (#2993) · merge signature (#3001) · lifecycle dates (#3007) · this. Six, in two components plus the fan-out path. #3001 called the merge signature "the last live site"; it was the last of *that* sweep's nine candidates, and two more have surfaced since from a different scan. Worth knowing before anyone declares the class closed again. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
1de0141ab8 |
fix(dashboard): Task Detail's blocking count read the LEGACY lanes (last of the three fan-out surfaces) (#3004)
Third and last of the three surfaces calling the blocker fan-out wrapper, completing the sweep started in #2990 (Board + Executor bar). ## What was wrong, precisely The dependent **list** is lane-independent — core pushes `dependentIds` without consulting lanes — so this section looked broadly right. Two things beside it are not: - `overlapBlockedTodoCount`, rendered as **"FN-X is blocking N todo task(s) via blockedBy overlap"** — counted against the literal `todo`, so on a renamed board it read **0 while cards were genuinely blocked**. - the `stale` marker on each blocking dependent — decided against `terminal`/`review` lanes the operator does not use. A wrong number sitting beside a right list is the easiest kind to miss, which is why I checked what the modal actually consumes before deciding this was worth a PR rather than assuming the whole section was broken. ## Why a prop and not a hook This was the surface I deferred in #2990 because it had no trait index in scope. Two options: - `useBoardWorkflows` inside the modal — rejected. The hook documents that it does **not** dedupe across consumers: each call installs its own visibilitychange/focus listeners and its own SSE subscription. That is a new fetch and subscription per modal open, to answer a question the app has already answered. - **Thread the index that already exists** — `App` builds `footerColumnFlagsByTaskId` for the footer; this forwards it through `AppModals` as an optional prop. Chosen. Optional throughout: a card with no entry keeps the documented legacy fallback, so the remote-node case (where local workflow metadata must never be applied to foreign ids) and the pre-load window stay byte-identical. ## Reverted The new case fails on the rendered text — the modal cannot find `"FN-B is blocking 2 todo task(s) via blockedBy overlap"`. The pre-existing legacy-column case above it passes either way, because `todo` satisfies the literal default; that is exactly why it never caught this. ## Verification TaskDetailModal.rendering + ExecutorStatusBar + useBlockerFanout **206 passed** · dashboard app suite 11986 passed / 5 skipped (581 files) · `pnpm test:gate` 161 + 13 + 487 + 71 · lint · census `--strict` · lane-wiring · fnxc-dates · changesets — green. ## One note for whoever owns the FNXC gate `check-fnxc-future-dates.mjs` **rewrites its baseline as a side effect and still exits 0**. Today's date roll dropped 183 stamps out of "future", so any run dirties `scripts/lib/fnxc-future-dates-baseline.json` in the working tree. It cost me a stash conflict before I noticed. Not bundled here — it is repo-wide midnight drift, not this change — but a check that mutates tracked state on a read is worth a look. |
||
|
|
72f5f8e51a |
fix(gate): the FNXC stamp gate never validated the hour, so 25:30 passed (#2995)
`check-fnxc-future-dates.mjs` validates the **date** portion of a stamp
and never looks at the clock time:
```js
const STAMP = /FNXC:[A-Za-z0-9_-]+\s+(\d{4}-\d{2}-\d{2})/g;
…
for (const match of source.matchAll(STAMP)) if (match[1] > today) hits += 1;
```
The capture stops before the hour, so a stamp may carry **any** `hh:mm`
and pass. Found while pre-flighting #2992, whose new comments read
`2026-07-30-25:30`.
## It is not one typo
Four stamps **already on `main`** carry a clock time that cannot exist:
```
packages/cli/src/__tests__/task-list-board-columns.test.ts:2 -24:40
packages/cli/src/commands/task.ts:29 -24:40
packages/cli/src/commands/task.ts:636 -24:40
scripts/check-lane-wiring.mjs:18 -24:00
```
Three separate authors, so this is the gate's blind spot rather than one
person's slip — and #2992 adds two more, which is how I noticed.
AGENTS.md specifies `yyyy-MM-dd-hh:mm`. The stamp's whole purpose is to
make the FNXC record a readable chronology of *why* code exists; a
timestamp that cannot exist quietly costs it that, and nothing was going
to catch it.
## The fix
Hours `00-23`, minutes `00-59`, counted per file **alongside** the
future-dated population rather than as a separate gate — same defect
class (a stamp that does not describe a real moment), and one ratchet is
cheaper to keep honest than two.
**Mutations, both directions:**
| stamp | result |
|---|---|
| `2026-07-30-25:00` | **flagged** |
| `2026-07-30-23:75` | **flagged** |
| clean tree | `475 known future-dated stamp(s), none added`, exit 0 |
## On the four existing stamps
Normalized by clamping the impossible hour to `23`, minutes preserved,
so relative ordering within each file survives. **That is a
normalization with a stated rule, not a claim about the true minute** —
`-24:40` most plausibly meant "just past midnight", but writing
`2026-07-31-00:40` would be future-dated against today's local calendar
and fail the very gate this PR extends. Clamping keeps every stamp real,
ordered, and non-future; the exact minute was already unrecoverable.
**Verified:** FNXC gate exit 0, lane-wiring gate exit 0,
`task-list-board-columns` 5/5, lint clean.
Comment-only changes to the CLI files (stamp text inside FNXC blocks),
so no behaviour change and no changeset.
Noted separately on #2992 so its two new stamps get corrected there
rather than landing and immediately failing this gate.
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
cf6062c524 |
fix(dashboard): finished cards on a renamed board never refreshed their diff stats (#3001)
Last live site from the stale-lane-dependency sweep recorded in #2998. **Nine persistent candidates; this and #2996 were real.** The other seven are covered transitively or by a dependency that already carries the flags — each checked by hand rather than filed, which is the whole point of that doc. ## The defect `mergeSignature` is the key `useTaskDiffStats` uses to notice that a merge changed what a finished card should display. It early-returns `undefined` unless `isCompleteColumn`, which derives from the `taskColumnFlags` **prop** — and its dependency list was three `task.*` fields, none of which is that prop or carries it. The flags arrive after first paint, so: 1. first computation runs with flags `undefined`; 2. the role helper falls back to the legacy id — `isCompleteColumnRole(undefined, "shipped")` is **false**; 3. the key is `undefined`; 4. for a card **already merged when the board loaded** — the common case for anything sitting in a completion lane — neither `mergeDetails` field changes afterwards either; 5. nothing recomputes, and the hook never learns a merge landed. A legacy board hides it: `column === "done"` answers true on the very first paint. ## Measured | check | result | |---|---| | test written first | red for the right reason — control and negative passed, only the arrival case failed (`expected undefined to be defined`) | | after the fix | 3 passed | | dropping the dependency again | that same case fails | | `TaskCard.test` + new suite | **391 tests green** | | gates | census + FNXC green; lint and `tsc` clean | The observable is the options object handed to `useTaskDiffStats`, so the assertion is on the value this component is responsible for producing rather than on what the hook does with it. ## The negative case Recomputing must not hand a signature to cards that aren't finished. An in-flight card has no merge to key on, and inventing one would have the diff-stats hook treat unfinished work as landed. ## The sweep is now closed For anyone picking this up later: the four "bounded" sites from #2998's triage remain unexamined **by design** — their dependency lists all contain a fast-refreshing value (`allTasks`, a live clock), so any wrong answer there survives only until the next update. That's a judgement about priority, not a claim that they're correct. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
faf4245c7b |
fix(dashboard): the duplicate chip kept pointing at work that had already landed (#2997)
Second live defect from the same sweep as #2996 — memoized hooks reading a lane value absent from their dependency list. **13 hits, triaged by hand, 2 real.** ## The defect `resolveNearDuplicateCanonicalInactive` decides whether a card's *"duplicate of X"* chip is hidden because the canonical is finished. It calls `getTaskColumnFlags`, whose identity changes when the board's workflow traits arrive — while its own dependency list was `[allTasks]` alone. So it kept the closure created during the **pre-load** render, over an empty trait map. With no traits the role helpers fall back to legacy ids, so a canonical sitting in a renamed complete lane reads as still **active**, and the chip stays up advertising a duplicate of work that has shipped. ## Severity, stated honestly The closure is rebuilt whenever `allTasks` changes identity, which any task-list refresh does. So this is a **bounded window**, not a permanent wrong answer — unlike #2996, where the dependency that would have refreshed it (`task.column`) never changes. On a quiet board the window is the gap until the next update. I'd rather say that plainly than let it read as equally severe because it's in the same family. ## The hoist is required by the fix, not tidying `getTaskColumnFlags` sat *after* this callback, with a note explaining that the body only runs during render so the const is initialised by then. That's true of the **body** and false of the **dependency array**, which evaluates eagerly — so the reference could not be listed at all until the declaration moved. The existing note reasoned carefully about declaration order and said nothing about staleness, which is exactly how it read as considered. Both hoisted callbacks close over props only, so the move carries no behaviour. ## Measured | check | result | |---|---| | test written first | red for the right reason — arrival case `expected false to be true`, negative passed | | after the fix | 2 passed | | **dropping the dep while keeping the hoist** | arrival case fails again | | `Column.test` + new suite | **87 tests green** | | gates | census + FNXC green; lint and `tsc` clean | That third row is the one that matters: it isolates the test as load-bearing on the **dependency**, not on the code move that had to accompany it. The observable is the prop `Column` computes, not the chip markup — `Column` is the producer here, and asserting on `TaskCard`'s rendering would test the consumer of a value this component gets wrong. ## The negative case Re-resolving must not degrade into "every canonical is inactive". A canonical still in a live lane keeps its chip, or the fix silently hides **real** duplicate warnings — worse than a stale one, because then nothing points at the collision at all. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
5ce23b2187 |
fix(dashboard): the live elapsed-time indicator never started on a renamed board (#2996)
## How this was found By generalizing the memo-dependency defect in the blocker fan-out (#2993) into a sweep for memoized hooks that read a lane value absent from their dependency list — rather than treating that one as a one-off. **13 raw hits, 12 benign** (refs, or values reached through a covered object). This is the one that's a live defect. ## The defect `wantsLiveTimeIndicator` decides whether a card subscribes to the shared time ticker. It reads `isWipColumn`, `isReviewColumn` and `taskColumnFlags` — all derived from the `taskColumnFlags` **prop** — while its dependency array listed only `task.*` fields. Those flags arrive **after first paint**: the board resolves workflow traits asynchronously. So: 1. first computation runs with flags `undefined`; 2. role helpers fall back to legacy ids — `isWipColumnRole(undefined, "building")` is **false**; 3. the card declines the ticker; 4. flags arrive, but `task.column` hasn't changed, so nothing in the dep array changed; 5. the memo never recomputes. **No live elapsed time, for the life of the mount.** ## Why it survived On a legacy board the fallback already answers `true` on the very first paint (`column === "in-progress"`), so the memo's initial value is correct and the stale list costs nothing. The defect is **renamed-board-only**. This repo also has no `react-hooks/exhaustive-deps` rule, so the entire class is invisible to lint — and a disable directive for that rule fails CI, so these lists are maintained by hand. ## Measured The test was written **first** and was red for the right reason before any fix — the control and the negative passed, and only the renamed case failed: | stage | result | |---|---| | before the fix | `expected false to be true` (renamed case only) | | after | 3 passed | | `TaskCard.test` + `cli-states` + `oversight` + new suite | **456 tests green** | | gates | census + FNXC green; lint and `tsc` clean | The assertion is on `useLiveTimeTicker(enabled)` — `enabled` *is* `wantsLiveTimeIndicator`, so it observes the subscription itself rather than a proxy for it. ## The negative case is the one that matters Recomputing must not degrade into "every card subscribes". A card in the renamed **complete** lane must stay off the shared ticker, or the fix trades one stalled indicator for sixty cards waking a backgrounded tab — the exact cost the shared-ticker refactor documented at this site (it replaced 60 per-card `setInterval`s precisely because mobile browsers discard a page that never goes idle). Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
65f4e8533e |
fix(dashboard): blocker fan-out classified every board against the LEGACY lanes (finished cards shown as blockers; escalation never fired) (#2990)
The dashboard's `computeBlockerFanoutMap` wrapper called core with **no
lane answers at all**:
```ts
return computeBlockerFanoutMapCore(tasks, MAX_AUTO_MERGE_RETRIES, {
staleHighFanoutAgeThresholdMs: options.staleHighFanoutAgeThresholdMs,
}); // no terminalColumns, no reviewColumns, no holdColumn, no classify
```
So every fan-out surface classified against `todo` / `in-review` /
`done` regardless of what the operator named their columns. Core defines
**active by exclusion — not terminal** — so on a renamed board a
**finished** card never became terminal and stayed an active blocker
forever. The Executor bar's highest-overlap blocker and the task modal's
blocking-dependents list both kept naming work that had already landed.
**Escalation was worse.** `shouldEscalate` requires the blocker to sit
in an escalation lane (wip ∪ review), which unresolved means
`in-progress`/`in-review` only — so a stale blocker holding up many
cards **never escalated**. The fan-out numbers themselves stayed
correct, which is what makes it easy to miss: the metric says there is a
problem and the mechanism that acts on it is switched off.
## Shape
**Per task, not a board-wide union** — the reason `blocker-fanout.ts`
documents on `classify`: an id means something only relative to its own
workflow, and this board renders several at once. `Board` builds the
index exactly as `App.tsx` already does for the footer
(`footerColumnFlagsByTaskId`): task → its own workflow → that workflow's
entry for the column the card rests in.
**Escalation = wip ∪ review**, mirroring `scheduler.ts`'s own
construction. The two must agree — the scheduler decides a blocker
escalates and the dashboard is where an operator sees it.
**An empty trait map means "not resolved yet", not "nothing is
terminal."** The pre-load window and the remote-node case keep the
documented legacy default rather than fabricated lifecycle state.
## Reverted
| case | reverted |
|---|---|
| a finished card in a renamed completion lane is not an active blocker
| **fails** |
| a stale high-fan-out blocker in a renamed wip lane escalates |
**fails** |
| unresolved traits stay byte-identical | passes either way — that is
why it is there |
## Two notes
- The hook call had to move below `useBoardWorkflows` in `Board` (it was
at line 206, the workflows at ~390). `blockerFanoutMap` is consumed only
in JSX, so the hook order change is unconditional and stable.
- The unresolved-card fallbacks are hoisted into three named helpers
with `DELIBERATE-LITERAL` markers on the **declarations** — the census
reads markers from leading comments, so an inline one attaches to the
wrong node and is silently ignored. Census baseline re-recorded in the
same commit (debt did not increase; markers moved 5 sites out of the
guard count).
## Not done
`ExecutorStatusBar` and `TaskDetailModal` call the wrapper directly and
still pass no traits. `ExecutorStatusBar` already receives
`columnFlagsByTaskId` so it is a one-liner; `TaskDetailModal` has no
trait index in scope and needs one threaded. Left out to keep this
reviewable — the ratchet keeps both visible.
## Verification
dashboard app suite **1919 passed (140 files)** · `pnpm test:gate` 161 +
13 + 487 + 71 · lint · census `--strict` · lane-wiring · fnxc-dates ·
changesets — green.
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
fb53a96eaa |
test(engine): the lease-seam alarm fired downward — re-point it, and close two ways it could pass without a fix (#2987)
## What happened Both of my source-level audits went red on the advance that landed #2975. They are exact counters, not floors, so this is the alarm working **downward** — the direction it was written for. #2975 converted the two self-healing `shouldHoldActiveFileScopeLease` call sites and closed the 2-of-4 seam that `workflow-file-scope-lease-caller-gap-live-e2e.pg.test.ts` was measuring. I judged the conversion real before updating anything. Both sites now derive their answers from `resolveProjectColumnsForRoles(...)` sets the sweep had already resolved a few lines above (`self-healing.ts:4754`, `:5825`) — trait membership, not a literal. So the numbers moved on purpose. ## The part that is not bookkeeping Re-pointing an audit to whatever the code now says is how a guard goes dead. Both assertions could have been satisfied by something that is **not** a fix, so both were tightened: | Way it could pass without a fix | Old assertion | Now | |---|---|---| | `isWipColumn: true` hardcoded at a self-healing site — the original defect wearing the converted call shape, answering "yes" for a blocker resting anywhere | only checked the key was *absent* | requires resolved-set membership: `/isWipColumn:\s*\w+\.has\(\w+\.column\)/` | | a site answering one of the two independent role questions and not the other | `includes(a) \|\| includes(b)` counted it as converted | `&&` | The scheduler's own two sites *do* pass literal `true`, correctly — they have already filtered to a role-resolved bucket, so there the answer is a fact about the loop, not about the card. The form check is scoped to `self-healing.ts` for that reason. ## Mutation evidence Not reasoned — measured. Each mutant applied to `self-healing.ts`, suite re-run, file restored: | Mutant | Result | |---|---| | baseline | 8 passed | | M1 — hardcode `isWipColumn: true` at one site | **1 failed** | | M2 — drop `isReviewColumn` at one site (half-converted) | **2 failed** | | M3 — revert both sites to pre-#2975 | **2 failed** | M2 is the one that justifies the `&&`: re-running it with the counter reverted to `||` leaves the file **green (4/4 passing)**. The old counter provably could not distinguish a closed seam from a half-closed one — the exact blind spot this file exists to remove. ## Verification `test:gate` exit 0 · live-PG E2E surface **171/171** · lifecycle-column census exit 0 · FNXC date ratchet exit 0 · `pnpm lint` clean. Production files untouched (`git status` clean on `self-healing.ts` after every mutant). ## Scope The other measured seam, `evaluateParkedAgentTaskLink`, is **unchanged at 2-of-6** — four callers still omit the resolved columns, so the class is not closed, only one of its two instances is. That remains characterized, not fixed, in the same file; converting those four is the capacity worker's file, not mine. The call-site facts are still asserted against source text rather than driven through the self-healing sweep, which would need the full dependency-lease reconcile harness. That limit was stated in the original file and still is. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Tests** * Updated end-to-end workflow checks to verify resolved workflow-role values at active lease call sites. * Strengthened assertions for WIP and review column detection. * Updated audit coverage to reflect conversion of all active-file-scope lease callers. * Preserved tracking for the remaining parked-link integration points. <!-- end of auto-generated comment: release notes by coderabbit.ai --> |
||
|
|
a57f6699b3 |
fix(cli): fn task list never printed cards in renamed columns (#2986)
## `fn task list` never printed cards in renamed columns
```ts
for (const col of COLUMNS) { // the legacy six ids
const colTasks = tasks.filter((t) => t.column === col);
```
A task in a workflow-defined column matches **no iteration**, so it is
not printed. This is not a wrong label or a wrong glyph — **the card is
absent**, and the output reads as a shorter, healthy board rather than
as a bug. On a fully renamed board the command prints nothing but the
header. `COLUMNS` also still contains `triage`, which U11 (#2515)
deleted.
## Found where the previous author left it
The `DELIBERATE-LITERAL` note directly above this loop is correct about
its own glyph, and it named the deeper bug rather than hiding it:
> NOT claimed as trait-resolved, and the deeper bug is left alone:
because the loop iterates the legacy enum, a card in a workflow-renamed
column is not rendered AT ALL. That is the R8/U10 surface change […] and
a far bigger fix than this glyph.
It also predicted the coupling: *"If this ever iterates
workflow-resolved columns, that difference becomes live and the right
answer is a trait lookup, not this."* So both move together — once the
loop can yield a custom id, the terminal test **must** stop being an id
comparison. Fixing only the loop would leave a renamed done-lane
rendering as active work.
## Two deliberate choices
**Lanes come from the tasks, not from a resolved IR.** A board can span
several workflows and therefore has no single column list, and a card
must never depend on a resolution succeeding in order to be *visible*.
Legacy ids keep their familiar order and labels; anything else follows
alphabetically, so output is deterministic.
**Terminal lanes are resolved**, via
`resolveProjectColumnsForRoles(TERMINAL_ROLES)` — that is a display
question with a real answer, and this function is async with a store in
hand. Best-effort: a failed resolve falls back to the legacy pair rather
than failing the command, and an unresolved custom lane renders as
*active*. Showing a finished card with the wrong glyph is a far smaller
error than the blank board this replaces.
## Coverage, and its limit stated plainly
The lane-selection decision is extracted to an exported seam and tested
there. It is **not** end-to-end: `runTaskList` resolves a real project
context and ends in `process.exit`, so driving it would need the
mock-the-world shell `docs/testing.md` tells us to avoid when a narrower
seam exists. The call site is held by the compiler instead — the loop's
only source of lanes is that function. I have written this in the test
file rather than leaving it implied, because "5 passed" on a helper
could otherwise read as proof of the command's behaviour.
Reverted — the seam returning `[...COLUMNS]`, which is exactly what the
loop did — **all 5 cases fail**:
```
AssertionError: expected [ 'triage', 'todo', …(4) ] to deeply equal [ 'backlog', 'building', 'checking' ]
AssertionError: expected [ 'triage', 'todo', …(4) ] to deeply equal [ 'todo', 'shipped' ]
Tests 5 failed (5)
```
## Verification (measured)
- **86 passed / 3 files** — new suite plus `bin.test.ts` and
`pr-merge-review-lane.test.ts`
- `tsc --noEmit`, `eslint` — clean
- `lifecycle-column-census --strict`, `check-lane-wiring` (26, none
added), `check-fnxc-future-dates` — green
- `pnpm check:changesets` — clean; changeset included (`patch`), since
`packages/cli` is the published `@runfusion/fusion` and this is
user-facing
|
||
|
|
d541c3154e |
fix(dashboard): retire the dead .agent-detail-overlay CSS (#2915) (#2985)
Closes #2915. `.agent-detail-overlay` has had **no renderer** since FN-8619 moved Agent Detail onto `FloatingWindow`, whose `modal` host owns the scrim (`.floating-window-overlay--modal`). Four CSS sites plus one inert mobile `@media` rule. ## Why this sat unfixed, and what unblocked it I filed this earlier and deliberately did **not** delete the CSS, because two *passing* guards pinned a selector list naming the class: ``` dashboard-overflow-containment.test.tsx:295 mobile-horizontal-pan-containment.test.ts:90 ".modal-overlay:not(.confirm-dialog-overlay),\n .agent-detail-overlay,\n .agent-dialog-overlay,\n .workflow-output-modal-overlay" ``` Neither list mentions `.floating-window-overlay--modal`. If that list were the mechanism keeping modal scrims inside mobile horizontal-pan containment, then FN-8619 moved every migrated modal's scrim out from under the guard and the dead entry was **masking a live defect** — deleting it would have been the wrong move twice over. I said at the time I couldn't settle it without rendering at a phone breakpoint. **That was wrong — it is answerable by reading**, and I only went back because the same mistaken conclusion cost me a day on the Planning coverage in #2982. **The containment lockdown is global**, on the mobile `html, body` block in `styles.css`: ```css @media (max-width: 768px) { html, body { overflow-x: hidden; overscroll-behavior-x: none; touch-action: pan-y; } ``` `FloatingWindow.css` says so itself at its mobile breakpoint — *"Mobile keeps the global `styles.css` pan-y lockdown so the dashboard cannot drift"*. The per-overlay list only reasserts it for overlays that are themselves scroll containers. Migrated modals are covered by the global rule, so **no hole, and no masked defect**. ## Verified the guards still guard something The risk in editing a pinned selector string is turning a real guard into a string-equality formality: | state | result | |---|---| | after this change | 15/15 pass | | global lockdown broken (`touch-action` / `overscroll-behavior-x` removed from `html, body`) | **2 failed / 13 passed** | They fail on the mechanism, not the text. ## Scope note Two of the four `styles.css` sites are **grouped selectors shared with `.agent-dialog-overlay`, which is still live** (`NewAgentDialog.tsx:415`). So this is a selector-list edit, not a block deletion — easy to get wrong in a bulk sweep, which is why it is called out here and in the FNXC note replacing the deleted rule. The retired mobile rule set `padding: 0; align-items: stretch` on the overlay. Not a lost feature: `.floating-window-overlay` is `position: fixed; inset: 0` with no flex context, so those declarations had nothing to act on — FloatingWindow positions the panel by geometry. **Verified:** 120/120 across `agent-modals-mobile`, `core-modals-mobile`, `AgentDetailView.core`, and both containment guards; `tsc -p tsconfig.app.json` 0 errors; lint clean; FNXC gate exit 0. Dead-CSS removal with no behaviour change, so no changeset. Main health while I was here: engine **11475 passed / 0 failed**. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
94df80bbd6 |
test(dashboard): restore the Planning project-switch invariant at its new home (last lane red) (#2982)
Clears the **last red test in the dashboard lane** (measured on `main` at `41af5e5dbd`: `1 failed | 11180 passed`) and restores a leak-class invariant that has had no assertion since FN-8619. ## What was wrong FN-8619 moved Planning out of `MainContent` (its branch returns `null`) into `PlanningKeepAlive`, mounted by `App`. `MainContent.planning-project-remount.test.tsx` kept asserting there, so it could only fail — and nothing asserted the invariant at the new location. **Deleting the project id from `App.tsx:1956`'s key, or loosening the gate at `:1954`, failed no test.** That invariant is not cosmetic. Before the project-keyed host, Planning kept a running plan's stream, selected session and sidebar list from the **previous** project, and persisted its session under the **new** project's storage key. ## I withdrew this test once, and that was my mistake I wrote it earlier, mutated each guard, saw it stay green both times, concluded it was vacuous, and handed the problem back. **That reasoning was wrong.** App defends this invariant **twice, independently**: 1. the `planningEverOpenedProjectId === currentProject.id` gate, which unmounts the host for a project that never opened Planning; and 2. the project id inside the host's `key`, which forces a remount instead of reconciling A's live instance under B. Either alone upholds the contract. So a single-guard mutation *should* leave a correct test green — that is defence in depth working, not a hole. The probe that settles it is breaking **both**: | mutation | result | |---|---| | both guards intact | **pass** | | key only (drop project id) | pass | | gate only (`!== null`) | pass | | **both** | **fail** | I had mutated guards, not the invariant, and mistook redundancy for vacuity. Worth recording because it is the opposite error from the one I have been making all day: I have caught four genuinely vacuous guards by mutation, and that success made "survived a mutation" read as "proves nothing" when the honest reading was "the system has a second defence." ## The assertion, and why it is `gone OR different node` An earlier draft asserted the subtree must be **absent** after the switch. Wrong: `planningViewActive` stays true, so the latch re-arms for the new project and a fresh host is expected. Both outcomes satisfy the real contract — *project A's instance is not reused* — which is what `subtreeForA.isConnected === false` pins. ## Scope - Planning case moves to `App.test.tsx`, which owns both halves of the guarantee. - `MainContent.planning-project-remount.test.tsx` keeps its **Chat** and **Missions** cases — those still render from MainContent — and gains a note saying where Planning went, so it is not re-added there. **Verified:** `App.test.tsx` 143/143, `MainContent.planning-project-remount` 2/2, `tsc -p tsconfig.app.json` 0 errors, lint clean, FNXC gate exit 0. Test-only; no product code touched, no changeset. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
16921fc518 |
fix(engine,core): role resolution was half-done in two shared lifecycle predicates (surfacing family + file-scope leases) (#2975)
The three surfacing sweeps stopped reporting anything for a card resting in a board's **second** review or hold column. A lifecycle role is a **trait**, and any number of columns may carry it. The shared runner resolved it with `resolveLifecycleColumns()[role]` — **first match** — then gated on it: ```ts const roleColumn = lifecycle?.[spec.role]; // FIRST column carrying the trait if (task.column !== resolved.roleColumn) continue; // everything else dropped ``` A workflow that splits human sign-off from the merge lane has two review columns; one that parks dependency-blocked cards separately has two hold columns. Cards in the second got **no stale-paused-todo, no stale-paused-review, no in-review-stalled** diagnostic — silently, with no error, on all three sweeps at once. ## The second bug hiding inside the fix for the first Resolving membership but still reading `roleColumns[0]`'s declared `recovery` applies the **merge lane's** threshold to a card sitting in the **sign-off** lane. Each card's policy now comes from its own column, and one of the new cases fails if it doesn't: the first role column declares a policy that suppresses the signal, the card's own column declares one that fires. ## Reverted | | | |---|---| | **6 of 12** new cases fail | `fires for a card in the SECOND column carrying its role` and `reads the recovery policy of the card's OWN role column` — × 3 sweeps | | the other 6 pass either way | non-regression halves: still fires for the FIRST role column, still does **not** fire for a card outside every role column. Membership must widen the gate, not move it. | The pre-existing 45 cases were all green throughout — the single-role-column fixture could not express the case, which is why the table-driven file that exists to stop these three sweeps drifting apart never caught it. ## Verification `pnpm test:gate` 161 + 13 + 487 + 71 · surfacing family 57 · core stale-paused 20 · lint · census `--strict` · sql-literals · fnxc-dates · lane-wiring · changesets — all green. ## Note `holdColumns` was missing from the lane-wiring vocabulary, so the gate could not see that argument dropped. Added in the same commit. While reviewing, I found and measured **two problems in #2974** (comment posted there): six of its newly-visible sites are `satisfies`-wrapped false positives, and baselining them means deleting a real `reviewColumns` argument keeps the count unchanged and the gate green; and its baseline predates #2970, re-opening the slot that PR closed. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Improved stale-card detection across all applicable review and hold columns. * Cards are now surfaced using the policies configured for their specific lifecycle column. * Cards outside matching lifecycle columns are no longer incorrectly surfaced. * Preserved existing fallback behavior when no lifecycle columns are configured. * **Tests** * Added coverage for workflows with split review and hold columns. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --- ## Second commit: the same predicate, half-converted (`shouldHoldActiveFileScopeLease`) Folded in here rather than stacked — same file, same class, and a stacked PR on an unmerged base is not mergeable. Reversible; say the word and I'll split it. `shouldHoldActiveFileScopeLease` is the **scheduler's** lease predicate, shared with the self-healing repair paths deliberately so the two cannot disagree about who holds a file-scope lease. Its two role answers are optional parameters defaulting to the legacy ids. The scheduler's own call sites were converted to pass resolved answers; self-healing's two were not: ```ts const isWipColumn = options?.isWipColumn ?? task.column === "in-progress"; const isReviewColumn = options?.isReviewColumn ?? task.column === "in-review"; ``` On a renamed board neither branch matches, so the predicate returns `false` for every card. The scheduler kept the lease; self-healing saw none, cleared `overlapBlockedBy`, and **released a dependent to edit files another agent still holds** — the outcome `groupOverlappingFiles` exists to prevent. Membership comes from the wip/review sets each sweep already resolved a few lines above, so this adds no reads. **Reverted:** both new cases fail with `overlapBlockedBy` = `null` — the release itself, not a proxy. The pre-existing legacy-column case in the same file passes either way, because `in-progress` satisfies the literal default; that is exactly why it never caught this. Lane-wiring baseline re-recorded `9 -> 7` in the same commit (the ratchet refused a stale allowance, as intended). **Verification:** gate 161 + 13 + 487 + 71 · surfacing 57 · overlap-seam + scheduler-lease + query-blindness 79 · core stale-paused 20 · lint · census `--strict` · sql-literals · fnxc-dates · changesets — green. |
||
|
|
41af5e5dbd |
fix(gate): the lane-wiring census could not see its own motivating case (#2956) (#2974)
#2966 shipped a gate that **cannot detect the defect named first in its own header.** `findLaneAcceptingFunctions` matched a lane parameter only when `param.type` was a `TypeLiteralNode` — an inline `{ reviewColumns?: … }`. But the real code declares these as interfaces: ```ts export function getInReviewStallReason( task: Pick<Task, …>, context: InReviewStallContext = {}, // TypeReference — invisible ): InReviewStallSignal | undefined ``` so the function never entered `accepting` and none of its call sites were examined. ### Measured, both directions | | before | after | |---|---|---| | lane-accepting functions detected | 20 | **30** | | `getInReviewStallReason` detected | no | **yes** | | re-introduce #2956 (drop `reviewColumns` from one call site) | `none added` — **passes** | **fails**: `reads.ts: 7 unwired now, baseline allows 6` | The gate now catches the thing it was built for. ### The baseline moves 10 → 24, and that number needs context `10 unwired call site(s) across 8 files` → `24 across 15`. **No entry was removed** — every previously-recorded file kept its count and 14 sites became visible for the first time: ``` core/task-store/reads.ts 0 -> 6 engine/self-healing.ts 2 -> 4 core/task-store/branch-and-pr-entities.ts 0 -> 1 core/task-store/task-update.ts 0 -> 1 engine/scheduler.ts 0 -> 1 dashboard/routes/register-task-workflow-routes 0 -> 1 cli/commands/dashboard-tui/bucket-mapping.ts 0 -> 1 cli/extension.ts 0 -> 1 ``` **These are newly VISIBLE, not newly broken** — they have been unwired all along. I have **not** audited them, and recording them in the baseline is not a claim that they are fine; it is the ratchet doing what its header describes, since the census's own note says roughly half of the original hits were legitimately unwired (identity proven by a stronger means, sentinel columns, dead exports). Someone should walk the 14. Two stand out as worth a look first: **`reads.ts` at 6** is the file #2956 was about, and **`scheduler.ts`** is a dispatch path. Flagging rather than fixing, because wiring a call site that should not be wired is its own defect and each needs the judgement call the census header describes. ### Regression test `packages/engine/src/__tests__/lane-wiring-census-named-types.test.ts` pins the detector's shape — named interface, type alias, inline literal, positional — against fixtures rather than live counts, so it does not churn when someone legitimately wires a call site. Plus one anti-vacuity case asserting the named-type arm is still load-bearing on real source (`getInReviewStallReason` resolves in the live tree), so the fixtures cannot pass while the tool has quietly stopped applying here. **Mutation:** removing the `TypeReference` arm fails **4 of 5**. ### Also worth knowing `findLaneAcceptingFunctions` still only visits `ts.isFunctionDeclaration` at top level, so `export const fn = (ctx) => …` remains invisible. I checked — no exported arrow function currently takes a lane argument, so nothing is missed today, and I left it rather than widen the surface in the same change. Resolved by **name across the corpus** instead of a type-checker `Program`: these are plain source scans and a checker would cost a full type-resolution pass for one lookup. Two same-named types merge, which only ever widens what counts as wired — safe for a ratchet. **Verified:** 5/5 new tests, `check-lane-wiring` clean at the new baseline, lint clean, FNXC gate exit 0. Core suite on main is green (4923 passed / 0 failed) — unrelated, but I had it running. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Improved lane-wiring analysis to recognize named interfaces and type aliases. * Added support for wrapped configuration expressions and positional parameters when detecting lane information. * **Tests** * Added comprehensive coverage for lane-wiring detection, including named contexts and live-tree validation. * **Chores** * Updated baseline counts to reflect newly recognized application areas and improved self-healing detection. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
0738fb1c8a |
test(a11y): the aria-label role guard was blind to the role word inside the t() default (#2979)
## What [#2965](https://github.com/../pull/2965) fixed thirteen dialogs whose accessible name restated its role, and shipped a source-scanning guard so the next copy-paste could not sneak back in. Good fix, real ratchet — its own header even documents catching one blind spot by mutation during review. It has a second one. The matcher looks for the role word at the **end** of the `ariaLabel` value, which is where all thirteen had it. One position over is invisible: ```tsx ariaLabel={t("scripts.title", "Scripts dialog")} ``` That renders the accessible name **"Scripts dialog"** — identical symptom, announced as *"Scripts dialog, dialog"* — but the value does not END in the role word, because a `")` closes the call after it. **Measured:** re-introducing this shape into `ScriptsModal` left the shipped suite green at **13/13**. ## Why this shape matters more than the one already covered The rendered label *is* the translator's default string. Whoever writes the next modal naturally puts the word where the title lives, inside `t()`, rather than appending it outside the call. The suffix form is what the original thirteen happened to be; this is the form the fourteenth takes. ## The fix Every quoted literal that is actually rendered is checked with the same matcher, not just the whole value. i18n **keys** are excluded, or the guard fires on `t("agents.onboarding.dialogLabel", "AI Interview")` — live in the tree today, and it announces nothing of the sort. Key-shaped means dotted and whitespace-free, which no real accessible name is. Keeping this narrow is the whole difficulty: a scan that flags every translated title gets deleted within a week. ## Measured | run | result | |---|---| | baseline, unmutated `main` | **18/18 green** — no false positive anywhere in the corpus | | mutation A — role word inside the `t()` default | **1 failed / 17 passed** (was green before) | | mutation B — original trailing-suffix shape | **1 failed / 17 passed** — no regression | Both defect shapes now fail the guard. Five matcher cases added, three of them negative; the negatives are what keep the scan from flagging every translated title. ## Note for the queue **#2946 is fixed on `main` and can be closed** — I verified all thirteen callers are clean there, the one grep hit being that i18n key. This PR is about the guard behind it, not the fix. Third time in this program an instrument has been blind to a case in its own motivating class, now across three tools and three authors. The pattern is not carelessness — each guard was checked against the shapes its author had in mind. Mutation against a shape you did *not* have in mind is the only thing that has caught any of them, which is an argument for making it routine when a ratchet ships rather than when someone gets suspicious later. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
be79fe0db6 |
fix(cli): PR merges silently never ran on a renamed board — the blocker was asked about in-review (#2976)
## PR merges silently never ran on a renamed board
`processPullRequestMergeTask` called its injected blocker with the task
alone:
```ts
if (getTaskMergeBlocker(task)) return "skipped";
```
So `options.reviewColumns` was undefined and the blocker's identity
check fell back to `task.column === "in-review"`. On a board whose merge
lane is named anything else it returns:
```
task is in 'checking', must be in 'in-review'
```
…which is truthy, so this function returns `"skipped"`. **Silently and
permanently** — nothing logs, nothing fails, the PR simply never merges.
`daemon.ts`, `serve.ts` and `dashboard.ts` all drain PR merges through
here, making this a third instance of the #2963/#2964 class ("merge
entry points unwired — merging was impossible on a renamed board").
Found via the baseline #2966 shipped:
`packages/cli/src/commands/task-lifecycle.ts` was a known-unwired call
site in it.
## Narrow resolution, deliberately
`resolveReviewColumns` is the **broad** set, and its own FNXC note warns
that a caller which admits on it *and then moves the card* will act on
cards the engine does not consider in review. This function merges and
moves to the complete lane — a state-changing admission — so it uses
`resolveMergeOrchestrationColumn`, the single lane the engine acts on.
That matches how `moves.ts` wires the same call.
Degradation is unchanged in both directions: `resolveWorkflowIrForTask`
substitutes the default IR rather than throwing, so a default board
resolves `in-review` and behaves identically; a v1-upgraded IR resolves
every role empty and keeps the documented legacy literal (covered by a
test).
## One shape choice worth flagging
The option is always **passed** and conditionally **valued**:
```ts
getTaskMergeBlocker(task, { reviewColumns: mergeLane ? new Set([mergeLane]) : undefined })
```
rather than making the whole argument conditional. These are identical
at runtime — the blocker treats an undefined `reviewColumns` exactly as
it treats absent options — but **only this shape is visible to
`lane-wiring-census.mjs`**, which matches an object-literal argument and
cannot see a ternary. I wrote the ternary first, and the gate still
reported the site as unwired; wiring a gate cannot check is how this
defect survived in the first place.
The gate then confirmed the fix and asked for the baseline in the same
commit:
```
[check-lane-wiring] unwired call sites decreased:
packages/cli/src/commands/task-lifecycle.ts: 1 -> 0
```
Baseline re-recorded 9 → 8 in this commit, so the allowance cannot be
regrown into.
## Revert proof
**There was no test for this function at all** — that is why it went
unnoticed. Restoring only `task-lifecycle.ts`:
```
AssertionError: expected "vi.fn()" to be called with arguments: [ ObjectContaining{…}, …(1) ]
AssertionError: expected 'skipped' not to be 'skipped'
AssertionError: expected "vi.fn()" to be called with arguments: [ ObjectContaining{…}, undefined ]
Tests 3 failed | 1 passed (4)
```
The one case that passes both ways is "still skips a card that is not in
any merge lane" — it guards against over-admission rather than proving
the fix, and I am not claiming it as coverage of the defect.
## Verification (measured)
- new suite **4/4**; with `pr-automerge-cleanup` **9 passed / 2 files**
- `tsc --noEmit`, `eslint` — clean
- `check-lane-wiring` (8, none added), `lifecycle-column-census
--strict`, `check-sql-column-literals`, `check-fnxc-future-dates` —
green
**Changeset added** (`patch`). `packages/cli` is the published
`@runfusion/fusion` and this changes user-facing merge behaviour, so
AGENTS.md requires one. My first pass hedged and left it to a maintainer
— that was wrong, the rule is not discretionary, and it is now in the
branch.
|
||
|
|
7fde4bb3ad |
fix(a11y): my #2965 gave six dialogs two elements with the same accessible name (#2977)
**This fixes a regression I introduced in #2965, found by re-running the full dashboard lane on `main` rather than trusting the targeted runs I did at the time.** `AddNodeModal` and `ConnectNodeModal` are red on main: ``` → Found multiple elements with the text of: Add Node → Found multiple elements with the text of: Connect to Node ``` ### Cause #2965 dropped the redundant `" dialog"` suffix from each `FloatingWindow`'s `ariaLabel`. That was correct — `role="dialog"` already conveys it. What I missed is that six of those modals **also** put an `aria-label` with the *same* text on their own inner `<div>`: ```jsx <FloatingWindow ariaLabel={t("nodes.addNode", "Add Node")} …> <div className="modal modal-md add-node-modal" aria-label={t("nodes.addNode", "Add Node")}> ``` Before #2965 the two differed (`"Add Node dialog"` vs `"Add Node"`), so `getByLabelText("Add Node")` matched exactly one element. Now both match. ### Why the inner one goes, not the dialog's Those inner labels sit on **role-less `<div>`s**, where assistive technology ignores `aria-label` entirely — it was never conveying anything to anyone. Removing it restores a single accessible name per dialog and needs no test changes. ### Surface enumeration — four of the six were latent Only two surfaced as failures; the other four have no test querying by that name, so they would have shipped a duplicate accessible name silently. Found by scanning every component for an inner `aria-label` whose expression matches its own `ariaLabel` prop: | modal | was it red? | |---|---| | `AddNodeModal` | red on main | | `ConnectNodeModal` | red on main | | `GroupTaskModal` | latent | | `NodeDetailModal` | latent | | `ScriptsModal` | latent | | `WorkflowAddStepModal` | latent | ### Five more, deliberately untouched `AgentDetailView`, `PlanningModeModal`, `SettingsModal` (`role="region"`), `ScheduledTasksModal` (`role="listbox"`) and `NewTaskModal` (`role="dialog"`) also carry their dialog's name on an inner element — but those elements **have a role**, so the label is meaningful rather than dead markup. A listbox named "Automations" inside a dialog named "Automations" is redundant, not broken, and renaming it is a UX decision rather than a cleanup. Left alone and recorded here. **Verified:** 93/93 across `AddNodeModal`, `ConnectNodeModal`, `NodesView`, `GroupTaskModal`, `ScriptsModal` and the #2965 aria guard; `tsc -p tsconfig.app.json` 0 errors; lint clean; FNXC gate exit 0. Product-code change to a11y markup, so this is user-visible but needs no operator-facing note — say the word if you want a changeset. **Measured dashboard-lane state on main before this PR:** `3 failed | 11173 passed`. Two are these; the third is `MainContent.planning-project-remount`, which belongs to #2420 and is detailed there. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
2fd798cb36 |
core: every review card reported a false stall on a renamed board (#2970)
**The failure mode worth distinguishing: the rest of this family went
quiet on a renamed board. This one shouted.**
`getInReviewStallReason` satisfied its **own** lane check from
`context.reviewColumns` — then called `getTaskMergeBlocker` **without**
them. That helper re-ran its column-identity check against the literal
`in-review` and returned, for a perfectly healthy card:
```
task is in 'signoff', must be in 'in-review'
```
…which was surfaced as `{ code: "merge-blocker" }`. **Every in-review
card on a renamed board was flagged as stalled**, each citing a lane the
board does not have. That is how a signal stops being read at all.
## A second symptom, found by the revert rather than by reading
On a **genuinely failed** card, the identity message wins over the real
one. The operator saw the bogus column complaint instead of `task is
marked 'failed': merge verification failed`.
So it did not only invent stalls — it **masked the true reason for real
ones**. I would not have noticed that from the diff; it showed up
because the revert run asserted on the reason text.
## Same shape, last one in the family
The outer question was resolved and the inner one was not — the
half-conversion the helper's own comment records for `moves.ts`, and
#2963/#2964 fixed for the merge entry points. This is the last site the
audit turned up where the lane answer was already in scope and simply
not forwarded.
## Revert results
| | reverted → |
| --- | --- |
| the unforwarded call (what ships today) | **2 of 3 fail** — healthy
card reports a merge-blocker stall; failed card reports the wrong reason
|
**Fixture note worth keeping:** `paused` is deliberately *not* the
genuine-stall case. An earlier guard returns `undefined` for a paused
card before the merge blocker is ever consulted, so that case would pass
whether or not the lanes are forwarded — the vacuous shape this series
has produced eight times.
## Verification
`pnpm test:gate` 161 + 487 + 13 + 71; `@fusion/core` full suite **4878
passed** (457 files); `tsc` core clean; lint, lifecycle census
`--strict`, FNXC gate, changesets all clean.
|
||
|
|
df73bbc14a |
fix(tests): clear the persisted Command Center sub-tab between cases (7 of 8 main reds) (#2971)
`main` is red in the dashboard backfill lane. This fixes **7 of the 8** failures. All 8 bisect to #2420 (`4f929acc10`): parent `189f237a07` passes 9/9, that commit fails 5. ### Cause — test-order pollution, not a product bug #2420 made Command Center restore its sub-tab on remount, because the view unmounts on navigation by design: ```ts const [activeTab, setActiveTab] = useState<SubViewId>( () => (getCommandCenterState(projectId)?.activeTab as SubViewId | undefined) ?? "overview", ); ``` The panel's test id is derived from that tab (`data-testid={\`command-center-panel-${activeTab}\`}`), and these files click through to other tabs — `tokens`, `team`, `github`, `system`, `mission-control`. Neither `beforeEach` cleared storage, so the **first** case left `mission-control` persisted and every later case rendered `command-center-panel-mission-control`: ``` → Unable to find an element by: [data-testid="command-center-panel-overview"] ``` Nothing in that message points at a previous test, which is what made it look like a component regression. **Confirmed as ordering rather than breakage:** each failing case passes when run alone with `-t`. The "mutation" here is `main` itself — without the `localStorage.clear()` these files fail 7; with it, 11/11. ### Scope Two lines plus the note explaining why they exist, so the next person who adds a tab-switching case knows the persistence is per-project and sticky. **No product code touched** — the persistence behaviour in #2420 is correct and stays as-is. **Verified:** 11/11 across both files, `tsc -p tsconfig.app.json` 0 errors, lint clean, FNXC gate exit 0. Test-only, no changeset. ### The 8th failure is NOT fixed here, deliberately `MainContent.planning-project-remount.test.tsx` fails because #2420 moved Planning out of `MainContent` (its branch now returns `null`) into `PlanningKeepAlive`, mounted by `App.tsx`. The product side is right — the host **is** keyed (`App.tsx:1956`): ```jsx <PlanningKeepAlive key={`${currentProject.id}:${modalManager.planningEntryGeneration}`} … /> ``` so project switches still remount and I found **no cross-project leak**. But that test was `FNXC:ProjectSwitchModalReset` coverage for a leak-class invariant (Planning carrying a previous project's stream/session), and **nothing asserts it at the new location**: the keep-alive test covers navigation reveal, not project switching, and `App.test.tsx` has no test for the host key. Deleting that `key=` today would fail no test. Restoring it needs an App-level test, which is a bigger change than this fix and worth keeping separate. Detailed on #2420. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
126cee7e6d |
engine: finalization parked ALREADY-MERGED work as failed on a renamed board (#2964)
**The worst symptom in this family: the branch landed, and the board says the task failed.** `project-engine`'s merge-confirmed finalization spread the task's **real** column into `getTaskHardMergeBlocker` with no `reviewColumns`, so the identity check ran against the literal `in-review`. On a renamed board it returned `task is in 'signoff', must be in 'in-review'`, and the caller parked the card: ``` status: "failed" error: "Merge confirmed but finalization blocked: task is in 'signoff', must be in 'in-review'" ``` For work that had already merged. ## Its sibling had already solved this `auto-merge-finalization.ts` passes the **review-eligible sentinel** instead of the card's own column, with the reasoning recorded at that site: `getTaskHardMergeBlocker` asks *"is this card blocked by anything other than where it sits?"*, and its callers are recovery paths for landed work that a graph crash can leave resting in any column. `project-engine` simply never got the same treatment. ## One name instead of two spellings Rather than write the sentinel a second time, it is exported once as `REVIEW_ELIGIBLE_SENTINEL_COLUMN` next to the helper whose contract gives it meaning, and both recovery paths use it. **Two sites independently spelling a magic value is how one of them came to be missing it** — that is the actual root cause here, not the literal itself. This also answers the census, which flagged the new literal — correctly. Its guidance (which I wrote, in #2909) is to hoist a deliberate literal into a *declaration*, where a `DELIBERATE-LITERAL` marker actually attaches, instead of leaving it mid-expression where the marker is silently ignored. The shared constant is exactly that, and it lowers `auto-merge-finalization`'s literal count too. ## Revert result | | reverted → | | --- | --- | | sentinel replaced by the card's own renamed column | reproduces the shipped string | The middle test asserts that string deliberately — it is what landed in `task.error`, so a regression reports what the operator would actually have seen. A third case checks the sentinel does **not** suppress genuine blockers: incomplete steps still block finalization in any lane. These drive the helper directly; reaching `project-engine`'s finalization end to end needs a live engine, a merge run and a real repo, while the defect is entirely in *what the blocker is asked*. ## Verification `pnpm test:gate` 161 + 487 + 13 + 71; `project-engine` + `auto-merge-finalization` + the new suite, 207; `tsc` clean on core and engine; lint, census `--strict`, FNXC gate, changesets all clean. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Fixed merge-confirmed tasks being finalized correctly when boards use renamed workflow columns. * Prevented already-merged tasks from being incorrectly marked as failed due to custom review-column names. * Preserved enforcement of genuine incomplete-step blockers. * **Tests** * Added coverage for finalization on renamed lanes and legitimate merge blockers. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
8e0219d573 |
fix(merger-ai): gate the no-commits dep-sync skip on the branch diff — the P1 #2501 shipped without (#2958)
## #2501 merged without its P1 fix; this is that fix, alone #2501 has landed. Its review threads were resolved — I judged and fixed them — but its head was a fork branch I could not push to, so **the fixes were never in it**. Confirmed on `main` at `c1c1b964af`: ``` merger-ai.ts:902: if (ctx.noCommitsExpected === true) { ← bare flag, no diff gate merge-dependency-sync.ts: export const LOCKFILE_CANDIDATES → 0 matches ``` Rebasing dropped this PR's five duplicated base commits, so it is now **one commit**: the review fix and its regression. ## The defect on main The dep-sync skip trusts `ctx.noCommitsExpected` alone, and **only ever runs on a branch that has commits** — the `rev-list --count` short-circuit ~50 lines above returns `outcome: "empty"` at zero ahead, so control reaches it only when the branch is AHEAD. Nothing revalidates the flag. Both downstream empty-lane guards carve no-commits tasks out explicitly — `merger-ai.ts:1372` (#2259 already-landed proof) and `:1994` (FN-8141 executor veto) — and both guard the *opposite* direction: commit-expected task, empty branch. The inverse has no check. So a task marked no-commits whose executor committed a manifest or lockfile change gets its dependency install **and** its frozen-lockfile validation skipped, and the change lands unvalidated. ## The fix The flag says *look*; the branch diff decides. A `main...branch` diff touching `package.json` or any `LOCKFILE_CANDIDATES` entry falls through to the normal sync and emits an audit row with `skipOverridden: true`. An unreadable diff **also** syncs — matching the hard-fail contract documented directly above that block, rather than treating absence of evidence as evidence of safety. `LOCKFILE_CANDIDATES` is exported instead of duplicated, so the skip and the installer cannot drift on what counts as a dependency change. **Mutation-verified:** reverting to trust-the-flag fails exactly the new case and nothing else. The existing *"lands successfully with noCommitsExpected: true and actual changes"* case is untouched and still passes — `feature.txt` is not a dependency file, so an ordinary source change on a no-commits task still skips. The new case differs only in *which* file the branch touches. ## Also carried over from the #2501 review **coderabbit's env nit** — `process.env.X = undefined` stores the string `"undefined"`, leaving a previously-absent var truthy and leaking into later tests. `restoreEnv` applied at both sites. **Both entry paths** — deferred with reasons: `runAiMerge`/`landWorkspaceTask` sit behind real worktrees, sessions and a merge agent, and the cheap version is a mirrored-implementation test that cannot fail on a revert (this repo has deleted two of those). The fix above also means propagation is no longer the only thing between a stale flag and an unvalidated lockfile. ## A correction to my own work My first version of the regression committed the lockfile while the fixture had left the tree on `main`, so the `main...branch` diff could not see it and the case **passed for the wrong reason**. Corrected, with the reason recorded in the test. ## Verification - `merger-ai-no-commits-deps-skip` — **5/5**, mutation-verified - `merge-dependency-sync-lockfile-heal` — **10/10** - engine typecheck — clean - `pnpm lint` — clean 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
04159ff9ed |
fix(tests): three CSS-shape cases pinned modal DOM the FloatingWindow migration retired (#2967)
Two cases in `agent-modals-mobile` asserted DOM shapes that the FN-8619
`FloatingWindow` migration deliberately retired. Both are **stale
expectations, not product regressions** — established below rather than
assumed, because "delete the failing assertion" is exactly how a real
bug gets buried.
### 1. `AgentDetailView` — the suite asserted both sides of the same
fact
```js
expect(document.querySelector(".agent-detail-overlay")).toBeTruthy(); // here — RED
```
```js
expect(document.querySelector(".agent-detail-overlay")).toBeNull(); // AgentDetailView.core.test.tsx:97 — GREEN
```
No component renders that class (`grep` across `app/**/*.tsx` outside
tests: zero hits). One of these two had to be red, and the one matching
the product is the `toBeNull` sibling. This case now asserts the panel
class it is actually named for — `.agent-detail-modal`, which **is**
live and **is** what the mobile `@media` block in `AgentDetailView.css`
targets — and pins the scrim as still-retired.
I did **not** re-point the overlay half at `.floating-window-overlay`.
That would only re-assert FloatingWindow's own contract (already covered
by `FloatingWindow.test.tsx`) while saying nothing about Agent Detail
being mobile-targetable.
### 2. `AgentGenerationModal` — the class belongs to a different
component
It demanded `.agent-dialog-overlay`. That class is **still live** —
`NewAgentDialog.tsx:415` renders it — which is why the stale expectation
looked plausible and survived. But this modal is a `FloatingWindow` with
`modal` (`AgentGenerationModal.tsx:162`), so its scrim is
`.floating-window-overlay--modal`. Now asserted explicitly, because "a
modal blocks the app beneath it" is a real FN-8619 contract worth
pinning.
### Why the dead CSS is still here
`.agent-detail-overlay` has 4 CSS definitions and one inert mobile
`@media` rule. I deliberately did **not** delete them in this PR:
- Two **passing** tests (`dashboard-overflow-containment.test.tsx:295`,
`mobile-horizontal-pan-containment.test.ts:90`) pin a selector *string*
that lists `.agent-detail-overlay`. Deleting the CSS turns two green
tests red.
- That raises a question I cannot answer without rendering, and I will
not boot an instance to find out: **those containment lists do not
mention `.floating-window-overlay--modal`.** If mobile horizontal-pan
containment is meant to cover modal scrims, the migration may have moved
the scrim out from under its guard. That is a product question for
whoever owns FN-8619, and it is the substance of #2915.
Worth recording: the retired `.agent-detail-overlay { padding: 0;
align-items: stretch }` mobile rule is not a lost feature.
`.floating-window-overlay` is `position: fixed; inset: 0` with no flex
context, so those declarations have nothing to act on — FloatingWindow
positions the panel by geometry instead.
**Verified:** 22/22 in this file, `tsc -p tsconfig.app.json` 0 errors,
lint clean, FNXC gate exit 0. Test-only, no changeset.
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
2502878166 |
fix(a11y): 13 dialogs announced their role twice ("Settings dialog, dialog") (#2965)
Thirteen modals set an `aria-label` that restates the role they already
carry. `FloatingWindow` renders `role="dialog"` and
`aria-label={ariaLabel}` on the **same element**
(`FloatingWindow.tsx:628` and `:631`), so `"Settings dialog"` is
announced as **"Settings dialog, dialog."**
Fixed at all 13 call sites, plus a guard so the next copy-paste fails
instead of shipping.
### Two independent lines of evidence
I found this by inspection. Then, chasing unexplained dashboard
failures, I hit `NodesView.test.tsx`:
```
Unable to find an accessible element with the role "dialog" and name "Add Node"
...
Name "Add Node dialog":
```
Two tests were already asserting the **correct** name and failing
because the product had drifted to add the suffix. So this is not a
style preference — it is a defect with pre-existing tests that were red.
**Those 2 failures go green here**, and they were among the ones I had
not yet accounted for.
### The guard was vacuous, and mutation is the only reason I know
My first version used one regex with `[^`"']*?` for the label body. It
passed. It was worthless.
Every real call site interpolates a translator call:
```jsx
ariaLabel={`${t("scripts.title", "Scripts")} dialog`}
```
Those inner **double quotes terminate the character class**, so the
pattern matched **none of the thirteen offenders**. It only matched
hand-written samples like ``{`Settings dialog`}`` that happen to contain
no quotes — which is exactly what I had put in the case table. Re-adding
the suffix to `ScriptsModal` in its original form left the suite
**green**.
Extraction is now structural (brace matching), and the case table
carries the real quote-bearing shapes, including the nested-brace
`NodeDetailModal` form and the `+ " dialog"` concatenation variant.
**Mutation now behaves:**
| state | result |
|---|---|
| clean tree | 13/13 pass |
| suffix re-added to `ScriptsModal` (faithful form) | **fails**, naming
the file and the offending value |
I would have shipped a guard that could not fail on the defect it was
written for. It is the same error the guard exists to prevent — a cheap
proxy standing in for the real measurement — so the reasoning is
recorded in the file rather than quietly fixed.
### Verified
- `NodesView` + `FloatingWindow` + the new guard: **110/110**, then
**13/13** for the guard after the rewrite
- `tsc -p tsconfig.app.json`: **0 errors** · lint clean · FNXC gate exit
0
- **No i18n key or default string changed** — all 15 `t()` keys
byte-identical across the diff; only the literal outside the call was
dropped, and the now-pointless `` {`${…}`} `` wrappers were unwrapped
### Not fixed here
`agent-modals-mobile` (2) and `core-modals-mobile` (1) still fail on
this branch — they fail identically on `main`, are CSS-structure
assertions unrelated to aria naming, and belong to the #2915
dead-`.agent-detail-overlay` family. Left alone deliberately rather than
bundled in.
No changeset: user-visible a11y correction with no API or setting
change, and the release-notes audience is operators. Say the word if you
want one.
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
c1c1b964af |
fix(dashboard): expose full permission-mapped task toolset in chat sessions (#2376)
## Bug Chat-session tool surface missing task-mutation tools that exist outside of chat, even when the agent's permission record grants them.\n\nRepro: agent-09dcf8b2 (role: custom, CEO) in NextGenEHS has tasks:archive / tasks:delete / tasks:merge / tasks:retry / tasks:update true in its permission record with permissionPolicy.presetId = unrestricted and task_agent_mutation = allow. Calling fn_task_archive / fn_task_delete / fn_task_merge in chat returns: Tool fn_task_* not found.\n\nRoot cause: packages/dashboard/src/chat.ts createChatFusionToolset() built a hardcoded narrow chat-only allowlist while heartbeat registered the complete lifecycle surface unconditionally.\n\nFix:\n- Add exported factories in packages/engine/src/agent-tools.ts for missing lifecycle tools: fn_task_archive, fn_task_unarchive, fn_task_delete, fn_task_retry, fn_task_pause, fn_task_unpause, fn_task_duplicate, fn_task_merge, fn_task_update, fn_task_add_dep, fn_task_promote, fn_trait_list, fn_ask_question, fn_reflect_on_performance, fn_read_evaluations, fn_update_identity, fn_send_message, fn_read_messages.\n- Wire those factories into createChatFusionToolset(). Mission/ideation mutations stay behind missionMutationGated. Agent-scoped tools still require agentId.\n- Re-export from packages/engine/src/index.ts.\n- Regression test: packages/dashboard/src/__tests__/chat-toolset-permissions.test.ts (3/3 passing). Existing chat.test.ts (14/14 passing). <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Chat now exposes task lifecycle actions—including archive, retry, pause, duplicate, merge, and dependency updates—when permitted by the agent’s action controls. - Added support for identity updates and evaluation viewing in agent-linked chats. - Existing read-only tools remain available, while restricted actions stay hidden when authorization is unavailable. - **Tests** - Added regression coverage for authorized and unauthorized chat tool surfaces, including preservation of read-only capabilities. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: gsxdsm <gsxdsm@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
dd930c8d7d |
fix(cli): qualify cross-fork PR heads (#2377)
## Summary - resolve the repository receiving pushes through `git remote get-url --push origin` - qualify pull-request head branches with the fork owner when the push owner differs from upstream - preserve the existing unqualified head for same-repository workflows ## Root cause Fusion correctly resolved the PR target from origin's fetch URL, but assumed the pushed branch lived in that same repository. With an upstream fetch URL and a fork push URL, GitHub requires `fork-owner:branch`; the unqualified branch is rejected. ## Validation - CLI task lifecycle tests: 48 passed - `@fusion/core` typecheck - `@runfusion/fusion` typecheck - strict changeset validation <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Pull requests created from branches pushed to contributor forks now correctly qualify the PR head with the fork owner when the push remote differs from the upstream owner. * Improved PR head handling across both group/shared-branch and per-task pull request creation paths. * **Tests** * Updated and expanded lifecycle tests to cover “origin push to fork” scenarios using push URL–based repo resolution. * **Documentation** * Added a patch release note for the fork-aware PR head fix. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: v <v@v.speedport.ip> Co-authored-by: gsxdsm <gsxdsm@users.noreply.github.com> |