Skip to content

fix: stop agents exceeding the global concurrency cap#2107

Merged
gsxdsm merged 5 commits into
mainfrom
feature/fix-concurrency
Jul 15, 2026
Merged

fix: stop agents exceeding the global concurrency cap#2107
gsxdsm merged 5 commits into
mainfrom
feature/fix-concurrency

Conversation

@gsxdsm

@gsxdsm gsxdsm commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Operators could see more agents running than Global Max Concurrent (e.g. 5 running with cap 4: 4 planners + 1 executor).
  • Scheduler now tryAcquires a shared semaphore slot before todo→in-progress and hands that pre-held slot to the executor/graph run.
  • Triage admits planners against the live top-level running-agent claim (planning + in-progress + active in-review), not only semaphore.availableCount.
  • Executor claims the pre-held slot for the full run and avoids a second top-level acquire on step/seam re-entry (deadlock under a full cap).

Test plan

  • pnpm --filter @fusion/engine exec vitest run src/__tests__/concurrency.test.ts src/__tests__/triage.test.ts
  • Regression: triage leaves room when 1 in-progress agent is live under global cap 4
  • Regression: pre-held executor slot register/take/drop handoff
  • Manual: set Global Max Concurrent and Max triage concurrent to 4, fill Planning + run 1 In Progress; footer should not show 5 running under a full steady state

Summary by CodeRabbit

  • Bug Fixes
    • Improved global concurrency enforcement so the scheduler and executor never start more agents than the configured limit, including tighter top-level “claimed capacity” accounting.
    • Updated triage admission control to consider global top-level utilization, factoring processing tasks and agents already running to prevent over-admitting planners.
    • Added safer pre-held concurrency-slot handoff behavior to avoid capacity leaks and drift during graph routing, step execution, and legacy fallback.
    • Ensured reserved capacity is reliably released on early exits, failed dispatches, and other aborted paths (with idempotent cleanup).
    • Refreshed concurrency diagnostics to better explain whether throttling is due to project or global limits, with clearer claimed/processing visibility.

Triage and scheduling could admit planners and an executor together so live running counts exceeded Global Max Concurrent. Reserve a shared semaphore slot before todo→in-progress, hand it to the executor/graph without double-acquire, and admit planners against the same live running-agent claim the dashboard shows.
@coderabbitai

coderabbitai Bot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The scheduler and triage processor now account for live top-level agent claims, pending planner demand, and semaphore activity. Pre-held semaphore slots transfer from scheduling to execution without nested acquisition, with explicit cleanup and regression coverage.

Changes

Global concurrency control

Layer / File(s) Summary
Claim computation and slot registry
packages/engine/src/concurrency.ts, packages/engine/src/__tests__/concurrency.test.ts
Adds top-level claim calculation and task-keyed pre-held executor slot lifecycle helpers with unit tests.
Scheduler admission and slot reservation
packages/engine/src/scheduler.ts
Uses live top-level claims for gating and reserves semaphore slots before releasing held tasks.
Executor claim handoff and nested execution
packages/engine/src/executor.ts
Transfers scheduler-held claims into execution, prevents nested semaphore acquisition, and cleans up claims across early exits and completion.
Triage admission and regression coverage
packages/engine/src/triage.ts, packages/engine/src/__tests__/triage.test.ts, packages/engine/src/__tests__/executor-preheld-legacy-handoff.test.ts, .changeset/fix-global-concurrency-over-cap.md
Limits new planners using global claimed capacity and adds regression coverage plus a patch changeset.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

  • Runfusion/Fusion#1539: Updates scheduler admission and concurrency throttling around global semaphore and in-progress capacity.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: preventing agents from exceeding the global concurrency cap.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/fix-concurrency

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@greptile-apps

greptile-apps Bot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR enforces the global concurrency cap across planning and execution. The main changes are:

  • Reserves a shared semaphore slot before moving tasks into progress.
  • Transfers reserved slots through graph and legacy executor paths.
  • Releases reservations on failed dispatches, early returns, and thrown errors.
  • Limits triage admission using live top-level agent usage.
  • Adds tests for reservation handoff and cleanup paths.

Confidence Score: 5/5

This looks safe to merge.

  • Reserved slots are released on executor returns and thrown errors.
  • Slot transfer and cleanup are idempotent.
  • The latest changes cover the previously leaking authoritative dispatch paths.
  • No blocking issue remains in the changed code.

Important Files Changed

Filename Overview
packages/engine/src/concurrency.ts Adds pre-held slot tracking and shared top-level concurrency accounting.
packages/engine/src/executor.ts Transfers reserved slots into graph or legacy execution and guarantees cleanup on executor exit.
packages/engine/src/scheduler.ts Reserves semaphore capacity before dispatch and releases it when dispatch preparation fails.
packages/engine/src/triage.ts Limits new planners using live planning, execution, review, and semaphore usage.
packages/engine/src/tests/executor-preheld-legacy-handoff.test.ts Covers reservation cleanup across legacy handoff exits and authoritative dispatch errors.
packages/engine/src/tests/concurrency.test.ts Covers reservation transfer, idempotent cleanup, scheduler rollback, and claimed-capacity calculation.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Scheduler checks capacity] --> B{Reserve semaphore slot}
    B -- Failed --> C[Keep task queued]
    B -- Succeeded --> D[Register pre-held slot]
    D --> E{Move task to in-progress}
    E -- Failed --> F[Drop registration and release slot]
    E -- Succeeded --> G[Executor routes task]
    G --> H{Graph owns task}
    H -- Yes --> I[Take slot for graph run]
    I --> J[Release slot after graph cleanup]
    H -- No --> K[Re-register for legacy execution]
    K --> L{Legacy work starts}
    L -- Yes --> M[Take slot and run work]
    M --> N[Release slot after work]
    L -- No or error --> O[Executor cleanup drops slot]
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A[Scheduler checks capacity] --> B{Reserve semaphore slot}
    B -- Failed --> C[Keep task queued]
    B -- Succeeded --> D[Register pre-held slot]
    D --> E{Move task to in-progress}
    E -- Failed --> F[Drop registration and release slot]
    E -- Succeeded --> G[Executor routes task]
    G --> H{Graph owns task}
    H -- Yes --> I[Take slot for graph run]
    I --> J[Release slot after graph cleanup]
    H -- No --> K[Re-register for legacy execution]
    K --> L{Legacy work starts}
    L -- Yes --> M[Take slot and run work]
    M --> N[Release slot after work]
    L -- No or error --> O[Executor cleanup drops slot]
Loading

Reviews (5): Last reviewed commit: "fix(engine): structural pre-held slot cl..." | Re-trigger Greptile

Comment thread packages/engine/src/executor.ts
When workflow graph falls back and re-registers a scheduler pre-held
concurrency slot, every execute() path that never reaches
runWithExecutorSemaphore must drop the registration. Authoritative
dispatch accept, work-engine claim, heartbeat defer, lock contention,
and outer finally now release the slot so global capacity cannot leak.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/engine/src/executor.ts (1)

10329-10396: 🩺 Stability & Availability | 🟠 Major | ⚖️ Poor tradeoff

Seed the outer concurrency claim before constructing StepSessionExecutor — on the legacy fallback path, maybeExecuteWorkflowGraph() drops outerConcurrencyClaims before returning, and runWithExecutorSemaphore() only restores it after this constructor has already run. That leaves step sessions with a real semaphore on stores that lack workflow-selection APIs, so per-step acquire() can still double-book the top-level slot and deadlock under a full cap.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/engine/src/executor.ts` around lines 10329 - 10396, Seed this task’s
outer concurrency claim before constructing StepSessionExecutor, including the
legacy fallback path where maybeExecuteWorkflowGraph() removes it before
returning. Ensure runWithExecutorSemaphore() restores or establishes the claim
before the StepSessionExecutor semaphore selection executes, so the existing
outerConcurrencyClaims check passes and per-step sessions receive no semaphore
when the top-level slot is already owned.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@packages/engine/src/executor.ts`:
- Around line 10329-10396: Seed this task’s outer concurrency claim before
constructing StepSessionExecutor, including the legacy fallback path where
maybeExecuteWorkflowGraph() removes it before returning. Ensure
runWithExecutorSemaphore() restores or establishes the claim before the
StepSessionExecutor semaphore selection executes, so the existing
outerConcurrencyClaims check passes and per-step sessions receive no semaphore
when the top-level slot is already owned.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 8c5eff92-4f7c-43e9-b377-0e589760813c

📥 Commits

Reviewing files that changed from the base of the PR and between cdf67c1 and 8e2112f.

📒 Files selected for processing (7)
  • .changeset/fix-global-concurrency-over-cap.md
  • packages/engine/src/__tests__/concurrency.test.ts
  • packages/engine/src/__tests__/triage.test.ts
  • packages/engine/src/concurrency.ts
  • packages/engine/src/executor.ts
  • packages/engine/src/scheduler.ts
  • packages/engine/src/triage.ts

Comment thread packages/engine/src/executor.ts Outdated
gsxdsm added 2 commits July 14, 2026 20:11
Graph fallback re-registers a scheduler concurrency slot for the legacy
path. If workflowAuthoritativeDispatch throws, execute exited before the
accept-path drop and before the main finally, leaking the semaphore.
Drop the registration before rethrowing and cover the rejection surface.
Keep global concurrency pre-held slot helpers and the planner overseer session-advisor log-flush setter from main.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
packages/engine/src/__tests__/executor-preheld-legacy-handoff.test.ts (1)

3-13: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Remove the redundant import.

The side-effect import of "./executor-test-helpers.js" on line 3 is redundant since createMockStore and resetExecutorMocks are already imported from the exact same file on line 12.

♻️ Proposed refactor
-import "./executor-test-helpers.js";
 import {
   AgentSemaphore,
   clearPreHeldExecutorSlotsForTests,
   hasPreHeldExecutorSlot,
   registerPreHeldExecutorSlot,
 } from "../concurrency.js";
 import { TaskExecutor } from "../executor.js";
 import { executingTaskLock } from "../active-session-registry.js";
 import { createMockStore, resetExecutorMocks } from "./executor-test-helpers.js";
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/engine/src/__tests__/executor-preheld-legacy-handoff.test.ts` around
lines 3 - 13, Remove the standalone side-effect import of
"./executor-test-helpers.js" and retain the existing named import of
createMockStore and resetExecutorMocks from that module.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@packages/engine/src/__tests__/executor-preheld-legacy-handoff.test.ts`:
- Around line 3-13: Remove the standalone side-effect import of
"./executor-test-helpers.js" and retain the existing named import of
createMockStore and resetExecutorMocks from that module.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: c8a87375-b0cb-4858-9138-8891695f8279

📥 Commits

Reviewing files that changed from the base of the PR and between 8e2112f and 3a0554a.

📒 Files selected for processing (3)
  • packages/engine/src/__tests__/concurrency.test.ts
  • packages/engine/src/__tests__/executor-preheld-legacy-handoff.test.ts
  • packages/engine/src/executor.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/engine/src/executor.ts

Wrap execute() in try/finally that always dropPreHeldExecutorSlot so new
early-return paths cannot permanently shrink global capacity. Document the
register-only-after-tryAcquire invariant and cover scheduler prep.release
on move failure.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
packages/engine/src/__tests__/concurrency.test.ts (1)

502-510: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Test the idempotency of dropPreHeldExecutorSlot directly.

The released boolean guard short-circuits the second call, meaning the test verifies the local wrapper's logic rather than the underlying utility. Since dropPreHeldExecutorSlot is inherently idempotent (it checks Set.delete() before releasing), you can simplify the test by calling it twice directly. This ensures the concurrency primitives themselves are robust against duplicate calls.

♻️ Proposed refactor
-    // Mirrors scheduler.ts prep.release() after a failed/aborted hold release.
-    let released = false;
-    const release = () => {
-      if (released) return;
-      released = true;
-      dropPreHeldExecutorSlot("FN-MOVE-FAIL", sem);
-    };
-    release();
-    release(); // idempotent
+    // dropPreHeldExecutorSlot is inherently idempotent; calling it twice is safe
+    dropPreHeldExecutorSlot("FN-MOVE-FAIL", sem);
+    dropPreHeldExecutorSlot("FN-MOVE-FAIL", sem); // idempotent
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/engine/src/__tests__/concurrency.test.ts` around lines 502 - 510,
Update the test around dropPreHeldExecutorSlot to call the utility directly
twice with the same executor and semaphore, removing the local released guard
and wrapper. Keep the assertions that verify only one slot is released, so the
test covers the utility’s own idempotency.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@packages/engine/src/__tests__/concurrency.test.ts`:
- Around line 502-510: Update the test around dropPreHeldExecutorSlot to call
the utility directly twice with the same executor and semaphore, removing the
local released guard and wrapper. Keep the assertions that verify only one slot
is released, so the test covers the utility’s own idempotency.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 9dced772-783a-479e-b777-b9e25eff98f7

📥 Commits

Reviewing files that changed from the base of the PR and between 3a0554a and 43588d1.

📒 Files selected for processing (4)
  • packages/engine/src/__tests__/concurrency.test.ts
  • packages/engine/src/__tests__/executor-preheld-legacy-handoff.test.ts
  • packages/engine/src/concurrency.ts
  • packages/engine/src/executor.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • packages/engine/src/concurrency.ts
  • packages/engine/src/tests/executor-preheld-legacy-handoff.test.ts
  • packages/engine/src/executor.ts

@gsxdsm
gsxdsm merged commit e97081f into main Jul 15, 2026
7 checks passed
@gsxdsm
gsxdsm deleted the feature/fix-concurrency branch July 15, 2026 04:34
gsxdsm added a commit that referenced this pull request Jul 15, 2026
## Summary
- Operators could see more agents running than Global Max Concurrent
(e.g. 5 running with cap 4: 4 planners + 1 executor).
- Scheduler now `tryAcquire`s a shared semaphore slot before
todo→in-progress and hands that pre-held slot to the executor/graph run.
- Triage admits planners against the live top-level running-agent claim
(planning + in-progress + active in-review), not only
`semaphore.availableCount`.
- Executor claims the pre-held slot for the full run and avoids a second
top-level acquire on step/seam re-entry (deadlock under a full cap).

## Test plan
- [x] `pnpm --filter @fusion/engine exec vitest run
src/__tests__/concurrency.test.ts src/__tests__/triage.test.ts`
- [x] Regression: triage leaves room when 1 in-progress agent is live
under global cap 4
- [x] Regression: pre-held executor slot register/take/drop handoff
- [ ] Manual: set Global Max Concurrent and Max triage concurrent to 4,
fill Planning + run 1 In Progress; footer should not show 5 running
under a full steady state

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

- **Bug Fixes**
- Improved global concurrency enforcement so the scheduler and executor
never start more agents than the configured limit, including tighter
top-level “claimed capacity” accounting.
- Updated triage admission control to consider global top-level
utilization, factoring processing tasks and agents already running to
prevent over-admitting planners.
- Added safer pre-held concurrency-slot handoff behavior to avoid
capacity leaks and drift during graph routing, step execution, and
legacy fallback.
- Ensured reserved capacity is reliably released on early exits, failed
dispatches, and other aborted paths (with idempotent cleanup).
- Refreshed concurrency diagnostics to better explain whether throttling
is due to project or global limits, with clearer claimed/processing
visibility.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant