Files
fusion/packages/dashboard/app/utils/worktreeGrouping.ts
gsxdsm a010dc42bf FN-9185: enforce effective task concurrency limits
Unify task concurrency around authoritative project settings and expose the effective admission ceiling.

- Resolve maxConcurrent and maxWorktrees through a shared core capacity model with consistent defaults.
- Apply the effective limit across scheduler, engine, runtime, CLI, API, and project-manager admission paths.
- Surface configured and binding concurrency values in dashboard controls, status views, diagnostics, and documentation.
- Add regression coverage for settings persistence, routing, scheduling, UI surfaces, and worktree-bound capacity.

Files changed:
 .../fn-9185-max-concurrent-effective-limit.md      |   7 +
 docs/architecture.md                               |   8 +-
 docs/cli-reference.md                              |   1 +
 docs/dashboard-guide.md                            |   4 +
 docs/settings-reference.md                         |   4 +-
 .../dashboard-concurrency-settings.test.ts         | 166 +++++++++++++++++++++
 packages/cli/src/commands/dashboard.ts             |  28 ++--
 packages/cli/src/commands/onboard.ts               |   4 +-
 packages/cli/src/project-resolver.ts               |  27 +++-
 .../concurrency-capacity-resolver.test.ts          |  46 ++++++
 .../src/__tests__/worktree-capacity-limit.test.ts  |  51 +++++--
 packages/core/src/index.gate.ts                    |   2 +-
 packages/core/src/index.ts                         |   2 +-
 packages/core/src/workflows/workflow-capacity.ts   |  61 ++++++--
 packages/dashboard/app/App.tsx                     |   2 +
 .../app/__tests__/concurrency-ui-surfaces.test.tsx | 155 +++++++++++++++++++
 .../scheduling-effective-concurrency.test.tsx      |  48 ++++++
 packages/dashboard/app/api/projects/projects.ts    |  10 +-
 packages/dashboard/app/api/settings/settings.ts    |  14 +-
 packages/dashboard/app/components/Board.tsx        |   7 +-
 packages/dashboard/app/components/Column.tsx       |  13 +-
 .../dashboard/app/components/EngineControlMenu.tsx |  17 ++-
 .../command-center/CommandCenterControls.tsx       |  17 ++-
 .../components/command-center/areas/TeamArea.tsx   |  10 +-
 .../app/components/dashboard/MainContent.tsx       |   3 +
 .../dashboard/app/components/dashboard/types.ts    |   2 +
 .../settings/sections/SchedulingSection.tsx        |  12 +-
 packages/dashboard/app/hooks/useAppSettings.ts     |   9 +-
 packages/dashboard/app/hooks/useExecutorStats.ts   |  10 +-
 .../app/utils/__tests__/worktreeGrouping.test.ts   |  12 +-
 packages/dashboard/app/utils/worktreeGrouping.ts   |   8 +-
 packages/dashboard/src/routes.ts                   |   6 +-
 ...concurrency-authoritative-source-routes.test.ts |  90 +++++++++++
 .../register-config-mcp-pi-settings-routes.test.ts |  29 +++-
 .../register-config-mcp-pi-settings-routes.ts      |  17 ++-
 .../src/routes/register-project-routes.ts          |  14 +-
 .../concurrency-effective-limit-surfaces.test.ts   |  34 +++++
 .../src/__tests__/project-engine-manager.test.ts   |  19 +++
 .../__tests__/scheduler-workflow-cutover.test.ts   |   6 +-
 packages/engine/src/concurrency/concurrency.ts     |  23 ++-
 packages/engine/src/concurrency/hybrid-executor.ts |  35 ++++-
 .../engine/src/executor/create-spawn-agent-tool.ts |   8 +-
 packages/engine/src/project-engine-manager.ts      |  50 +++++--
 packages/engine/src/project-engine.ts              |  12 +-
 packages/engine/src/project/project-manager.ts     |  26 +++-
 packages/engine/src/runtimes/in-process-runtime.ts |  12 +-
 packages/engine/src/scheduler.ts                   |  31 ++--
 packages/engine/src/triage.ts                      |  15 +-
 48 files changed, 1028 insertions(+), 159 deletions(-)

Fusion-Task-Id: FN-9185
Fusion-Task-Lineage: 6294456c-d417-40a5-a5fa-2035c6b964a6
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-08-21 10:57:44 -07:00

203 lines
7.7 KiB
TypeScript

import { isWorkspaceTask, type Task } from "@fusion/core";
import { getPathBasename } from "./pathDisplay";
import { isArchivedColumnRole, isCompleteColumnRole, isHoldColumnRole, isReviewColumnRole } from "./columnRoles";
export interface WorktreeGroupData {
/** Stable identity; display labels collide for separate worktree paths. */
id: string;
kind: "worktree" | "workspace" | "unassigned" | "up-next";
label: string;
repoCount?: number;
activeTasks: Task[];
queuedTasks: Task[];
}
/**
* Extract a clean display name from a worktree path.
* e.g. ".worktrees/FN-001" → "FN-001", "/path/to/fn/fn-001" → "fn-001"
*/
export function getWorktreeLabel(worktreePath: string): string {
return getPathBasename(worktreePath) || worktreePath;
}
/**
* Topological sort of tasks by dependency order.
* Mirrors resolveDependencyOrder from @fusion/core but inlined to avoid
* build alias issues (Vite aliases @fusion/core to types.ts only).
*/
function resolveDependencyOrder(tasks: Task[]): string[] {
const taskMap = new Map(tasks.map((t) => [t.id, t]));
const ordered: string[] = [];
const visited = new Set<string>();
const visiting = new Set<string>();
function visit(id: string): void {
if (visited.has(id)) return;
if (visiting.has(id)) return;
visiting.add(id);
const task = taskMap.get(id);
if (task) {
for (const depId of task.dependencies || []) {
if (taskMap.has(depId)) visit(depId);
}
}
visiting.delete(id);
visited.add(id);
ordered.push(id);
}
for (const task of tasks) visit(task.id);
return ordered;
}
/**
* Group in-progress tasks by worktree and collect queued todo tasks
* as visual previews in the "Up Next" group.
*
* Queued tasks (eligible "todo" tasks whose dependencies are all satisfied)
* are always placed in the "Up Next" group — they are never distributed
* to worktree-specific groups since they have no worktree assignment yet.
* The number of queued tasks shown is capped at the engine's effective concurrency ceiling.
*/
export function groupByWorktree(
inProgressTasks: Task[],
allTasks: Task[],
effectiveConcurrencyLimit: number,
/*
FNXC:WorkflowResolvedColumns 2026-07-29-00:00 (U12 — R8 drift conversion):
The ids of TASKS whose own column is a hold lane in their own workflow, when the caller
resolved them.
Task ids, not column ids (PR #2625 review — greptile). This helper scans `allTasks`, which
can span workflows, and column ids are namespaced per workflow — two workflows may both
declare `staging` with only one of them marking it `hold`. A unioned column-id set cannot
tell those apart and would list every executing card of the second workflow as waiting.
Only a card's own workflow can answer "is this waiting?", so the caller answers it per task
and passes the result.
Board resolves this; Lane does not pass it and keeps the legacy-id fallback.
*/
holdTaskIds?: ReadonlySet<string>,
/*
FNXC:WorkflowResolvedColumns 2026-07-30-11:30 (batch-dashboard-app):
Per-dependency column flags, mirroring the `holdTaskIds` seam directly above: the caller resolves,
this stays pure. Omitted -> the legacy ids, i.e. today's behaviour for every unconverted caller.
Without it, "are this card's dependencies satisfied?" was three legacy ids. On a renamed board a
FINISHED dependency matched none of them, so the dependent never appeared in the worktree view's
upcoming-work list — it looked like there was nothing ready to run while there was.
*/
dependencyColumnFlags?: ReadonlyMap<string, Parameters<typeof isCompleteColumnRole>[0]>,
): WorktreeGroupData[] {
/*
FNXC:Workspace 2026-08-20-20:05:
A populated workspaceWorktrees map is authoritative over a stale singular worktree delivered
before asynchronous store normalization. Classify it as workspace first so a one-repository
workspace cannot be hidden under an unrelated singular group; stable ids still prevent
basename collisions between acquired repository paths.
*/
const workspaceTasks = inProgressTasks.filter(isWorkspaceTask);
const assigned = inProgressTasks.filter((task) => !isWorkspaceTask(task) && Boolean(task.worktree));
const unassigned = inProgressTasks.filter((task) => !isWorkspaceTask(task) && !task.worktree);
// Group assigned tasks by worktree
const worktreeMap = new Map<string, Task[]>();
for (const task of assigned) {
const key = task.worktree!;
const list = worktreeMap.get(key) || [];
list.push(task);
worktreeMap.set(key, list);
}
/*
FNXC:WorkflowResolvedColumns 2026-07-29-00:00 (U12 — R8 drift conversion):
The filter wants "cards waiting for capacity" — the HOLD role, resolved from the board's
columns rather than the id `todo`.
THE BUG THIS CLOSES, measured rather than assumed: on the default board the id and the
role coincide (U11 gave `todo` the hold trait), so this looked healthy. On a board whose
hold column is renamed the filter matched NOTHING, so the worktree view showed no upcoming
work at all and read as idle — a whole panel silently empty, with nothing failing.
Dependency satisfaction below still names terminal ids. That is a separate question from
the hold role and is left alone deliberately: it needs `complete`/`mergeBlocker`/`archived`
traits for the DEPENDENCY's column, which is another lookup and another unit of work.
*/
// Find queued hold-lane tasks: cards in the hold column with all deps satisfied.
const taskById = new Map(allTasks.map((t) => [t.id, t]));
const isWaitingTask = (task: Task): boolean =>
holdTaskIds ? holdTaskIds.has(task.id) : isHoldColumnRole(undefined, task.column);
const todoTasks = allTasks.filter(isWaitingTask);
const eligible = todoTasks.filter((t) =>
!t.paused &&
(t.dependencies || []).every((depId) => {
const dep = taskById.get(depId);
if (!dep) return false;
const depFlags = dependencyColumnFlags?.get(dep.id);
/* Satisfied = the dependency rests in its OWN board's terminal pair, or its review lane —
the same union the scheduler's legacy rule uses, preserved exactly. */
return isCompleteColumnRole(depFlags, dep.column)
|| isArchivedColumnRole(depFlags, dep.column)
|| isReviewColumnRole(depFlags, dep.column);
}),
);
// Order eligible tasks by dependency order
const orderedIds = resolveDependencyOrder(eligible);
const orderedEligible = orderedIds
.map((id) => taskById.get(id))
.filter((t): t is Task => t !== undefined && eligible.includes(t));
// Build groups from worktree map
const groups: WorktreeGroupData[] = [];
const worktreeKeys = Array.from(worktreeMap.keys());
for (const key of worktreeKeys) {
groups.push({
id: key,
kind: "worktree",
label: getWorktreeLabel(key),
activeTasks: worktreeMap.get(key)!,
queuedTasks: [],
});
}
for (const task of workspaceTasks) {
const entries = task.workspaceWorktrees!;
const firstRepo = Object.keys(entries).sort()[0]!;
groups.push({
id: `workspace:${task.id}`,
kind: "workspace",
label: getWorktreeLabel(entries[firstRepo]!.worktreePath),
repoCount: Object.keys(entries).length,
activeTasks: [task],
queuedTasks: [],
});
}
// Add unassigned group if needed
if (unassigned.length > 0) {
groups.push({
id: "unassigned",
kind: "unassigned",
label: "Unassigned",
activeTasks: unassigned,
queuedTasks: [],
});
}
// All eligible queued tasks go into the "Up Next" group (capped at the effective ceiling).
const queued = orderedEligible.slice(0, effectiveConcurrencyLimit);
if (queued.length > 0) {
groups.push({
id: "up-next",
kind: "up-next",
label: "Up Next",
activeTasks: [],
queuedTasks: queued,
});
}
return groups;
}