Files
fusion/package.json
gsxdsm 7871b28766 fix(core): bind the in-transaction capacity gate — one shared pool-id convention (NOT user-visible yet — see R2) (#2488)
## The bug

`moves.ts` asked `countActiveInCapacitySlotAsync` for occupants of pool
`"builtin:coding"`, while the counter buckets selection-less rows under
`DEFAULT_WORKFLOW_POOL_ID` (`"__default-workflow__"`). Nothing ever
landed in the pool being asked about, so the count came back **0** and a
finite limit could never bind.

## Root fix, not a literal swap

A shared *constant* would not have prevented this:
**`DEFAULT_WORKFLOW_ID` was already imported in `moves.ts` and the code
still wrote a literal.** So both sides now call a shared **function**,
`resolveCapacityPoolId` — "which pool does a selection-less task belong
to" has exactly one answer and no call site is in a position to disagree
with it.

The one variable serving two masters is split: a capacity **pool key**
(a bucketing sentinel that must not collide with a workflow id) and a
**workflow id** (telemetry, must stay a real id). The emitted
`TaskTransitioned` payload is byte-identical.

## Checked, not assumed: no second copy

`scheduler.ts:2514` and `:2536` do carry `?? "builtin:coding"` — but as
an **IR resolution key** (`resolveWorkflowIrById`), where a real
workflow id is required and the pool sentinel would not resolve at all.
Same literal, different concept, correctly used. A blanket replace would
have broken it.

## Something did depend on the gate being dead — exactly one thing

`move-path-equivalence.pg.test.ts` → *"UNPROVEN: in-transaction column
capacity did NOT reject on EITHER path in this fixture"*. It left the
cause open —

> something further in (`resolveColumnCapacity`'s limit resolution, or
what `countActiveInCapacitySlotAsync` counts as an occupant — a task
with no session/agent may not count) keeps the check from firing … This
suite does not establish which.

— and predicted its own obsolescence (*"if a future change makes this
reject, that is the capacity gate coming alive"*). **Neither guess was
right; it was the pool id.** Updated to assert the divergence with the
answer recorded — **not weakened**. Its fixture also had to start each
phase from an empty wip column: once the gate binds, the inline phase's
leftovers trip the cap on the *holder* move before the contended move
under test runs.

`schema-applier.test.ts` failed only in the full-suite run and passes in
isolation both with and without the fix — cross-file contamination, not
mine.

## Before / after — measured, both directions

`maxConcurrent: 1`, real PG store, real `moveTask`:

| | flagOFF / no selection | flagOFF / selection | flagON / no selection
| flagON / selection |
|---|---|---|---|---|
| **before** | ADMITTED | ADMITTED | **ADMITTED** ← the bug | REJECTED |
| **after** | ADMITTED | ADMITTED | **REJECTED** | REJECTED |

The E2E acceptance row asserts **held at cap 1 and admitted at cap 2 on
the same fixture**, so it cannot pass by simply never admitting
anything. **With the fix reverted that row fails**; the `admitted` case
still passes, as it should. The Phase A3 ratchet's two flipped
assertions also fail with the fix reverted.

Ratchet flipped exactly as its author specified: `DEFECT (R1)` becomes a
rejection, and `it.fails` on the invariant becomes a plain `it`.

## ⚠️ This is NOT user-visible yet — please read before merging

The premise this was approved on ("once it binds, cards that currently
slip through will start being held") **does not hold for this change
alone.** The whole capacity block sits inside `if (useWorkflow &&
workflowIr && fromColumn !== toColumn)`, and `useWorkflow` is
`experimentalFeatures.workflowColumns === true` — absent from
`DEFAULT_GLOBAL_SETTINGS`, with **no writer anywhere outside tests**.
That is Phase A3's R2, still live and now retitled `DEFECT (R2, STILL
LIVE)` with the measured matrix recorded in it.

So on merge: nothing changes for any real project. Making it actually
bind means **also** removing the `useWorkflow` condition — a materially
larger, genuinely user-visible change that I have not made unilaterally.
Escalated for a decision; if that lands, the changeset here should be
re-categorised.


## Review follow-up (48e79ffd9): the convention was still duplicated —
swept and ratcheted

The first pass added the resolver and routed the transactional gate +
counters, but **hold-release still derived the pool independently**.
Swept the repo: six sites name the sentinel, **five derive the
convention** and now call `resolveCapacityPoolId`
(`hold-release.ts:116/118/442/576`, `task-store-helpers.ts:290`). The
sixth, `scheduler.ts:1558`, names the default pool as a literal in a
capacity *diagnostic* — no selection input, nothing to disagree with —
so it keeps the constant.

**Does this change hold-release behavior? No, and it was never releasing
against the wrong pool.** hold-release computed `x ??
DEFAULT_WORKFLOW_POOL_ID`, which is exactly what the counter buckets
under; `moves.ts` (`?? "builtin:coding"`) was the sole disagreeing site,
and the first commit moved *it* into agreement with hold-release, not
the reverse. `resolveCapacityPoolId(x)` **is** `x ??
DEFAULT_WORKFLOW_POOL_ID`, so every routed site computes an identical
value for every input. **No second user-visible change rides along with
this PR** — the only behavior delta remains the gate binding on the
flag-ON path, which per R2 is still not the path production takes.
Evidence: hold-release + capacity suites **43/43 identical before and
after**.

**The resolver is now the only way to compute a pool id, not merely the
newest way.** `scripts/check-capacity-pool-id.mjs` fails on any inline
`?? DEFAULT_WORKFLOW_POOL_ID` outside `workflow-capacity.ts`, wired into
**both `pretest` and the blocking `test:gate`**. A review note would not
have sufficed: the original defect landed in a file that *already
imported* the canonical constant. Verified both ways — clean run scans
1124 files and passes; reintroducing the old hold-release expression
exits 1 and names the line.


## Review follow-up (a5b675503): the ratchet was rebuilt because it
would not have caught the bug

The first ratchet matched one spelling (`?? DEFAULT_WORKFLOW_POOL_ID`)
and the real defect used another (`?? "builtin:coding"`). **Verified:
reintroducing the original defect and running the old checker exits 0.**
A guard that reports success without checking is worse than no guard —
it stops anyone looking.

Rebuilt on the TypeScript AST with two rules. **Rule 1 (sink):** a value
reaching a capacity counter's `workflowId` must come from
`resolveCapacityPoolId`, or a local initialized from it — so it fires on
the original defect regardless of which literal was used, on one line or
twenty. **Rule 2 (sentinel):** no `??` onto the sentinel at any
qualification depth or as its raw value; multiline is one AST node and
caught by construction. `?? "builtin:coding"` is deliberately *not*
banned outright — it is the legitimate default for a *workflow* id in ~8
places, and is only a bug when it reaches a capacity pool.

**Fails closed three ways** that previously reported success without
inspecting: unreadable file, unparseable file, and an empty file listing
(the old script would have printed a green tick off a broken glob).

**Acceptance was not "passes on main".** Each form was reintroduced into
the real source and confirmed to fail: the original defect in
`moves.ts`, a multiline fallback, and a deeply qualified sentinel. All
are pinned in `capacity-pool-id-check.test.ts` (12 cases: 7 must-catch
starting with the reduced actual pre-fix `moves.ts`, 4 must-not-flag, 1
fail-closed) so the guard cannot silently narrow again.

Also added to `pretest:full`, which had omitted it.


### Follow-up (0be8df6ea): a dead rule found by fixing a test title

Splitting the mislabelled fail-closed test surfaced more than a
mislabel: **`ts.createSourceFile` is error-tolerant and does not throw
on malformed syntax**, so the `try/catch` behind the `unparseable` rule
was unreachable and that rule could never fire. The earlier "fails
closed three ways" claim was overstated — the guard advertised a
capability it did not have. Detection now reads `sf.parseDiagnostics`; a
partial AST can silently lack the `??` nodes and sink calls the rules
look for, so "did not parse" must not read as "inspected and clean".
Mutation-verified: reverting the detection fails that case and only that
case.

Test-file exclusion also moved to the repo's `{test,spec}.{ts,tsx}`
guideline shape — a `.spec.ts` under `packages/<pkg>/src/` was being
scanned as production source. Verified both ways: the `.spec.ts` is
skipped, and the identical content in a non-test file is still caught,
so the exclusion is scoped rather than a hole.

## Verification

- engine + core `tsc --noEmit` clean
- `pnpm test:gate` green (299 + 10 + 71)
- E2E 20/20; capacity + move-path suites 14/14
- full core PG: **1037 passed / 3 failed** — all three reproduce with
the fix stashed (pre-existing)
- engine-default: **279 failed** vs **280 at baseline** with the fix
stashed — pre-existing red lane, no regression
- hold-release + capacity suites: **43/43 identical before and after**
the resolver routing
- `check-capacity-pool-id` ratchet: 14/14 regression cases; clean over
1124 files; exits 1 on the original defect, a multiline fallback, and a
deeply qualified sentinel reintroduced into real source

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

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

* **Bug Fixes**
* Fixed capacity-limit accounting when workflow selection is missing by
consistently deriving the correct capacity pool id.
* Made capacity enforcement align across move and hold/release paths,
rejecting over-limit moves with `capacity-exhausted`.
* **Tests**
* Updated PostgreSQL and added an E2E scenario to verify the corrected
in-transaction gating behavior at `maxConcurrent` limits of 1 and 2.
* **Chores**
* Added an automated guard to detect inconsistent capacity pool id
fallback patterns in code.
* **Public API**
* Exposed `resolveCapacityPoolId` for consistent capacity pool id
derivation.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 21:09:51 -07:00

124 lines
7.4 KiB
JSON

{
"name": "fusion-workspace",
"version": "0.74.0-beta.5",
"private": true,
"license": "MIT",
"homepage": "https://github.com/Runfusion/Fusion#readme",
"repository": {
"type": "git",
"url": "https://github.com/Runfusion/Fusion"
},
"bugs": {
"url": "https://github.com/Runfusion/Fusion/issues"
},
"type": "module",
"packageManager": "pnpm@10.33.0",
"scripts": {
"pretest": "node scripts/check-no-nohup.mjs && node scripts/check-no-cwd-relative-dashboard-test-reads.mjs && node scripts/check-no-kill-4040.mjs && node scripts/check-no-getdatabase.mjs && node scripts/check-capacity-pool-id.mjs && node scripts/check-no-node-only-core-imports-in-dashboard.mjs && node scripts/check-pi-versions-pinned.mjs && node scripts/check-no-test-timeout-appeasement.mjs && node scripts/check-changeset-format.mjs && node scripts/check-routes-modular.mjs",
"pretest:full": "node scripts/check-no-nohup.mjs && node scripts/check-no-cwd-relative-dashboard-test-reads.mjs && node scripts/check-no-kill-4040.mjs && node scripts/check-no-getdatabase.mjs && node scripts/check-capacity-pool-id.mjs && node scripts/check-no-node-only-core-imports-in-dashboard.mjs && node scripts/check-pi-versions-pinned.mjs && node scripts/check-no-test-timeout-appeasement.mjs && node scripts/check-changeset-format.mjs && node scripts/check-routes-modular.mjs",
"check:line-count": "node scripts/check-file-line-count.mjs",
"check:routes-modular": "node scripts/check-routes-modular.mjs",
"check:changesets": "node scripts/check-changeset-format.mjs",
"check:quarantine-ledger": "node scripts/check-quarantine-ledger.mjs",
"check:mock-completeness": "node scripts/check-mock-completeness.mjs",
"test:gate": "node scripts/check-no-nohup.mjs && node scripts/check-no-cwd-relative-dashboard-test-reads.mjs && node scripts/check-no-kill-4040.mjs && node scripts/check-no-getdatabase.mjs && node scripts/check-capacity-pool-id.mjs && node scripts/check-no-node-only-core-imports-in-dashboard.mjs && node scripts/check-pi-versions-pinned.mjs && node scripts/check-no-test-timeout-appeasement.mjs && node scripts/check-changeset-format.mjs && node scripts/check-mock-completeness.mjs && sh -c 'pnpm --filter @fusion/engine test:core & engine_pid=$!; pnpm --filter @fusion/core test:pg-gate & pg_pid=$!; status=0; wait $engine_pid || status=1; wait $pg_pid || status=1; exit $status' && pnpm --filter @runfusion/fusion test:ci-shape",
"smoke:boot": "node scripts/boot-smoke.mjs",
"local": "node scripts/start-local.mjs",
"dev": "node scripts/dev-with-memory.mjs",
"start": "node scripts/dev-with-memory.mjs",
"dev:ui": "pnpm --filter @fusion/dashboard dev",
"dev:hmr": "node scripts/dev-hmr.mjs",
"lint": "eslint .",
"sync:fusion-skill": "node scripts/sync-fusion-skill-tools.mjs",
"sync:fusion-skill:check": "node scripts/sync-fusion-skill-tools.mjs --check",
"build": "node scripts/build-workspace.mjs",
"build:full": "node scripts/build-workspace.mjs --full",
"build:force": "node scripts/build-workspace.mjs --force",
"build:all": "pnpm -r build",
"verify:workspace": "pnpm lint && pnpm test:full && pnpm build:full",
"build:exe": "pnpm build:full && pnpm --filter @runfusion/fusion build:exe",
"build:exe:all": "pnpm build:full && pnpm --filter @runfusion/fusion build:exe:all",
"test": "node scripts/test-changed.mjs",
"verify:fast": "node scripts/verify-fast.mjs",
"test:scripts": "node scripts/run-script-tests.mjs",
"test:workflow-release-check": "node scripts/workflow-reliability-release-check.mjs",
"fn:cache-stats": "node scripts/cache-stats.mjs",
"test:full": "node scripts/test-changed.mjs --full --no-cache && pnpm --filter @fusion/engine test:slow",
"test:velocity": "node scripts/test-velocity-baseline.mjs",
"test:feedback-baseline": "node scripts/test-feedback-baseline.mjs",
"test:ci:shard": "node scripts/ci-test-shard.mjs",
"test:serial": "FUSION_TEST_CONCURRENCY=1 FUSION_TEST_WORKSPACE_CONCURRENCY=1 pnpm test:full",
"test:fast": "FUSION_TEST_CONCURRENCY=4 FUSION_TEST_WORKSPACE_CONCURRENCY=4 pnpm test:full",
"test:locked": "node scripts/test-with-lock.mjs",
"test:build": "pnpm --filter @fusion/dashboard test:build",
"test:isolated": "node scripts/check-test-isolation.mjs --before && pnpm test:full && node scripts/check-test-isolation.mjs",
"test:check-isolation": "node scripts/check-test-isolation.mjs",
"test:coverage": "pnpm -r --workspace-concurrency=1 exec vitest run --silent=passed-only --reporter=dot --coverage",
"test:coverage:core": "pnpm --filter @fusion/core exec vitest run --silent=passed-only --reporter=dot --coverage",
"test:coverage:engine": "pnpm --filter @fusion/engine exec vitest run --silent=passed-only --reporter=dot --coverage",
"test:coverage:cli": "pnpm --filter @runfusion/fusion exec vitest run --silent=passed-only --reporter=dot --coverage",
"test:coverage:dashboard": "pnpm --filter @fusion/dashboard exec vitest run --silent=passed-only --reporter=dot --coverage",
"test:slow-cli": "pnpm --filter @runfusion/fusion test:slow-cli",
"typecheck": "pnpm -r --filter=!@fusion/desktop --filter=!@fusion/mobile typecheck",
"i18n:extract": "i18next-cli extract",
"i18n:sync": "i18next-cli sync",
"i18n:types": "i18next-cli types",
"i18n:status": "tsx --no-warnings packages/i18n/scripts/check-i18n-parity.mjs",
"i18n:status:report": "i18next-cli status",
"i18n:lint": "i18next-cli lint",
"i18n:gen-cli": "pnpm --filter @fusion/i18n gen:cli-catalogs",
"changeset": "changeset",
"version": "changeset version",
"release": "node scripts/release.mjs",
"release:version": "changeset version && node scripts/sync-workspace-version.mjs && node scripts/run-ci-distill.mjs",
"mobile:build": "pnpm --filter @fusion/dashboard build && pnpm --filter @fusion/mobile cap sync",
"mobile:ios": "pnpm mobile:build && pnpm --filter @fusion/mobile cap open ios",
"mobile:android": "pnpm mobile:build && pnpm --filter @fusion/mobile cap open android",
"mobile:dev:ios": "pnpm --filter @fusion/mobile dev:ios",
"mobile:dev:android": "pnpm --filter @fusion/mobile dev:android",
"mobile:sync": "pnpm --filter @fusion/mobile cap sync",
"mobile:run:android": "bash scripts/mobile-run-android.sh",
"ios:acceptance": "node scripts/ios-acceptance.mjs",
"build:desktop": "pnpm --filter @fusion/desktop build",
"dist:desktop:win": "pnpm --filter @fusion/desktop build && pnpm --filter @fusion/desktop dist:win"
},
"pnpm": {
"ignoredBuiltDependencies": [
"@google/genai",
"cpu-features",
"electron-winstaller",
"keytar",
"sharp",
"ssh2"
],
"onlyBuiltDependencies": [
"@embedded-postgres/darwin-arm64",
"@embedded-postgres/darwin-x64",
"@embedded-postgres/linux-arm",
"@embedded-postgres/linux-arm64",
"@embedded-postgres/linux-ia32",
"@embedded-postgres/linux-ppc64",
"@embedded-postgres/linux-x64",
"@embedded-postgres/windows-x64",
"@homebridge/node-pty-prebuilt-multiarch",
"electron",
"esbuild",
"koffi",
"protobufjs"
]
},
"devDependencies": {
"@changesets/cli": "^2.30.0",
"@eslint/js": "^9.0.0",
"eslint": "^9.0.0",
"esbuild": "^0.25.12",
"fast-glob": "^3.3.3",
"i18next-cli": "^1.59.1",
"react-devtools-core": "^7.0.1",
"tsx": "^4.19.0",
"typescript": "^5.7.0",
"typescript-eslint": "^8.0.0",
"yaml": "^2.8.3"
}
}