From eef5eb751ed00cd29b5f268e06dc05760b0ccfea Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 21 Jul 2026 15:30:21 -0700 Subject: [PATCH] FN-8453: unify concurrency accounting and indicators Unify live-agent capacity accounting across engine and dashboard. - Derive Running and Waiting from workflow traits and durable agent liveness. - Apply unified limits to planner, executor, and merge admission while updating dashboard indicators. - Remove duplicate concurrency controls and document the unified operator model. Files changed: .changeset/fn-8453-unified-concurrency.md | 7 + docs/agent-tool-surface-full-loop.md | 4 +- docs/architecture.md | 2 +- docs/dashboard-guide.md | 4 +- docs/settings-reference.md | 4 +- .../skill/fusion/references/fusion-capabilities.md | 4 +- .../core/src/__tests__/live-agent-count.test.ts | 91 ++++---- packages/core/src/index.gate.ts | 6 + packages/core/src/index.ts | 6 + packages/core/src/live-agent-count.ts | 107 ++++++--- packages/dashboard/app/App.tsx | 28 ++- packages/dashboard/app/api/board-workflows.ts | 2 + packages/dashboard/app/components/Column.tsx | 6 +- .../dashboard/app/components/EngineControlMenu.tsx | 26 --- .../dashboard/app/components/ExecutorStatusBar.tsx | 38 ++- .../dashboard/app/components/SettingsModal.tsx | 1 - .../app/components/__tests__/Column.test.tsx | 6 +- .../__tests__/EngineControlMenu.test.tsx | 10 +- .../__tests__/ExecutorStatusBar.test.tsx | 32 ++- .../command-center/CommandCenterControls.tsx | 26 --- .../settings/sections/SchedulingSection.search.ts | 9 - .../settings/sections/SchedulingSection.tsx | 13 -- .../app/hooks/__tests__/useExecutorStats.test.ts | 12 +- packages/dashboard/app/hooks/useExecutorStats.ts | 50 ++-- .../src/__tests__/project-store-resolver.test.ts | 11 +- packages/dashboard/src/project-store-resolver.ts | 14 +- .../register-config-mcp-pi-settings-routes.ts | 3 +- packages/engine/src/__tests__/concurrency.test.ts | 123 +++++++++- .../engine/src/__tests__/project-engine.test.ts | 34 +++ packages/engine/src/__tests__/triage.test.ts | 7 +- packages/engine/src/concurrency.ts | 207 ++++++++++++++++- packages/engine/src/project-engine.ts | 151 ++++++++++-- packages/engine/src/scheduler.ts | 82 ++++++- packages/engine/src/triage.ts | 254 +++++++++++++-------- .../lib/dashboard-browser-safe-core-modules.json | 5 + 35 files changed, 991 insertions(+), 394 deletions(-) Fusion-Task-Id: FN-8453 Fusion-Task-Lineage: 12cfa5df-675d-4fce-b17e-932376544239 Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-8453-unified-concurrency.md | 7 + docs/agent-tool-surface-full-loop.md | 4 +- docs/architecture.md | 2 +- docs/dashboard-guide.md | 4 +- docs/settings-reference.md | 4 +- .../fusion/references/fusion-capabilities.md | 4 +- .../src/__tests__/live-agent-count.test.ts | 93 ++++--- packages/core/src/index.gate.ts | 6 + packages/core/src/index.ts | 6 + packages/core/src/live-agent-count.ts | 107 ++++++-- packages/dashboard/app/App.tsx | 28 +- packages/dashboard/app/api/board-workflows.ts | 2 + packages/dashboard/app/components/Column.tsx | 6 +- .../app/components/EngineControlMenu.tsx | 26 -- .../app/components/ExecutorStatusBar.tsx | 38 +-- .../app/components/SettingsModal.tsx | 1 - .../app/components/__tests__/Column.test.tsx | 6 +- .../__tests__/EngineControlMenu.test.tsx | 10 +- .../__tests__/ExecutorStatusBar.test.tsx | 32 +-- .../command-center/CommandCenterControls.tsx | 26 -- .../sections/SchedulingSection.search.ts | 9 - .../settings/sections/SchedulingSection.tsx | 13 - .../hooks/__tests__/useExecutorStats.test.ts | 12 +- .../dashboard/app/hooks/useExecutorStats.ts | 50 ++-- .../__tests__/project-store-resolver.test.ts | 11 +- .../dashboard/src/project-store-resolver.ts | 14 +- .../register-config-mcp-pi-settings-routes.ts | 3 +- .../engine/src/__tests__/concurrency.test.ts | 123 ++++++++- .../src/__tests__/project-engine.test.ts | 34 +++ packages/engine/src/__tests__/triage.test.ts | 7 +- packages/engine/src/concurrency.ts | 207 +++++++++++++- packages/engine/src/project-engine.ts | 151 +++++++++-- packages/engine/src/scheduler.ts | 82 +++++- packages/engine/src/triage.ts | 256 +++++++++++------- .../dashboard-browser-safe-core-modules.json | 5 + 35 files changed, 993 insertions(+), 396 deletions(-) create mode 100644 .changeset/fn-8453-unified-concurrency.md diff --git a/.changeset/fn-8453-unified-concurrency.md b/.changeset/fn-8453-unified-concurrency.md new file mode 100644 index 0000000000..6458c1e681 --- /dev/null +++ b/.changeset/fn-8453-unified-concurrency.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Unify max concurrency across planning/execution/review and simplify board capacity indicators. +category: feature +dev: maxConcurrent caps all top-level working agents per project; maxTriageConcurrent removed from UI (Settings, Command Center, Engine Control) and admission; free slots admit oldest createdAt via per-project atomic admission coordinator across lanes; footer Waiting/Running/Blocked; column headers active/total; nested runNested helpers remain parent-internal soft-breach by design. diff --git a/docs/agent-tool-surface-full-loop.md b/docs/agent-tool-surface-full-loop.md index 8c5bbbcba0..bfce4140f9 100644 --- a/docs/agent-tool-surface-full-loop.md +++ b/docs/agent-tool-surface-full-loop.md @@ -63,9 +63,9 @@ FR-07/FR-08 require one canonical operation beneath UI and tool callers. The par Fusion does not have a single-workflow-only scheduler by design. The scheduler can dispatch independent runnable tasks, but admission is bounded and serialized at specific safety gates: - `packages/engine/src/scheduler.ts` computes dispatch capacity from **`maxConcurrent`**, **`maxWorktrees`**, and the shared **`semaphore`** in `computeConcurrencyGateDiagnostic()`. The settings default to `maxConcurrent` 2 and `maxWorktrees` 4 when unspecified (scheduler dispatch path). -- `AgentSemaphore` in `packages/engine/src/concurrency.ts` gates all top-level triage, execution, and merge agents. Its priority queue serves merge before execute before specification (`PRIORITY_MERGE`, `PRIORITY_EXECUTE`, `PRIORITY_SPECIFY`). A full semaphore can therefore make work appear serialized even with multiple runnable cards. +- `AgentSemaphore` in `packages/engine/src/concurrency.ts` gates all top-level planning, execution, and merge/review agents. Per-project admission ranks eligible candidates by `createdAt` then task ID across lanes before claiming the shared capacity; lane priority is not a free-slot rank key. Nested helpers remain parent-internal and intentionally soft-breach this displayed top-level cap to avoid parent/child deadlocks. - The workflow hold/release sweep in `scheduler.ts` reserves worktree and semaphore capacity with `tryAcquire()` **before** moving a task to `in-progress`, then transfers that pre-held slot to the executor. This prevents a race but means the available minimum of all gates is authoritative. -- `maxWorktrees` counts only `in-progress` tasks; in-review worktrees do not consume that execution-worktree limit. `maxConcurrent` counts execution slots, while the global semaphore also accounts for planning and active review top-level holders. +- `maxWorktrees` counts only `in-progress` tasks; in-review worktrees do not consume that execution-worktree limit. `maxConcurrent` caps per-project top-level working agents across planning, execution, and active review/merge, while the host semaphore remains the process-global pool. - Runnable candidates are additionally filtered for paused state, unmet dependencies, recovery backoff, workflow hold/release state, and file-scope overlap via `isRunnableQueuedOverlapCandidate()` / `pathsOverlap()` in `scheduler.ts`. This is coarse path-scope serialization, not FR-48 symbol locking. - `packages/engine/src/workflow-work-scheduler.ts` claims one due workflow work item per call with a lease. The surrounding scheduler’s repeated dispatch and capacity gates determine aggregate concurrency; this helper alone does not fan out a batch. - `packages/engine/src/verification-concurrency.ts` separately defaults expensive verification subprocesses to one concurrent project-wide slot. Task execution can be parallel while heavy E2E verification intentionally queues. diff --git a/docs/architecture.md b/docs/architecture.md index fb2460ffc5..ac54b134f2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1134,7 +1134,7 @@ The run-audit system records every mutation performed by the engine across four Events are tied to specific run IDs for end-to-end traceability. -For scheduler concurrency diagnostics, the queued reason now names the active limiter(s) and usage (for example `gate=maxConcurrent ...`). The reason includes the `bindingGates` (`maxConcurrent`/`maxWorktrees`/`semaphore`), per-gate `{ used, limit, slack }`, `holders`, and computed `available`. `holders.maxConcurrent` and `holders.maxWorktrees` are current `in-progress` task IDs; `holders.semaphore` mirrors that set but semaphore slots can also be consumed by triage/merge agents outside `in-progress`. So if `semaphore.used` exceeds the visible holder list, that usually indicates non-execution agents are legitimately consuming shared capacity (not stale accounting). `maxWorktrees` is also enforced inside `TaskStore.moveTaskInternal` when committing an allocated move into `in-progress`, making it a hard active execution worktree cap even when workflow WIP/`maxConcurrent` would allow more tasks. These queued-reason logs are transition-only: a newly emitted line indicates the limiter signature changed or the condition cleared and later reappeared, not that a poll loop simply observed the same blocked state again. +For scheduler concurrency diagnostics, the queued reason names the active limiter(s) and usage (for example `gate=maxConcurrent ...`). The reason includes the `bindingGates` (`maxConcurrent`/`maxWorktrees`/`semaphore`), per-gate `{ used, limit, slack }`, `holders`, and computed `available`. `maxConcurrent` is a per-project cap on enriched live top-level planning, execution, and review/merge agents; the host semaphore is the separate process-global pool. Free project capacity is admitted oldest-first across lanes, rather than by lane priority. `maxWorktrees` is also enforced inside `TaskStore.moveTaskInternal` when committing an allocated move into `in-progress`, making it a hard active execution worktree cap even when workflow WIP/`maxConcurrent` would allow more tasks. These queued-reason logs are transition-only: a newly emitted line indicates the limiter signature changed or the condition cleared and later reappeared, not that a poll loop simply observed the same blocked state again. **Run audit endpoints:** - `GET /api/agents/:id/runs/:runId/audit` — Returns audit trail for a specific agent run diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index ee7adf8bcb..9c726c6658 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -1258,7 +1258,7 @@ Features: -- **Overview controls dashboard** sits at the top of the Overview landing surface on desktop and mobile. It includes AI engine stop/start backed by `globalPause`, live scheduler status from executor stats, the shared Global Max Concurrent slider backed by `/api/global-concurrency`, range sliders for `maxConcurrent`, `maxTriageConcurrent`, and `maxWorktrees` that persist through `/api/settings`, and a compact theme dropdown with the same color-chip swatches and Shadcn variant list as Settings → Appearance. The four concurrency sliders ask for confirmation after a changed value settles; confirming persists the new cap, while cancel, backdrop, or Escape dismissal reverts to the last persisted value without saving. The global and current-project max-concurrent sliders show running-agent counts plus a current-use dot on the track once utilization data loads; triage and worktree sliders remain cap-only. These controls reuse existing APIs and App-level theme setters; they do not add a new backend route or second theme owner. +- **Overview controls dashboard** includes AI engine stop/start backed by `globalPause`, the shared Global Max Concurrent slider, and current-project **Max concurrency** plus **Max worktrees** controls. Max concurrency caps top-level working agents across planning, execution, and review/merge; free capacity is admitted oldest-first within the project. The footer reports **Waiting**, **Running (N/max)**, and **Blocked**; column headers report active/total. Nested helper agents remain parent-internal and may temporarily exceed the displayed top-level count. - **Team tab — Org export / import** lets an operator download a portable organization JSON bundle or paste one for a dry-run preview before confirming the apply step. Exports are secret-scrubbed by default: credentials and tokens are never included, while safe secret references can remain for setup in the destination project. - **Configuration versions** lives in **Settings → Project → Configuration Versions**. It lists recorded project-setting revisions newest first; select **Roll back** on any revision and confirm once to restore it. The restore is recorded as a new forward revision, so it can itself be undone without manually reconstructing settings. @@ -1451,7 +1451,7 @@ Use this panel when upgrading a project with pre-FN-6245/FN-6277 in-review rows -The global AI engine stop/start control and triage pause/resume control live in the executor footer status bar rather than the header. Select the small engine-controls button beside the executor state badge, or select the state text such as **Running**, to open the footer popover. The popover includes **Stop AI engine** / **Start AI engine**, **Pause triage** / **Resume scheduling**, and live scheduler sliders for max concurrent tasks, max triage concurrency, and max worktrees. On mobile, narrow tablets, and tablet landscape, the same controls open as a full-width bottom panel above the executor footer and mobile navigation so the close button and sliders remain reachable. Use the visible **Close engine controls** X button, Escape, or outside-click to dismiss it. The global and current-project concurrency sliders also show how many agents are running, including actively-triaging planners (`triage` + `planning`, not paused), and a dot on the slider track for current use. The dot uses the same min-relative range coordinates and thumb-size edge inset as the native slider: it aligns to the cap-clamped running count, so one running agent at the slider minimum stays visible at the start and over-cap usage pins to the cap thumb instead of the expanded track end. Changed concurrency slider values ask for confirmation after the value settles. Confirming saves the global cap through `/api/global-concurrency` and project caps through `/api/settings`; cancel, backdrop dismissal, Escape, close, outside-click, or unmount reverts unconfirmed slider edits without saving. Multiple changed project sliders within one debounce window are summarized in one confirmation dialog, matching Command Center behavior. +The global AI engine stop/start control and triage pause/resume control live in the executor footer status bar rather than the header. Select the small engine-controls button beside the executor state badge, or select the state text such as **Running**, to open the footer popover. The popover includes **Stop AI engine** / **Start AI engine**, **Pause triage** / **Resume scheduling**, and live scheduler sliders for **Max concurrency** and max worktrees. Max concurrency is the per-project top-level working-agent cap across planning, execution, and review/merge; nested helpers remain parent-internal and can temporarily exceed the displayed count. On mobile, narrow tablets, and tablet landscape, the same controls open as a full-width bottom panel above the executor footer and mobile navigation so the close button and sliders remain reachable. Use the visible **Close engine controls** X button, Escape, or outside-click to dismiss it. The global and current-project concurrency sliders show the shared live top-level agent count and a dot on the slider track for current use. The dot uses the same min-relative range coordinates and thumb-size edge inset as the native slider: it aligns to the cap-clamped running count, so one running agent at the slider minimum stays visible at the start and over-cap usage pins to the cap thumb instead of the expanded track end. Changed concurrency slider values ask for confirmation after the value settles. Confirming saves the global cap through `/api/global-concurrency` and project caps through `/api/settings`; cancel, backdrop dismissal, Escape, close, outside-click, or unmount reverts unconfirmed slider edits without saving. Multiple changed project sliders within one debounce window are summarized in one confirmation dialog, matching Command Center behavior. Brief, single-poll executor stats fetch blips keep showing the last good footer stats instead of flashing **Connecting…**. Routine executor stats heartbeats also keep the populated footer mounted after initial load, so an open engine/concurrency popover stays open while counts refresh. The footer only switches to **Connecting…** for sustained suspension-like stats failures, or to an explicit error state for non-transient failures. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index e20597e5d1..8d9ff43824 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -430,9 +430,9 @@ Security-sensitive file-browser escape hatches are project-only. `allowAbsoluteF | `globalPause` | `boolean` | `false` | Hard stop: terminate active engine sessions and pause scheduling immediately. | | `globalPauseReason` | `string` | `undefined` | Optional reason for `globalPause` (`"rate-limit"` for automatic pauses, `"manual"` for user-triggered pauses). Cleared on unpause. | | `enginePaused` | `boolean` | `false` | Soft pause: stop dispatching new work while letting active sessions finish. While paused (including shared pause windows with `globalPause`), stuck-task polling/timers are suspended so paused wall-clock time does not count against `taskStuckTimeoutMs`. Clearing pause state resumes runtime scheduling and gives tracked active sessions a fresh stuck-task grace window before normal detection resumes; when `autoMerge` is enabled, eligible `in-review` tasks are re-swept into the auto-merge queue (paused/blocked/failed review tasks remain skipped). | -| `maxConcurrent` | `number` | `2` | Max concurrent task-lane AI agents (planning, executor, merge). Editable from Settings and the Command Center Overview controls dashboard. | +| `maxConcurrent` | `number` | `2` | Max concurrency for top-level working agents per project across planning, execution, and review/merge. Nested helper agents remain parent-internal and may temporarily exceed this displayed count. Editable from Settings, Command Center, and Engine Control. | | `maxConcurrentVerifications` | `number` | `1` | Max concurrent verification subprocesses (`fn_run_verification`, merge test/build commands) process-wide. Caps stacked monorepo typecheck/build so concurrent tasks do not peg host CPU. Range **1–8** (clamped at runtime and in Settings). Editable from Settings → Scheduling. Each project engine registers its cap; the effective process limit is the **minimum** of registered project caps. | -| `maxTriageConcurrent` | `number` | `2` | Max concurrent planning agents. Editable from Settings and the Command Center Overview controls dashboard. | +| `maxTriageConcurrent` | `number` | `2` | Legacy persisted value; ignored. Planning shares `maxConcurrent` and no Max Triage control is displayed. | | `globalMaxConcurrent` | `number` | `4` | System-wide max concurrent agents across all projects. | | `maxWorktrees` | `number` | `4` | Max git worktrees. Editable from Settings and the Command Center Overview controls dashboard. | | `pollIntervalMs` | `number` | `15000` | Scheduler poll interval (ms). | diff --git a/packages/cli/skill/fusion/references/fusion-capabilities.md b/packages/cli/skill/fusion/references/fusion-capabilities.md index 670120c99b..9cb638c5eb 100644 --- a/packages/cli/skill/fusion/references/fusion-capabilities.md +++ b/packages/cli/skill/fusion/references/fusion-capabilities.md @@ -155,8 +155,8 @@ Structured runtime metadata is authoritative in PostgreSQL. A retained `fusion.d | Setting | Default | Description | |---------|---------|-------------| -| `maxConcurrent` | 2 | Concurrent task execution lanes (executor + merge). Triage/specification is controlled by `maxTriageConcurrent`. | -| `maxTriageConcurrent` | 2 | Concurrent triage/specification agents. Falls back to `maxConcurrent` when undefined. | +| `maxConcurrent` | 2 | Per-project cap for top-level working agents across planning, execution, and review/merge. Free slots admit the oldest eligible task; nested helpers are parent-internal. | +| `maxWorktrees` | 4 | Separate cap for execution worktree holders; it does not define the live Running count. | | `autoMerge` | true | Auto-merge completed tasks | | `requirePlanApproval` | false | Manual approval for specs | | `prCompletionMode` | direct | Completion mode: direct/pr-first | diff --git a/packages/core/src/__tests__/live-agent-count.test.ts b/packages/core/src/__tests__/live-agent-count.test.ts index 2bf0d1a2e0..1203429093 100644 --- a/packages/core/src/__tests__/live-agent-count.test.ts +++ b/packages/core/src/__tests__/live-agent-count.test.ts @@ -1,64 +1,67 @@ import { describe, expect, it } from "vitest"; -import { countRunningAgentTasks, deriveRunningAgentCounts, isRunningAgentTask } from "../live-agent-count.js"; -import type { Task } from "../types.js"; +import { + countRunningAgentTasks, + deriveRunningAgentCounts, + enrichRunningAgentTaskShapeFromFlags, + isRunningAgentTask, + isWaitingAgentTask, +} from "../live-agent-count.js"; +import type { RunningAgentTaskShape } from "../live-agent-count.js"; -function task(overrides: Pick & Partial>): Pick { - return { - column: overrides.column, - status: overrides.status, - paused: overrides.paused, - }; +function task(overrides: Partial & Pick): RunningAgentTaskShape { + return { columnTerminalKind: "none", ...overrides }; } describe("live agent count predicates", () => { - it("identifies tasks that hold top-level running-agent slots", () => { - expect(isRunningAgentTask(task({ column: "in-progress" }))).toBe(true); - expect(isRunningAgentTask(task({ column: "triage", status: "planning", paused: false }))).toBe(true); - expect(isRunningAgentTask(task({ column: "triage", status: "planning", paused: true }))).toBe(false); + it("counts live planners in every non-terminal workflow lane", () => { + expect(isRunningAgentTask(task({ column: "todo", status: "planning" }))).toBe(true); + expect(isRunningAgentTask(task({ column: "ideas", status: "planning" }))).toBe(true); + expect(isRunningAgentTask(task({ column: "ideas", status: "planning", paused: true }))).toBe(false); + expect(isRunningAgentTask(task({ column: "ideas", status: "planning", userPaused: true }))).toBe(false); + }); + it("requires durable liveness for WIP execution rather than an in-progress shell", () => { + expect(isRunningAgentTask(task({ column: "in-progress", columnCountsTowardWip: true }))).toBe(false); + expect(isRunningAgentTask(task({ column: "in-progress", columnCountsTowardWip: true, sessionFile: "/tmp/run" }))).toBe(true); + expect(isRunningAgentTask(task({ column: "in-progress", columnCountsTowardWip: true, checkedOutBy: "agent-a" }))).toBe(true); + expect(isRunningAgentTask(task({ column: "in-progress", columnCountsTowardWip: true, sessionFile: "/tmp/run", paused: true }))).toBe(false); + }); + + it("counts only active review/merge statuses and excludes terminal columns", () => { for (const status of ["merging", "merging-pr", "merging-fix", "reviewing", "landing", "fixing"]) { - expect(isRunningAgentTask(task({ column: "in-review", status, paused: false }))).toBe(true); + expect(isRunningAgentTask(task({ column: "review", status, columnIsReviewOrMerge: true }))).toBe(true); } - - expect(isRunningAgentTask(task({ column: "in-review", paused: false }))).toBe(false); - expect(isRunningAgentTask(task({ column: "in-review", status: "pending", paused: false }))).toBe(false); - expect(isRunningAgentTask(task({ column: "in-review", status: "reviewing", paused: true }))).toBe(false); - expect(isRunningAgentTask(task({ column: "done" }))).toBe(false); - expect(isRunningAgentTask(task({ column: "todo" }))).toBe(false); - expect(isRunningAgentTask(task({ column: "archived" }))).toBe(false); + expect(isRunningAgentTask(task({ column: "review", status: "pending", columnIsReviewOrMerge: true }))).toBe(false); + expect(isRunningAgentTask(task({ column: "ideas", status: "merging", columnIsReviewOrMerge: false }))).toBe(false); + expect(isRunningAgentTask(task({ column: "shipped", sessionFile: "/tmp/stale", columnCountsTowardWip: true, columnTerminalKind: "complete" }))).toBe(false); + expect(isRunningAgentTask(task({ column: "working", sessionFile: "/tmp/live", columnCountsTowardWip: true, columnTerminalKind: "none" }))).toBe(true); }); - it("counts only tasks that satisfy the shared running-agent predicate", () => { + it("enriches terminal, waiting, and WIP traits from board flags", () => { + const complete = enrichRunningAgentTaskShapeFromFlags(task({ column: "shipped", sessionFile: "/tmp/stale" }), { complete: true, countsTowardWip: true }); + expect(complete.columnTerminalKind).toBe("complete"); + expect(isRunningAgentTask(complete)).toBe(false); + + const intake = enrichRunningAgentTaskShapeFromFlags(task({ column: "ideas" }), { intake: true }); + expect(isWaitingAgentTask(intake)).toBe(true); + expect(isWaitingAgentTask({ ...intake, status: "planning" })).toBe(false); + expect(isWaitingAgentTask(enrichRunningAgentTaskShapeFromFlags(task({ column: "hold" }), { hold: true }))).toBe(true); + }); + + it("counts only the shared predicate", () => { expect(countRunningAgentTasks([ + task({ column: "in-progress", sessionFile: "/tmp/run" }), task({ column: "in-progress" }), - task({ column: "triage", status: "planning", paused: false }), - task({ column: "triage", status: "planning", paused: true }), - task({ column: "in-review", status: "merging", paused: false }), - task({ column: "in-review", status: "merging-pr", paused: false }), - task({ column: "in-review", status: "merging-fix", paused: false }), - task({ column: "in-review", status: "reviewing", paused: false }), - task({ column: "in-review", status: "landing", paused: false }), - task({ column: "in-review", status: "fixing", paused: false }), - task({ column: "in-review", status: "fixing", paused: true }), - task({ column: "todo" }), - task({ column: "done" }), - task({ column: "archived" }), - ])).toBe(8); + task({ column: "triage", status: "planning" }), + task({ column: "in-review", status: "merging", columnIsReviewOrMerge: true }), + task({ column: "done", sessionFile: "/tmp/stale" }), + ])).toBe(3); }); - it("normalizes display counts for zero, one, multi-project, unopened, and oversubscribed states", () => { - expect(deriveRunningAgentCounts({})).toEqual({ currentlyActive: 0, projectsActive: {} }); - expect(deriveRunningAgentCounts({ proj_zero: 0, proj_one: 1 })).toEqual({ + it("normalizes aggregate display counts", () => { + expect(deriveRunningAgentCounts({ proj_zero: 0, proj_one: 1, proj_nan: Number.NaN })).toEqual({ currentlyActive: 1, projectsActive: { proj_one: 1 }, }); - expect(deriveRunningAgentCounts({ proj_a: 2, proj_b: 4, proj_unopened: 0 })).toEqual({ - currentlyActive: 6, - projectsActive: { proj_a: 2, proj_b: 4 }, - }); - expect(deriveRunningAgentCounts({ proj_over_limit: 12, proj_negative: -3, proj_nan: Number.NaN })).toEqual({ - currentlyActive: 12, - projectsActive: { proj_over_limit: 12 }, - }); }); }); diff --git a/packages/core/src/index.gate.ts b/packages/core/src/index.gate.ts index dfec191174..aaff1a78cd 100644 --- a/packages/core/src/index.gate.ts +++ b/packages/core/src/index.gate.ts @@ -566,7 +566,13 @@ export { getRunningAgentCountSource, deriveRunningAgentCounts, isRunningAgentTask, + isWaitingAgentTask, countRunningAgentTasks, + enrichRunningAgentTaskShape, + enrichRunningAgentTaskShapeFromFlags, + resolveColumnTerminalKind, + type RunningAgentTaskShape, + type ColumnTerminalKind, type RunningAgentCountSource, type RunningAgentCounts, } from "./live-agent-count.js"; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 63d430d569..455b617cad 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -656,7 +656,13 @@ export { getRunningAgentCountSource, deriveRunningAgentCounts, isRunningAgentTask, + isWaitingAgentTask, countRunningAgentTasks, + enrichRunningAgentTaskShape, + enrichRunningAgentTaskShapeFromFlags, + resolveColumnTerminalKind, + type RunningAgentTaskShape, + type ColumnTerminalKind, type RunningAgentCountSource, type RunningAgentCounts, } from "./live-agent-count.js"; diff --git a/packages/core/src/live-agent-count.ts b/packages/core/src/live-agent-count.ts index 2917771f06..a9b4502de4 100644 --- a/packages/core/src/live-agent-count.ts +++ b/packages/core/src/live-agent-count.ts @@ -1,13 +1,34 @@ import { ACTIVE_MERGE_PIPELINE_STATUSES } from "./active-merge-status.js"; +import type { TraitFlags } from "./trait-types.js"; import type { Task } from "./types.js"; +import type { WorkflowIr } from "./workflow-ir-types.js"; +import { columnHasFlag } from "./workflow-lifecycle-traits.js"; export type RunningAgentCountSource = (projectIds: readonly string[]) => Promise> | Record; -type RunningAgentTaskShape = Pick; +/** Terminal classification supplied by a workflow-IR or board-flags enricher. */ +export type ColumnTerminalKind = "none" | "complete" | "archived"; + +/** + * The deliberately small, pure shape used by all top-level live-agent counts. + * Store- and board-backed callers must attach trait-derived fields first. + */ +export type RunningAgentTaskShape = Pick & { + columnTerminalKind?: ColumnTerminalKind; + /** Trait-derived intake/hold membership, used by {@link isWaitingAgentTask}. */ + columnIsIntakeOrHold?: boolean; + /** Trait-derived WIP membership; legacy fixtures fall back to in-progress. */ + columnCountsTowardWip?: boolean; + /** Trait-derived review/merge membership; active merge statuses are live only here. */ + columnIsReviewOrMerge?: boolean; +}; /* -FNXC:MergeQueue 2026-07-15-10:40: -In-review live agents include the full AI merge pipeline (merging/reviewing/landing) plus fix-pass and generic fixing statuses so utilization counts stay honest during clean-room review and land. +FNXC:ConcurrencyIndicators 2026-08-03-12:00: +FN-8453 / GitHub #2359 defines Running as a live top-level working agent, not a +board-column or worktree-holder count. Every production store- or board-backed +consumer enriches this pure shape from workflow traits before it counts; the +literal terminal fallback exists only for legacy fixtures while no IR is loaded. */ const ACTIVE_IN_REVIEW_AGENT_STATUSES = new Set([ ...ACTIVE_MERGE_PIPELINE_STATUSES, @@ -16,17 +37,10 @@ const ACTIVE_IN_REVIEW_AGENT_STATUSES = new Set([ let runningAgentCountSource: RunningAgentCountSource | undefined; -/** - * FNXC:GlobalConcurrencyControls 2026-06-26-17:22: - * Live running-agent counts must come from side-effect-safe reads of `in-progress` task columns, not from stale slot or health bookkeeping. This DI seam lets dashboard, CLI, remote-node, and plugin consumers share one core path without starting project engines/runtimes, opening watchers, or mutating `globalConcurrency.currentlyActive`, `globalConcurrency.queuedCount`, or `projectHealth.inFlightAgentCount`. - */ export function setRunningAgentCountSource(fn: RunningAgentCountSource | undefined): void { runningAgentCountSource = fn; } -/** - * Returns the registered side-effect-safe running-agent count source, if one has been wired by the host process. - */ export function getRunningAgentCountSource(): RunningAgentCountSource | undefined { return runningAgentCountSource; } @@ -36,24 +50,65 @@ export interface RunningAgentCounts { projectsActive: Record; } +/** Resolve the terminal classification of one column from its workflow IR. */ +export function resolveColumnTerminalKind(columnId: string, ir: WorkflowIr): ColumnTerminalKind { + if (columnHasFlag(ir, columnId, "archived")) return "archived"; + if (columnHasFlag(ir, columnId, "complete")) return "complete"; + return "none"; +} + +/** Attach the workflow traits required by the pure Running and Waiting predicates. */ +export function enrichRunningAgentTaskShape(task: T, ir: WorkflowIr): T & Required> { + return { + ...task, + columnTerminalKind: resolveColumnTerminalKind(task.column, ir), + columnIsIntakeOrHold: columnHasFlag(ir, task.column, "intake") || columnHasFlag(ir, task.column, "hold"), + columnCountsTowardWip: columnHasFlag(ir, task.column, "countsTowardWip"), + columnIsReviewOrMerge: columnHasFlag(ir, task.column, "mergeOrchestration") || columnHasFlag(ir, task.column, "mergeBlocker"), + }; +} + +/** Attach the same traits from dashboard board-column flags without loading an IR. */ +export function enrichRunningAgentTaskShapeFromFlags(task: T, flags?: Pick): T & Required> { + return { + ...task, + columnTerminalKind: flags?.archived ? "archived" : flags?.complete ? "complete" : "none", + columnIsIntakeOrHold: flags ? flags.intake === true || flags.hold === true : task.column === "triage" || task.column === "todo", + columnCountsTowardWip: flags ? flags.countsTowardWip === true : task.column === "in-progress", + // The literal fallback is fixture-only; board/store callers always supply flags/IR. + columnIsReviewOrMerge: flags ? flags.mergeOrchestration === true || flags.mergeBlocker === true : task.column === "in-review", + }; +} + +function terminalKind(task: RunningAgentTaskShape): ColumnTerminalKind { + // Legacy literals are intentionally fixture-only degradation when workflow IR is unavailable. + return task.columnTerminalKind ?? (task.column === "done" ? "complete" : task.column === "archived" ? "archived" : "none"); +} + +function hasDurableExecuteLiveness(task: RunningAgentTaskShape): boolean { + return Boolean(task.sessionFile?.trim() || task.checkedOutBy?.trim()); +} + /** - * FNXC:GlobalConcurrencyControls 2026-06-27-00:00: - * FN-7160 defines live running-agent counts as top-level concurrency slot holders: in-progress executors, active unpaused triage planners, and active unpaused in-review reviewer/merger/fix agents, including PR/fix merge substates. Keep this pure predicate as the shared source of truth for engine slot accounting and all dashboard/CLI read-layer count surfaces so in-review agents cannot drift out of utilization displays again. + * Returns true only for a live, unpaused top-level agent. + * Planning may run in any non-terminal workflow column; execute needs durable + * session/checkout evidence so idle worktree shells do not consume capacity. */ export function isRunningAgentTask(task: RunningAgentTaskShape): boolean { - if (task.column === "in-progress") { - return true; + if (task.paused || task.userPaused || terminalKind(task) !== "none") return false; + if (task.status === "planning") return true; + // Review statuses are not globally live: a stale status in intake/WIP must not consume capacity. + if (ACTIVE_IN_REVIEW_AGENT_STATUSES.has(String(task.status ?? ""))) { + return task.columnIsReviewOrMerge ?? task.column === "in-review"; } + const isWip = task.columnCountsTowardWip ?? task.column === "in-progress"; + return isWip && hasDurableExecuteLiveness(task); +} - if (task.column === "triage") { - return task.status === "planning" && !task.paused; - } - - if (task.column === "in-review") { - return ACTIVE_IN_REVIEW_AGENT_STATUSES.has(String(task.status ?? "")) && !task.paused; - } - - return false; +/** Exact footer waiting membership: unpaused, non-terminal intake/hold work that is not live. */ +export function isWaitingAgentTask(task: RunningAgentTaskShape): boolean { + if (task.paused || task.userPaused || terminalKind(task) !== "none" || isRunningAgentTask(task)) return false; + return task.columnIsIntakeOrHold ?? (task.column === "triage" || task.column === "todo"); } export function countRunningAgentTasks(tasks: readonly RunningAgentTaskShape[]): number { @@ -63,14 +118,10 @@ export function countRunningAgentTasks(tasks: readonly RunningAgentTaskShape[]): export function deriveRunningAgentCounts(perProject: Record): RunningAgentCounts { const projectsActive: Record = {}; let currentlyActive = 0; - for (const [projectId, rawCount] of Object.entries(perProject)) { const count = Number.isFinite(rawCount) ? Math.max(0, Math.trunc(rawCount)) : 0; currentlyActive += count; - if (count > 0) { - projectsActive[projectId] = count; - } + if (count > 0) projectsActive[projectId] = count; } - return { currentlyActive, projectsActive }; } diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index 15b5eadd70..c80f92d8ad 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -29,6 +29,8 @@ import { useBackgroundSessions } from "./hooks/useBackgroundSessions"; import { useGitHubStarPromptShown, markGitHubStarPromptShown } from "./hooks/useGitHubStarPrompt"; import { useSessionBannersHidden } from "./hooks/useSessionBannerPref"; import { useTasks } from "./hooks/useTasks"; +import { useBoardWorkflows } from "./hooks/useBoardWorkflows"; +import type { ExecutorColumnFlags } from "./hooks/useExecutorStats"; import { useProjects } from "./hooks/useProjects"; import { useAgents } from "./hooks/useAgents"; import { useNodes } from "./hooks/useNodes"; @@ -528,6 +530,29 @@ function AppInner() { sseEnabled: taskSseEnabled, } ); + const { boardWorkflows: footerBoardWorkflows } = useBoardWorkflows({ projectId: currentProject?.id }); + const footerTasks = isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks; + const footerColumnFlagsByTaskId = useMemo(() => { + const index = new Map(); + // FNXC:ConcurrencyIndicators 2026-08-04-10:00: remote tasks belong to a + // different store, so local board-workflow metadata must never be applied to + // their ids. Until the remote node supplies its own traits, use only the + // documented literal fallback rather than fabricate custom lifecycle state. + if (isRemote || !footerBoardWorkflows) return index; + const workflowsById = new Map(footerBoardWorkflows.workflows.map((workflow) => [workflow.id, workflow])); + // Build traits for the exact local rows supplied to the footer. + for (const task of footerTasks) { + const workflow = workflowsById.get(footerBoardWorkflows.taskWorkflowIds[task.id] ?? footerBoardWorkflows.defaultWorkflowId); + const flags = workflow?.columns.find((column) => column.id === task.column)?.flags; + if (flags) index.set(task.id, flags); + } + return index; + }, [footerBoardWorkflows, footerTasks, isRemote]); + /* + FNXC:ConcurrencyIndicators 2026-08-03-12:00: + FN-8453 threads board workflow traits into the footer so custom intake, + complete, WIP, and merge columns share the same live-agent predicate as the engine. + */ /* FNXC:Navigation 2026-06-22-00:00: @@ -1846,8 +1871,9 @@ function AppInner() { {rightDock.modal} {executorFooterVisible && currentProject && ( 0 ? remoteData.tasks : tasks} + tasks={footerTasks} projectId={currentProject.id} + columnFlagsByTaskId={footerColumnFlagsByTaskId} taskStuckTimeoutMs={taskStuckTimeoutMs} staleHighFanoutBlockerAgeThresholdMs={staleHighFanoutBlockerAgeThresholdMs} lastFetchTimeMs={lastFetchTimeMs} diff --git a/packages/dashboard/app/api/board-workflows.ts b/packages/dashboard/app/api/board-workflows.ts index da257bc9e9..e92a2f910c 100644 --- a/packages/dashboard/app/api/board-workflows.ts +++ b/packages/dashboard/app/api/board-workflows.ts @@ -42,6 +42,8 @@ export interface BoardWorkflowColumnFlags { hold?: boolean; intake?: boolean; mergeBlocker?: boolean; + /** Merge/review lane membership used by the shared live-agent predicate. */ + mergeOrchestration?: boolean; humanReview?: boolean; [key: string]: boolean | undefined; } diff --git a/packages/dashboard/app/components/Column.tsx b/packages/dashboard/app/components/Column.tsx index e5295f3eeb..b67990929e 100644 --- a/packages/dashboard/app/components/Column.tsx +++ b/packages/dashboard/app/components/Column.tsx @@ -4,6 +4,7 @@ import { useFlashOnIncrease } from "../hooks/useFlashOnIncrease"; import { useConfirm } from "../hooks/useConfirm"; import type { Task, TaskDetail, Column as ColumnType, ColumnId, TaskCreateInput, GithubIssueAction, MergeResult } from "@fusion/core"; import { COLUMN_LABELS, COLUMN_DESCRIPTIONS, getErrorMessage } from "@fusion/core"; +import { enrichRunningAgentTaskShapeFromFlags, isRunningAgentTask } from "../../../core/src/live-agent-count"; import { isNearDuplicateCanonicalInactive } from "../../../core/src/near-duplicate-canonical"; import { TaskCard } from "./TaskCard"; import { WorktreeGroup } from "./WorktreeGroup"; @@ -286,6 +287,9 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktree The project setting is an explicit show/hide control: worktree grouping and labels render only when enabled and only for the board's WIP/processing column. Turning it off must leave plain task cards with no legacy group shell in either legacy or workflow-mode columns. */ const showWorktreeGroups = showWorktreeGrouping === true && isWipProcessingColumn; + const activeTaskCount = useMemo(() => tasks.filter((task) => + isRunningAgentTask(enrichRunningAgentTaskShapeFromFlags(task, columnFlags)), + ).length, [tasks, columnFlags]); // When search is active, skip pagination so all matching tasks are visible const shouldPaginate = !isArchived && !isSearchActive && !showWorktreeGroups && tasks.length > PAGINATED_COLUMN_THRESHOLD; @@ -656,7 +660,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktree

{workflowMode ? (columnDisplayName ?? COLUMN_LABELS[column] ?? column) : COLUMN_LABELS[column]}

- {tasks.length} + {activeTaskCount}/{tasks.length} {(workflowMode ? isReviewColumn : column === "in-review") && onToggleAutoMerge && ( - -