aedee4b8231bf050c3240a00ab6645ede5d87ee9
15 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
9c83812351 |
fix(ci): unblock check:lane-wiring for the task-recommendations read
check:lane-wiring runs in CI's Lint gate and has been failing on main since FN-9037 landed `listTaskRecommendations`, so it blocks every PR in the repo, not just the one that hit it. Recorded rather than rewired, because this is the false-positive shape the escape hatch exists for. The guard catches a callee silently falling back to a LEGACY COLUMN LITERAL when a caller omits the lane; `listTaskRecommendationsImpl` falls back to `resolveProjectColumnsForRoles(store, ["complete"])`, which reads the board's own lanes. Real callers already pass a resolved set — the dashboard route resolves `completeColumns` before calling — so the fallback only serves the pass-through wrapper. Resolving again in the wrapper would duplicate that query on every call for no behavioural difference. The reason lives at the call site as well as in the baseline, since a bare count in a JSON file is exactly the kind of entry that later reads as unexplained debt. Baseline diff verified to be a single added entry (store.ts: 1) — nothing else raised or lowered. Verified: check:lane-wiring exits 0, core typecheck clean, eslint clean, FNXC date check clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
cb57093d03 |
refactor: domain folder layout (types, API, core, engine) (#2398)
## Summary Wave 17 organizes Fusion into **domain folders** (stacks on #2397). ### Layout - **core/types/** — board, task, agents, settings, merge, workflow, mesh, … - **core/src/** — agents, ai, async-stores, workflows, tasks, config, db, … - **dashboard/app/api/** — client, tasks, agents, git, missions, planning, … - **engine/src/** — agents, auth, execution, merge, missions, overseer, worktree, … Root keepers retained for large entrypoints (`store.ts`, `executor.ts`, `merger.ts`, …). Public barrels (`@fusion/core`, `@fusion/engine`, `app/api.ts` → legacy) stay stable. ## Test plan - [x] `@fusion/core` typecheck - [x] `@fusion/engine` typecheck (pre-existing playwright-core noise only) - [ ] CI merge gate **Stack:** #2394 → #2397 → **this PR** |
||
|
|
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 --> |
||
|
|
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> |
||
|
|
5897d87e95 |
fix(gate): the lane census judged a call against a signature it never had (the false positive #3013's merge introduced) (#3021)
#3013's merge of same-named declarations fixed a false **negative** and introduced a false **positive**. `ModelSelectorTab` declares its own two-parameter `resolveEffectiveExecutor(task, settings)` — a pass-through with nothing lane-related — while an unrelated exported function of the same name in `effective-model-resolution.ts` takes `columnFlags`. Both local calls were reported unwired against a signature they have never had. Only **exported** declarations enter the accepting map, so the rule is exact: if the calling file declares the name itself and the map entry came from a different file, the call resolves to the local declaration and is not a lane call here. ## The version I did not ship My first attempt re-ran the detector over the single calling file and used that result. It scored *better* on this tree — **19 → 16** instead of 19 → 17, also clearing `bucket-mapping.ts` — and I threw it away. A single-file pass cannot resolve an **imported** options interface. A locally-declared function with an imported context type would quietly stop being lane-accepting, and every call to it would stop being checked. That is a false negative, which is the one failure a ratchet must not have; the better-looking number came from the gate seeing less. The global pass still does all type resolution here — only the *choice* of declaration is local. ## Measured | | | |---|---| | new tests | 3 | | against the old census | **1 of 3 fails** — the positive | | baseline | **19 → 17**, exactly the two `ModelSelectorTab` sites | Both negatives pass either way and they are the ones that matter: a file declaring its **own** exported lane function is not shadowed by itself, and a file declaring nothing is judged normally. Shadowing must not become a way to disappear a genuine unwired call. ## Still flagged, honestly `bucket-mapping.ts:75` stays in the baseline. `bucketForTask(task: TaskItem)` is only lane-accepting because `TaskItem` *declares* `columnFlags` — the lane data rides on the domain object, so passing `task` forwards it inherently. That is a different limitation (options-bag vs domain-entity parameters) and I have not tried to fix it here; it accounts for 2 of the remaining 17 along with `otherBucketSecondaryLabel`. ## Verification `node --test scripts/__tests__/check-lane-wiring.test.mjs` **19 passed** · `pnpm test:gate` 13 + 161 + 487 + 71 · lint · lifecycle census `--strict` · lane-wiring · fnxc-dates (TZ=UTC) · changesets — green. |
||
|
|
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.
|
||
|
|
ffe9898710 |
fix(gate): the lane-wiring census could not see three of the shapes it asks for (incl. a false positive it started reporting) (#3013)
**The gate could not see three of the shapes it exists to check** — including the wiring I landed this week in #2990 and #3004. Found by using it: the baseline still listed `useBlockerFanout.ts`, `ExecutorStatusBar.tsx`, and `TaskDetailModal.tsx` as unwired *after* those PRs merged. ### 1. Conditional shapes Passing lanes only when they resolved is the **correct** way to write these — an empty trait index means "not loaded yet", not "nothing is terminal", so the caller must fall through to the documented legacy default rather than fabricate one. Both idioms that produces were invisible: ```ts computeBlockerFanoutMap(tasks, flags ? { columnFlagsByTaskId: flags } : {}) // ConditionalExpression computeBlockerFanoutMapCore(tasks, N, { ...(flags ? { classify } : {}) }) // SpreadAssignment, name === undefined ``` Either branch supplying the lane now counts. **Neither branch supplying it is still unwired** — that negative is tested. ### 2. Vocabulary `columnFlagsByTaskId`, the per-task trait index the dashboard threads, was never added. It answers every lane question at once, so a call site dropping it reverts to the legacy vocabulary wholesale — and the gate would have stayed silent. ### 3. Name collisions — the false positive Declarations were `set` by name, so the **last one parsed won**. Core's `computeBlockerFanoutMap(tasks, n, opts)` and the dashboard wrapper `computeBlockerFanoutMap(tasks, opts)` put their lane options at **different argument indices**, so core's callers were checked against the wrapper's signature: `task-priority.ts:141` passes `terminalColumns` at index 2 and was reported unwired. I caught this because adding the vocabulary entry in (2) made it appear. A ratchet that reports a correctly-wired site is worse than one that misses it — the first person to open one learns the number is noise. Both shapes are now merged; a call satisfying either counts. ### Measured | | | |---|---| | new tests | **6** — every positive paired with its negative | | against the old census | **3 of 6 fail** — exactly the three positives; the negatives pass either way, which is why they exist | | baseline | **23 → 19** — four sites recognized as *already* wired; no site newly excused | | new flags | none | ### Verification `node --test scripts/__tests__/check-lane-wiring.test.mjs` **16 passed** · `pnpm test:gate` 161 + 13 + 487 + 71 · lint · lifecycle census `--strict` · fnxc-dates (TZ=UTC) · changesets — green. Seven other `scripts/__tests__` files fail on main independently of this change (`verify-fast`, `dependency-security-floor`, `engine-vitest-gate-policy`, `plugin-authoring-docs`, `release-prompt-gate`, `ci-test-shard-timings`, `workflow-reliability-release-check`). None are in the merge gate and none are touched here — noting them because I looked, not because this PR affects them. |
||
|
|
cfe47b3754 |
chore(plugins): delete the superseded fusion-plugin-even-cards (#2790) (#2988)
Closes #2790 by finishing a decision that was already made and written down. ## The issue's premise was wrong, including as I filed it I raised this as "a package accidentally missing from `pnpm-workspace.yaml`." It wasn't. `CHANGELOG-archive.md:9596`: > Consolidate Even Realities plugin support into `fusion-plugin-even-realities-glasses` and **remove `fusion-plugin-even-cards` from the active workspace package list to avoid duplicate user-facing integrations.** The removal was deliberate, for a stated reason. The directory is what got left behind. That also rules out the option I had been weighting first — adding it back would undo a shipped consolidation and re-create the duplicate integration it was removed to prevent. ## Unreachable by every path | check | result | |---|---| | `pnpm-workspace.yaml` globs | no — never installed or built | | CLI bundle list (`packages/cli/tsup.config.ts`) | no — 0 mentions, while seven other plugins are named | | runtime `plugins/*` directory-scan discovery | none exists — plugins are enumerated explicitly | | `package.json` | `private: true` — never published | | imports outside its own directory | none | | kept as a docs/authoring example | no — zero references in `docs/` or any root `*.md` | | successor in the workspace | yes — `fusion-plugin-even-realities-glasses` | ## It was also polluting two ratchets Dead code in a scanned tree is worse than dead code: both censuses are **source-text scanners**, so they counted debt in files no build or typecheck covers. Nobody could retire those entries through a normally-verified refactor, and they inflated how much of the remaining debt looked actionable. Both baselines regenerated, and I checked each diff rather than trusting the totals: | baseline | change | |---|---| | `lane-wiring` | 26 → 23 sites, 21 → 20 files — **one entry removed**, `board-routes.ts: 3` | | `lifecycle-column-census` | exactly its two `board-cards.ts` entries | Neither regeneration tightened anything unrelated — worth confirming explicitly, because `lifecycle-column-census.mjs --strict` **writes** its baseline as a side effect and could have folded an unrelated drop into this commit. **Verified:** lane-wiring, SQL-literal and FNXC gates all exit 0 after the deletion; lint clean. 15 files removed. ## Why I went ahead I said twice I would not delete this unilaterally. What changed is that the trade-off dissolved — once the consolidation decision turned out to be documented and the "is it a teaching example?" question answered by a docs grep, there was nothing left to decide, only to execute. The deletion is git-reversible and the standing guidance is that reversible calls are mine to make. Fourth time today a thing I filed as "needs someone else's judgement" turned out to have its answer already in the repository. Cheap habit worth keeping: before deferring, grep for whether the judgement has already been made. 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. |
||
|
|
968af0822c |
gate: the lane-wiring ratchet did not scan plugins, dashboard/app, or any .tsx (#2978)
## The new gate re-opened a blind spot the old one had already learned about `check-lane-wiring.mjs` (#2966) scanned four roots and only `.ts`: ```js const ROOTS = ["packages/core/src", "packages/engine/src", "packages/dashboard/src", "packages/cli/src"]; ``` `unwired-lane-parameter-guard.test.ts` scans **six**, including `packages/dashboard/app` and `plugins`, and its FNXC note records exactly why: > `plugins` is scanned, and its absence was half of a real escape. […] an unwired `completeColumnsByTaskId` sat on `main` unreported: the guard found 0 across 1753 files, and 0 again across 2114 once plugins were added, because the shape was invisible too. **Fixing either alone would still have missed it.** That is the same trap here, and it needed **two** changes. Those trees are overwhelmingly `.tsx`, which the file filter excluded — so adding the roots without the extension would have scanned a handful of files and reported a reassuring near-zero. ## What the widened scan found: 10 sites, in 8 files, audited not blind-baselined | site | verdict | | --- | --- | | `dependency-graph/GraphTaskNode.tsx` (`isTaskStuck`) | **real** — `isTaskStuck` takes an optional 4th `columnFlags`; omitted, `isWipColumnRole` falls back to the literal, so **no card on a renamed board is ever shown stuck** in the graph | | `dashboard/app/Lane.tsx`, `ListView.tsx` (`sortTasksForDisplayColumn`) | **real**, dashboard batch | | `dashboard/app/ModelSelectorTab.tsx` ×2 (`resolveEffectiveExecutor`/`Validator`) | **real**, dashboard batch | | `dashboard/app/TaskDetailModal.tsx` (`isNearDuplicateCanonicalInactive`) | **real**, dashboard batch | | `even-cards/routes/board-routes.ts` ×3 (`boardToDeck`) | **cannot be fixed in place** — deprecated plugin depending on `@fusion/plugin-sdk` alone, with no resolution source | | `even-realities-glasses/routes/board-routes.ts:141` (`boardToDeck`) | **harmless by construction** — the `{ maxCards: 1 }` summary call slices `active` to empty, so `terminalColumns` cannot change its output; documented in `cards.ts` | They are baselined rather than fixed because they span three other batches. I did **not** fix the graph one despite it being my area: wiring it needs the plugin prop contract to carry column flags, and the plugin's own `dashboard-interop.d.ts` declares `isTaskStuck` with only three parameters — so it crosses the dashboard↔plugin API boundary rather than being a local change. ## Merge-order hazard, stated precisely A **decrease** also exits 1 (`process.exit(1)` on the `decreased` branch), and #2976 wires `packages/cli/src/commands/task-lifecycle.ts`, which is present in this baseline. **If #2976 lands after this PR, main's gate goes red** until the baseline is re-recorded. It fails loudly rather than silently, so it is a chore not a risk. Merging #2976 first and letting me re-record here is the cleanest order — say the word and I will push the re-record. ## Verification (measured) - `check-lane-wiring` — green, **19 known / none added** (was 9 across 4 roots) - `unwired-lane-parameter-guard.test.ts` — **9 passed**, the older guard is unaffected - `lifecycle-column-census --strict`, `check-sql-column-literals`, `check-fnxc-future-dates` — green Gate/tooling only; no product file is touched. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Tests** * Expanded lane-wiring checks to cover dashboard and plugin code. * Added support for scanning `.tsx` files while excluding declarations, tests, specs, and ignored directories. * Updated baseline coverage counts for the additional files. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
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.
|
||
|
|
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.
|
||
|
|
19deb42170 |
gate: ratchet call sites that never receive the lane answer (#2966)
**This is the gap that let three defects reach `main` in one day.** `unwired-lane-parameter.mjs` catches a parameter that reaches **no** caller. It is deliberately satisfied by a mention *anywhere*, so **partial** wiring is invisible to it: | | | | --- | --- | | #2956 | `getInReviewStallReason` wired at **0 of 4** call sites while both siblings were wired | | #2963 | both merge entry points unwired — merging was **impossible** on a renamed board | | #2964 | merge-confirmed finalization unwired — **already-landed work parked `failed`** | Every one was a fix that added an optional parameter without the call-site sweep that has to follow it. The existing guard was green throughout, correctly by its own contract. ## A census, not a guard — and that distinction is the whole design Auditing the sites this finds showed **four of seven were legitimately unwired**: `skipColumnIdentityCheck` callers have already proven lane identity by a stronger means, a sentinel-column caller wants the identity check satisfied by construction, and a dead export has no caller to wire at all. A check that failed on those is ~57% false positives. The sibling guard's own header says why that is worse than a miss — *"it teaches people to disable the check"* — and I agree, so this does not do it. Instead it ratchets like the lifecycle census: **36 known unwired sites across 20 files**, allowed to shrink and not to grow. A new unwired caller raises the count and fails; wiring one lowers it and re-records. The recurrence — adding a caller that forgets the lane answer — is precisely what gets caught, and the legitimate sites cost one baseline line each instead of a permanently red gate. ## Detection is AST-based, deliberately It finds exported functions accepting a lane-named argument — directly *or* as an options-bag member — then finds call sites passing none of them. Not regex: the ad-hoc scan I used during the audit produced false negatives on multi-line calls, which is exactly how a caller gets missed in the first place. Using a heuristic to police a defect caused by a heuristic seemed like a poor trade. ## Verified to fail on the recurrence A ratchet that cannot fail is worse than none, so this was measured rather than assumed. Injecting one new unwired caller into `self-healing.ts`: ``` [check-lane-wiring] call sites not passing a resolved lane argument INCREASED: packages/engine/src/self-healing.ts: 9 unwired now, baseline allows 8 ``` exit 1, naming the file and the delta. ## Placement Runs as a named `check:lane-wiring` step in `pr-checks.yml` beside the lifecycle, SQL, inert-seam and FNXC ratchets — same convention, same failure ergonomics, ~1s. Note the baseline records today's state, which still includes the #2963/#2964 sites because those fixes have not merged yet. When they land the count drops and the baseline is re-recorded downward — the ratchet working as intended rather than a conflict. ## Verification `pnpm test:gate` 161 + 487 + 13 + 71; `tsc` engine clean; lint, lifecycle census `--strict`, FNXC gate, and the new check all clean. |