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>
203 lines
7.7 KiB
TypeScript
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;
|
|
}
|