diff --git a/.changeset/capacity-worktrees-toggle.md b/.changeset/capacity-worktrees-toggle.md new file mode 100644 index 0000000000..6385e23b86 --- /dev/null +++ b/.changeset/capacity-worktrees-toggle.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Add a "Limit concurrent worktrees" toggle — turn it off and Max Concurrent Tasks becomes the only limit. +category: feature +dev: New project setting `worktreeLimitEnabled` (default true). It is CAPACITY ONLY: tasks still execute in their own git worktree with it off — it decides whether the worktree count is a second limit alongside the agent count. When false, `resolveWorktreeCapacityLimit` returns null and the scheduler builds no worktree gate at all, so `maxWorktrees` is structurally incapable of binding rather than merely generous; `ConcurrencyGateDiagnostic.maxWorktreesGate` is now optional and the queued-reason string omits the worktree line. Absent `worktreeLimitEnabled` reads as true, so existing projects keep their cap. Also deletes `maxTriageConcurrent`, which had zero enforcement reads since FN-8453 removed its pool — the `/config` response no longer includes it. diff --git a/packages/core/src/__tests__/merge-concurrency-not-configurable.test.ts b/packages/core/src/__tests__/merge-concurrency-not-configurable.test.ts new file mode 100644 index 0000000000..162850a0e7 --- /dev/null +++ b/packages/core/src/__tests__/merge-concurrency-not-configurable.test.ts @@ -0,0 +1,114 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { getTraitRegistry } from "../trait-registry.js"; +import { registerBuiltinTraits } from "../builtin-traits.js"; +import { DEFAULT_SETTINGS } from "../settings-schema.js"; + +/* +FNXC:CapacityModel 2026-07-28-10:15: +MERGE CONCURRENCY MUST NEVER BECOME A SETTING. + +The capacity model is two CONFIGURABLE numbers per project (total agents, +maxWorktrees) plus one FIXED invariant: exactly one merge in flight per project. +The enforcement lives in the engine's merge pump and is ratcheted by +`packages/engine/src/__tests__/merge-single-flight-invariant.test.ts`. + +THIS file guards the other direction — that no one makes the fixed number +configurable. A merge-concurrency knob would not fail the pump ratchet: it would +sit unread for a release (like `maxTriageConcurrent`, which shipped in the schema, +the Settings UI and i18n while being read by ZERO enforcement sites) and then get +wired up by someone who assumed a setting that exists must mean something. Merge +is where the irreversible work happens; every merge-safety guard assumes one. +*/ + +const HERE = dirname(fileURLToPath(import.meta.url)); +const SETTINGS_SOURCES = [ + join(HERE, "..", "settings-schema.ts"), + join(HERE, "..", "types", "settings-scope.ts"), + join(HERE, "..", "types", "merge-policy.ts"), +]; + +/** + * An identifier is a merge-concurrency knob when it names BOTH merge and a + * capacity concept. Requiring both halves keeps `maxConcurrentVerifications` + * (capacity, not merge) and `mergeIntegrationWorktree` (merge, not capacity) out + * of the net, which is what makes a hit here meaningful rather than noise. + */ +const MERGE_CONCURRENCY_IDENTIFIER = + /\b[A-Za-z_$][A-Za-z0-9_$]*\b/g; +const NAMES_MERGE = /merge/i; +const NAMES_CAPACITY = /(concurren|parallel|lane|slot|worker|simultaneous|inflight|in_flight)/i; + +function findMergeConcurrencyIdentifiers(source: string): string[] { + const hits = new Set(); + for (const ident of source.match(MERGE_CONCURRENCY_IDENTIFIER) ?? []) { + if (NAMES_MERGE.test(ident) && NAMES_CAPACITY.test(ident)) hits.add(ident); + } + return [...hits]; +} + +describe("merge concurrency is fixed at 1 and cannot be made configurable", () => { + /* + SELF-CHECK FIRST. A source-scanning guard that silently reads nothing, or whose + pattern no longer matches its own defect, reports success while checking + nothing — the failure mode this program has hit repeatedly (the pool-id sentinel + that never matched, the ratchet whose regex missed its own case). Prove the + detector works on a known positive and a known negative BEFORE trusting a pass. + */ + it("the detector actually detects (positive and negative controls)", () => { + expect(findMergeConcurrencyIdentifiers("maxConcurrentMerges: 2,")).toEqual(["maxConcurrentMerges"]); + expect(findMergeConcurrencyIdentifiers("mergeLanes: 3,")).toEqual(["mergeLanes"]); + expect(findMergeConcurrencyIdentifiers("parallelMergeWorkers?: number;")).toEqual(["parallelMergeWorkers"]); + // Negative controls: capacity-but-not-merge, and merge-but-not-capacity. + expect(findMergeConcurrencyIdentifiers("maxConcurrentVerifications: 1,")).toEqual([]); + expect(findMergeConcurrencyIdentifiers("mergeIntegrationWorktree: 'reuse-task-worktree',")).toEqual([]); + }); + + it("no settings key expresses a merge concurrency", () => { + for (const file of SETTINGS_SOURCES) { + const source = readFileSync(file, "utf8"); + // FAIL CLOSED: an empty/moved file means we checked nothing. + expect(source.length, `${file} is empty or unreadable — the guard checked nothing`).toBeGreaterThan(500); + expect(findMergeConcurrencyIdentifiers(source), `${file} declares a merge-concurrency knob`).toEqual([]); + } + }); + + it("the merge trait exposes no concurrency/limit configuration", () => { + registerBuiltinTraits(); + const mergeTrait = getTraitRegistry().getTrait("merge"); + expect(mergeTrait, "the `merge` trait must exist for this guard to mean anything").toBeDefined(); + + const fieldKeys = (mergeTrait!.configSchema?.fields ?? []).map((f) => f.key); + expect(fieldKeys.length, "merge trait has no config fields — guard is vacuous").toBeGreaterThan(0); + + const offenders = fieldKeys.filter((k) => NAMES_CAPACITY.test(k) || /^limit/i.test(k)); + expect(offenders, "the merge trait must not let a workflow configure merge concurrency").toEqual([]); + }); + + /* + FNXC:CapacityModel 2026-07-28-14:30: + TOMBSTONE. `maxTriageConcurrent` shipped as a settings default, a Settings + section key, a /config response field and six i18n catalogs while being read by + ZERO enforcement sites — FN-8453 removed the pool it gated and left the knob + behind. It is deleted; this keeps it deleted. A knob that no longer does anything + is worse than a deleted one: the next person to find it wires it up. + */ + it("maxTriageConcurrent stays deleted (dead-knob tombstone)", () => { + for (const file of SETTINGS_SOURCES) { + const source = readFileSync(file, "utf8"); + expect(source.length, `${file} is empty or unreadable — the guard checked nothing`).toBeGreaterThan(500); + expect(source).not.toContain("maxTriageConcurrent"); + } + expect(Object.keys(DEFAULT_SETTINGS)).not.toContain("maxTriageConcurrent"); + }); + + it("the merge trait carries no wip/capacity flag", () => { + registerBuiltinTraits(); + const mergeTrait = getTraitRegistry().getTrait("merge"); + // `countsTowardWip` on the merge trait would pool merge into the WIP budget and + // make merge concurrency follow the agent count instead of being fixed at 1. + expect(mergeTrait!.flags.countsTowardWip ?? false).toBe(false); + }); +}); diff --git a/packages/core/src/__tests__/worktree-capacity-limit.test.ts b/packages/core/src/__tests__/worktree-capacity-limit.test.ts new file mode 100644 index 0000000000..915a00da73 --- /dev/null +++ b/packages/core/src/__tests__/worktree-capacity-limit.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { resolveWorktreeCapacityLimit } from "../workflow-capacity.js"; +import { DEFAULT_SETTINGS } from "../settings-schema.js"; + +/* +FNXC:CapacityModel 2026-07-28-12:40: +`resolveWorktreeCapacityLimit` is THE single expression of "are worktrees a +capacity dimension for this project?". These cases pin the distinction the whole +worktrees-off design rests on: OFF returns `null` (absence — callers build no gate) +and never a large number, because "very high" is a limiter that can start binding +again while absence cannot. +*/ +describe("resolveWorktreeCapacityLimit", () => { + it("returns the configured limit when worktrees are enabled", () => { + expect(resolveWorktreeCapacityLimit({ maxWorktrees: 7, worktreeLimitEnabled: true })).toBe(7); + }); + + it("treats an omitted worktreeLimitEnabled as ON (back-compat for every existing project)", () => { + // Existing rows have no `worktreeLimitEnabled` key. They must keep gating exactly + // as before — an upgrade must not silently remove a limiter operators rely on. + expect(resolveWorktreeCapacityLimit({ maxWorktrees: 3 } as never)).toBe(3); + }); + + it("returns null — not Infinity, not a large number — when worktrees are off", () => { + const resolved = resolveWorktreeCapacityLimit({ maxWorktrees: 4, worktreeLimitEnabled: false }); + expect(resolved).toBeNull(); + // Explicitly NOT a number: a numeric "disabled" value is a sentinel, and a + // sentinel is what silently stopped binding in the capacity-pool-id defect. + expect(typeof resolved).not.toBe("number"); + }); + + it("ignores maxWorktrees entirely when off, including values that would deadlock", () => { + // 0 deadlocks the ON path (`used >= 0` holds on an empty board). Off must not + // care what the number is. + expect(resolveWorktreeCapacityLimit({ maxWorktrees: 0, worktreeLimitEnabled: false })).toBeNull(); + expect(resolveWorktreeCapacityLimit({ maxWorktrees: 999, worktreeLimitEnabled: false })).toBeNull(); + expect(resolveWorktreeCapacityLimit({ worktreeLimitEnabled: false } as never)).toBeNull(); + }); + + it("falls back to the shipped default when the limit is missing or non-finite", () => { + expect(resolveWorktreeCapacityLimit({ worktreeLimitEnabled: true } as never)).toBe(DEFAULT_SETTINGS.maxWorktrees); + expect(resolveWorktreeCapacityLimit(undefined)).toBe(DEFAULT_SETTINGS.maxWorktrees); + expect(resolveWorktreeCapacityLimit({ maxWorktrees: Number.NaN } as never)).toBe(DEFAULT_SETTINGS.maxWorktrees); + expect(resolveWorktreeCapacityLimit({ maxWorktrees: Infinity } as never)).toBe(DEFAULT_SETTINGS.maxWorktrees); + }); + + it("ships with worktrees enabled by default", () => { + // The supported shape is everything-in-a-worktree, planning included. + expect(DEFAULT_SETTINGS.worktreeLimitEnabled).toBe(true); + }); +}); diff --git a/packages/core/src/index.gate.ts b/packages/core/src/index.gate.ts index 4f10777211..1eb82b9fa3 100644 --- a/packages/core/src/index.gate.ts +++ b/packages/core/src/index.gate.ts @@ -416,7 +416,7 @@ export { } from "./plugin-gate-verdict.js"; export type { PluginGateVerdict, ColumnPluginGate } from "./plugin-gate-verdict.js"; // ── U6: workflow capacity (WIP) resolution shared by store + sweep ─────────── -export { resolveColumnCapacity, DEFAULT_WORKFLOW_POOL_ID, resolveCapacityPoolId } from "./workflow-capacity.js"; +export { resolveColumnCapacity, DEFAULT_WORKFLOW_POOL_ID, resolveCapacityPoolId, resolveWorktreeCapacityLimit } from "./workflow-capacity.js"; export type { ColumnCapacity } from "./workflow-capacity.js"; // ── U5: workflow lifecycle reconciliation (switch / edit / delete) ─────────── export { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 1d16e82d3a..2976730122 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -447,7 +447,7 @@ export { } from "./plugin-gate-verdict.js"; export type { PluginGateVerdict, ColumnPluginGate } from "./plugin-gate-verdict.js"; // ── U6: workflow capacity (WIP) resolution shared by store + sweep ─────────── -export { resolveColumnCapacity, resolveWipBudgetColumns, DEFAULT_WORKFLOW_POOL_ID, resolveCapacityPoolId } from "./workflow-capacity.js"; +export { resolveColumnCapacity, resolveWipBudgetColumns, DEFAULT_WORKFLOW_POOL_ID, resolveCapacityPoolId, resolveWorktreeCapacityLimit } from "./workflow-capacity.js"; export { createWorkflowEventBus, getWorkflowEventBus, emitWorkflowLifecycleEvent, resetWorkflowEventBusForTesting } from "./workflow-events.js"; export type { WorkflowEventBus, WorkflowEventSubscriber, WorkflowEventSubscription } from "./workflow-events.js"; export { findWorkflowEventShapeViolations, isIdsOnlyWorkflowEvent, MAX_ID_VALUE_LENGTH, IMPLEMENTATION_EXITS } from "./types/workflow-events.js"; diff --git a/packages/core/src/settings-schema.ts b/packages/core/src/settings-schema.ts index 8f8ce8b899..5e933091ab 100644 --- a/packages/core/src/settings-schema.ts +++ b/packages/core/src/settings-schema.ts @@ -426,9 +426,16 @@ export const DEFAULT_PROJECT_SETTINGS = { Default one verification at a time process-wide so concurrent tasks cannot each run verify:fast / full builds simultaneously and peg the host. Operators with spare cores may raise this in Scheduling settings (clamped 1–8 at runtime). */ maxConcurrentVerifications: 1, - maxTriageConcurrent: 2, globalMaxConcurrent: 4, maxWorktrees: 4, + /* + FNXC:CapacityModel 2026-07-28-11:20: + Worktrees ON is the default and the supported shape — everything (planning + included) runs in a worktree. OFF drops maxWorktrees from the dispatch gate so + capacity is total agents only; it is a counting statement, not permission for + concurrent agents to share one checkout. + */ + worktreeLimitEnabled: true, pollIntervalMs: 15000, heartbeatMultiplier: 1, autoClaimCandidatesInPrompt: 5, diff --git a/packages/core/src/types/settings-scope.ts b/packages/core/src/types/settings-scope.ts index f8543f15a8..85d058ae46 100644 --- a/packages/core/src/types/settings-scope.ts +++ b/packages/core/src/types/settings-scope.ts @@ -1139,15 +1139,36 @@ export interface ProjectSettings { * Max concurrent verification subprocesses (fn_run_verification / merge testCommand builds) across all tasks in this process. Caps stacked monorepo typecheck/build pegging CPU when many tasks are in-progress. Default 1. Raise only on high-core hosts. */ maxConcurrentVerifications?: number; - /** Maximum number of concurrent triage/specification agents. When undefined, - * falls back to maxConcurrent. */ - maxTriageConcurrent?: number; /** System-wide maximum concurrent agents across ALL projects. * When multiple projects are active, the sum of their in-flight agents * will not exceed this limit. Applies to triage, execution, and merge. * Default: 4. When undefined, falls back to CentralCore default (4). */ globalMaxConcurrent?: number; maxWorktrees: number; + /** + * FNXC:CapacityModel 2026-07-28-22:15 (PR #2502 review): + * Whether Max Worktrees GATES DISPATCH for this project. Default true. + * + * Renamed from `worktreesEnabled`, which two reviewers read as "run tasks + * without worktrees" — it never meant that. Tasks always execute in their own + * git worktree; this only decides whether the worktree COUNT is a second limit + * alongside the agent count. + * + * When false the operator asked to "limit via total agents only": `maxWorktrees` + * stops gating dispatch entirely — not raised, not skipped by convention, but + * structurally absent (`resolveWorktreeCapacityLimit` returns null and no + * worktree gate object is constructed, so `bindingGates` can never contain + * "maxWorktrees"). See `resolveWorktreeCapacityLimit` in workflow-capacity.ts + * for why this is a boolean rather than `maxWorktrees: 0`. + * + * SCOPE: this is a statement about COUNTING, not about isolation or execution. + * Both scheduler dispatch paths still allocate a worktree per task with this + * off, and planning still runs in the task's own worktree. It does not make + * concurrent agents safe to share one checkout — the non-worktree paths that + * exist today are fallbacks to the operator's own tree, one of which caused + * FN-8600. Turning this off does not grant shared-checkout concurrency. + */ + worktreeLimitEnabled?: boolean; pollIntervalMs: number; /** Global multiplier applied to all agent heartbeat intervals. * For example, 0.5 halves the interval (faster checks), 2.0 doubles it (slower checks). diff --git a/packages/core/src/workflow-capacity.ts b/packages/core/src/workflow-capacity.ts index bd022e1b15..16fc472913 100644 --- a/packages/core/src/workflow-capacity.ts +++ b/packages/core/src/workflow-capacity.ts @@ -29,6 +29,42 @@ import { getTraitRegistry } from "./trait-registry.js"; * `settings.maxConcurrent` (the legacy "N agents in-progress" gate). */ const DEFAULT_WIP_COLUMN_ID = "in-progress"; +/** Fallback when `maxWorktrees` is unset. Matches `DEFAULT_SETTINGS.maxWorktrees`. */ +const DEFAULT_MAX_WORKTREES = 4; + +/* +FNXC:CapacityModel 2026-07-28-11:20: +THE one place "are worktrees a capacity dimension for this project?" is answered. + +The capacity model is two configurable numbers per project: + 1. total agents (`maxConcurrent`) — always binds + 2. `maxWorktrees` — binds ONLY when worktrees are enabled + +When `worktreeLimitEnabled === false` the operator asked for "limit via total agents +only". This returns `null` for that case, and callers construct NO worktree gate +at all — rather than a gate with a very high or infinite limit. That distinction +is the whole point: a limiter that still exists and merely happens not to bind is +the bug class this program keeps excavating (the pool-id sentinel that never +matched a real pool; the approval gate three surfaces re-derived; the always-true +flag whose "disabled" branch was the live one). An absent gate cannot silently +start binding again; a gate holding `Infinity` can, the moment someone "fixes" a +comparison. `ConcurrencyGateDiagnostic.maxWorktreesGate` is therefore OPTIONAL, +so consulting a worktree limit in OFF mode does not type-check. + +Deliberately NOT expressed as `maxWorktrees === 0`. Zero is a legible number that +already means something to the gate (`used >= 0` is true on an empty board, so a +0 limit deadlocks dispatch rather than disabling it) and the Command Center +slider clamps it to a 1..50 range. Overloading a value as a mode is how sentinels +become defects; the boolean says what it means. +*/ +export function resolveWorktreeCapacityLimit( + settings: Pick | undefined, +): number | null { + if (settings?.worktreeLimitEnabled === false) return null; + const limit = settings?.maxWorktrees; + return typeof limit === "number" && Number.isFinite(limit) ? limit : DEFAULT_MAX_WORKTREES; +} + /** U6 (KTD-10): sentinel effective-workflow id for default-workflow * (null-selection) tasks, so they all share one per-column capacity pool. It * is not a real workflow row id (no `builtin:`/custom collision possible). */ diff --git a/packages/dashboard/app/__tests__/settings-sections.test.tsx b/packages/dashboard/app/__tests__/settings-sections.test.tsx index dd141052f8..58d192941f 100644 --- a/packages/dashboard/app/__tests__/settings-sections.test.tsx +++ b/packages/dashboard/app/__tests__/settings-sections.test.tsx @@ -296,6 +296,80 @@ describe("WorktreesSection", () => { expect(onAdd).toHaveBeenCalledTimes(1); }); + /* + FNXC:CapacityModel 2026-07-28-13:45: + Capacity is two configurable numbers per project; `worktreeLimitEnabled` decides + whether the SECOND one applies. These pin the UI half of the "inert means + incapable, not ignored" rule: with worktrees off the Max Worktrees control is + DISABLED, so an operator cannot set a number the engine will not consult. A live + control feeding a dead limiter is exactly the confusion this change removes. + */ + const worktreeCapacityProps = { + gitRemotes: [], + worktrunkInstall, + worktrunkInstallVerified: true, + onOpenWorktreesDirPicker: vi.fn(), + onWorktreeCopyFileChange: vi.fn(), + onRemoveWorktreeCopyFile: vi.fn(), + onAddWorktreeCopyFile: vi.fn(), + onOpenWorktreeCopyFilePicker: vi.fn(), + } as never; + + it("enables the Max Worktrees control while worktrees are on", () => { + render( + , + ); + expect(screen.getByLabelText("Max Worktrees")).not.toBeDisabled(); + }); + + it("disables the Max Worktrees control while worktrees are off", () => { + render( + , + ); + expect(screen.getByLabelText("Max Worktrees")).toBeDisabled(); + }); + + it("treats an absent worktreeLimitEnabled as on, so existing projects keep their worktree cap", () => { + // Every project row predating this setting has no `worktreeLimitEnabled` key. If + // absence read as OFF, an upgrade would silently drop a limiter operators rely + // on — and the control would grey out with no one having asked for it. + render( + , + ); + expect(screen.getByLabelText("Limit concurrent worktrees")).toBeChecked(); + expect(screen.getByLabelText("Max Worktrees")).not.toBeDisabled(); + }); + + it("toggling the worktree limit off writes worktreeLimitEnabled false", () => { + const setForm = vi.fn((updater: SettingsFormState | ((prev: SettingsFormState) => SettingsFormState)) => + typeof updater === "function" + ? updater({ recycleWorktrees: false, worktreeLimitEnabled: true } as SettingsFormState) + : updater, + ); + render( + , + ); + fireEvent.click(screen.getByLabelText("Limit concurrent worktrees")); + expect(setForm).toHaveBeenCalledTimes(1); + expect(setForm.mock.results[0]?.value).toMatchObject({ worktreeLimitEnabled: false }); + }); + it("renders and toggles the board worktree grouping checkbox", () => { const setForm = vi.fn((updater: SettingsFormState | ((prev: SettingsFormState) => SettingsFormState)) => { if (typeof updater === "function") { diff --git a/packages/dashboard/app/components/SettingsModal.tsx b/packages/dashboard/app/components/SettingsModal.tsx index bee6560a69..96743187ce 100644 --- a/packages/dashboard/app/components/SettingsModal.tsx +++ b/packages/dashboard/app/components/SettingsModal.tsx @@ -973,6 +973,9 @@ export function SettingsModal({ maxConcurrent: 2, maxConcurrentVerifications: 1, maxWorktrees: 4, + // FNXC:CapacityModel 2026-07-28-13:20: worktrees are a capacity dimension by + // default; off means capacity is total agents only. + worktreeLimitEnabled: true, pollIntervalMs: 15000, heartbeatMultiplier: 1, groupOverlappingFiles: true, diff --git a/packages/dashboard/app/components/__tests__/EngineControlMenu.test.tsx b/packages/dashboard/app/components/__tests__/EngineControlMenu.test.tsx index 77dd02a767..579e757713 100644 --- a/packages/dashboard/app/components/__tests__/EngineControlMenu.test.tsx +++ b/packages/dashboard/app/components/__tests__/EngineControlMenu.test.tsx @@ -12,7 +12,6 @@ const commandCenterControlsCss = readAppFile("components/command-center/CommandC const defaultSettings = { maxConcurrent: 2, - maxTriageConcurrent: 1, maxWorktrees: 4, globalPause: false, enginePaused: false, @@ -293,7 +292,6 @@ describe("EngineControlMenu", () => { legacyMocks.fetchSettings.mockResolvedValue({ ...defaultSettings, maxConcurrent: 60, - maxTriageConcurrent: 70, maxWorktrees: 80, }); await openMenu(); @@ -335,7 +333,6 @@ describe("EngineControlMenu", () => { legacyMocks.fetchSettings.mockResolvedValue({ ...defaultSettings, maxConcurrent: 12, - maxTriageConcurrent: 3, maxWorktrees: 25, }); await openMenu(); diff --git a/packages/dashboard/app/components/__tests__/SettingsModal.cliBinaryVisibility.test.tsx b/packages/dashboard/app/components/__tests__/SettingsModal.cliBinaryVisibility.test.tsx index 1c98b93b38..2a46f8c4cd 100644 --- a/packages/dashboard/app/components/__tests__/SettingsModal.cliBinaryVisibility.test.tsx +++ b/packages/dashboard/app/components/__tests__/SettingsModal.cliBinaryVisibility.test.tsx @@ -57,7 +57,6 @@ function buildSettings() { return { autoMerge: true, maxConcurrent: 2, - maxTriageConcurrent: 2, maxWorktrees: 4, pollIntervalMs: 15000, heartbeatMultiplier: 1, diff --git a/packages/dashboard/app/components/__tests__/SettingsModal.keyboardShortcuts.test.tsx b/packages/dashboard/app/components/__tests__/SettingsModal.keyboardShortcuts.test.tsx index a0bfd51f8a..d75e446569 100644 --- a/packages/dashboard/app/components/__tests__/SettingsModal.keyboardShortcuts.test.tsx +++ b/packages/dashboard/app/components/__tests__/SettingsModal.keyboardShortcuts.test.tsx @@ -58,7 +58,6 @@ function buildSettings() { autoMerge: true, testMode: false, maxConcurrent: 2, - maxTriageConcurrent: 2, maxWorktrees: 4, pollIntervalMs: 15000, heartbeatMultiplier: 1, diff --git a/packages/dashboard/app/components/__tests__/SettingsModal.testMode.test.tsx b/packages/dashboard/app/components/__tests__/SettingsModal.testMode.test.tsx index bcc14d0a97..f5e87311e5 100644 --- a/packages/dashboard/app/components/__tests__/SettingsModal.testMode.test.tsx +++ b/packages/dashboard/app/components/__tests__/SettingsModal.testMode.test.tsx @@ -46,7 +46,6 @@ function buildSettings(testMode: boolean) { autoMerge: true, testMode, maxConcurrent: 2, - maxTriageConcurrent: 2, maxWorktrees: 4, pollIntervalMs: 15000, heartbeatMultiplier: 1, diff --git a/packages/dashboard/app/components/command-center/__tests__/CommandCenter.mobile-scroll.test.tsx b/packages/dashboard/app/components/command-center/__tests__/CommandCenter.mobile-scroll.test.tsx index 07edb41e98..f2c7350700 100644 --- a/packages/dashboard/app/components/command-center/__tests__/CommandCenter.mobile-scroll.test.tsx +++ b/packages/dashboard/app/components/command-center/__tests__/CommandCenter.mobile-scroll.test.tsx @@ -14,7 +14,7 @@ vi.mock("../../../api/legacy", () => ({ projectId ? `${path}${path.includes("?") ? "&" : "?"}projectId=${encodeURIComponent(projectId)}` : path, fetchOrgTree: vi.fn().mockResolvedValue([]), fetchExecutorStats: vi.fn().mockResolvedValue({ globalPause: false, enginePaused: false, maxConcurrent: 2 }), - fetchSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxTriageConcurrent: 1, maxWorktrees: 5 }), + fetchSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 5 }), fetchConfig: vi.fn().mockResolvedValue({ maxConcurrent: 2, rootDir: "/" }), updateSettings: vi.fn().mockResolvedValue({}), })); diff --git a/packages/dashboard/app/components/command-center/__tests__/CommandCenter.test.tsx b/packages/dashboard/app/components/command-center/__tests__/CommandCenter.test.tsx index 060ff3681a..d7404a30fb 100644 --- a/packages/dashboard/app/components/command-center/__tests__/CommandCenter.test.tsx +++ b/packages/dashboard/app/components/command-center/__tests__/CommandCenter.test.tsx @@ -18,7 +18,7 @@ vi.mock("../../../api/legacy", () => ({ projectId ? `${path}${path.includes("?") ? "&" : "?"}projectId=${encodeURIComponent(projectId)}` : path, fetchOrgTree: vi.fn().mockResolvedValue([]), fetchExecutorStats: vi.fn().mockResolvedValue({ globalPause: false, enginePaused: false, maxConcurrent: 2 }), - fetchSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxTriageConcurrent: 1, maxWorktrees: 5 }), + fetchSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 5 }), fetchConfig: vi.fn().mockResolvedValue({ maxConcurrent: 2, rootDir: "/" }), updateSettings: vi.fn().mockResolvedValue({}), })); diff --git a/packages/dashboard/app/components/command-center/__tests__/CommandCenterControls.test.tsx b/packages/dashboard/app/components/command-center/__tests__/CommandCenterControls.test.tsx index 0688eeaea6..783ff9ca92 100644 --- a/packages/dashboard/app/components/command-center/__tests__/CommandCenterControls.test.tsx +++ b/packages/dashboard/app/components/command-center/__tests__/CommandCenterControls.test.tsx @@ -27,7 +27,6 @@ vi.mock("../../../hooks/useAppSettings", () => ({ const defaultSettings = { maxConcurrent: 12, - maxTriageConcurrent: 1, maxWorktrees: 4, }; diff --git a/packages/dashboard/app/components/command-center/__tests__/SystemControlsArea.test.tsx b/packages/dashboard/app/components/command-center/__tests__/SystemControlsArea.test.tsx index 3d939cb9e9..e436c7c082 100644 --- a/packages/dashboard/app/components/command-center/__tests__/SystemControlsArea.test.tsx +++ b/packages/dashboard/app/components/command-center/__tests__/SystemControlsArea.test.tsx @@ -35,7 +35,7 @@ vi.mock("../../../api/legacy", () => ({ projectId ? `${path}${path.includes("?") ? "&" : "?"}projectId=${encodeURIComponent(projectId)}` : path, fetchOrgTree: vi.fn().mockResolvedValue([]), fetchExecutorStats: vi.fn().mockResolvedValue({ globalPause: false, enginePaused: false, maxConcurrent: 2 }), - fetchSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxTriageConcurrent: 1, maxWorktrees: 5 }), + fetchSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 5 }), fetchConfig: vi.fn().mockResolvedValue({ maxConcurrent: 2, rootDir: "/" }), updateSettings: vi.fn().mockResolvedValue({}), createBackup: vi.fn().mockResolvedValue({ ok: true }), diff --git a/packages/dashboard/app/components/settings/section-keys.ts b/packages/dashboard/app/components/settings/section-keys.ts index da80cb8b50..99ec173ce2 100644 --- a/packages/dashboard/app/components/settings/section-keys.ts +++ b/packages/dashboard/app/components/settings/section-keys.ts @@ -119,6 +119,7 @@ export const PROJECT_SECTION_KEYS: Readonly> = worktrees: [ "executorAllowSiblingBranchRename", "maxWorktrees", + "worktreeLimitEnabled", "recycleWorktrees", "showWorktreeGrouping", "worktreeCopyFiles", @@ -148,7 +149,6 @@ export const PROJECT_SECTION_KEYS: Readonly> = "maxConcurrent", "maxConcurrentVerifications", "maxStuckKills", - "maxTriageConcurrent", "overlapIgnorePaths", "pollIntervalMs", "preserveProgressOnStuckRequeue", diff --git a/packages/dashboard/app/components/settings/sections/WorktreesSection.search.ts b/packages/dashboard/app/components/settings/sections/WorktreesSection.search.ts index 19e5e58d1f..8b03ae52fc 100644 --- a/packages/dashboard/app/components/settings/sections/WorktreesSection.search.ts +++ b/packages/dashboard/app/components/settings/sections/WorktreesSection.search.ts @@ -9,6 +9,22 @@ import type { SettingsSearchEntry } from "../search/types"; export const worktreesSearchEntries: SettingsSearchEntry[] = [ + { + sectionId: "worktrees", + key: "worktreeLimitEnabled", + labelKey: "settings.worktrees.worktreeLimitEnabled", + labelFallback: "Limit concurrent worktrees", + helpKey: "settings.worktrees.worktreeLimitEnabledHelp", + helpFallback: "When on, Max Worktrees caps how many tasks may hold a worktree at once. When off, Max Concurrent Tasks is the only limit. Tasks always run in their own git worktree either way — this does not change where work executes. Default: on.", + /* + FNXC:SettingsSearch 2026-07-28-13:20: + An operator reaching for this is asking a CAPACITY question ("why won't more + tasks start?"). "isolation" is deliberately NOT a keyword: this setting does + not affect isolation, and matching that word would re-create the same false + impression the old label gave (PR #2502 review). + */ + keywords: ["capacity", "parallelism", "agents only", "max worktrees", "concurrency", "limit"], + }, { sectionId: "worktrees", key: "maxWorktrees", diff --git a/packages/dashboard/app/components/settings/sections/WorktreesSection.tsx b/packages/dashboard/app/components/settings/sections/WorktreesSection.tsx index 0f1a677710..3e4a68e944 100644 --- a/packages/dashboard/app/components/settings/sections/WorktreesSection.tsx +++ b/packages/dashboard/app/components/settings/sections/WorktreesSection.tsx @@ -41,15 +41,44 @@ export function WorktreesSection({ form, setForm, gitRemotes, worktrunkInstall, const worktreeCopyFileRows = (form.worktreeCopyFiles?.length ?? 0) > 0 ? form.worktreeCopyFiles ?? [] : [""]; return (<>

{t("settings.worktrees.worktrees", "Worktrees")}

+ {/* + FNXC:CapacityModel 2026-07-28-22:15 (PR #2502 review — coderabbit + greptile): + RENAMED from "Run tasks in worktrees", which was a lie. + + This setting is CAPACITY SEMANTICS ONLY: it decides whether Max Worktrees + gates dispatch. It does NOT change where work executes — both scheduler + dispatch paths still allocate a worktree per task with it off, and planning + still runs in the task's own worktree. Two reviewers independently read the + old label as "turn off worktree execution", which is exactly the wrong + inference and worse than having no switch: an operator would flip it, + still get worktrees, and conclude the product is broken. + + The label now names the limit rather than the mechanism, and the help says + outright that tasks run in a worktree either way. The setting KEY was renamed + with it (`worktreesEnabled` -> `worktreeLimitEnabled`) — a key persisted in + every settings blob misleads every future reader of the schema, not just the + operator reading the UI once. + */} + setForm((f) => ({ ...f, worktreeLimitEnabled: value !== false } as SettingsFormState))} + /> {/* FNXC:Worktrees 2026-07-15-17:35: An emptied Max Worktrees stores `undefined`, not 0 or "", so the key is absent from the settings blob and the scheduler falls back to the schema default of 4 rather than capping concurrency at nothing. */} setForm((f) => ({ ...f, maxWorktrees: v ?? undefined } as SettingsFormState))} diff --git a/packages/dashboard/app/components/settings/sections/__tests__/settings-default-descriptions.test.tsx b/packages/dashboard/app/components/settings/sections/__tests__/settings-default-descriptions.test.tsx index 3e87333ee8..fdfab92f40 100644 --- a/packages/dashboard/app/components/settings/sections/__tests__/settings-default-descriptions.test.tsx +++ b/packages/dashboard/app/components/settings/sections/__tests__/settings-default-descriptions.test.tsx @@ -214,7 +214,6 @@ const SETTING_DESCRIPTION_KEYS: Record = { globalMaxConcurrent: "scheduling.maximumConcurrentAgentsAcrossAllProjects", maxConcurrent: "scheduling.maxConcurrentTasksHint", maxConcurrentVerifications: "scheduling.maxConcurrentVerificationsHint", - maxTriageConcurrent: "scheduling.maximumConcurrentPlanningAgents", pollIntervalMs: "scheduling.pollIntervalMsHint", heartbeatScopeDiscipline: "scheduling.strictDefault", engineerBacklogAutoClaim: "scheduling.backlogNoTaskAutoClaimIsExecutorOnly", @@ -241,6 +240,7 @@ const SETTING_DESCRIPTION_KEYS: Record = { overlapIgnorePaths: "scheduling.optionalFileOrDirectoryPathsToIgnoreWhen", // WorktreesSection maxWorktrees: "worktrees.limitsTotalGitWorktreesIncludingInReviewTasks", + worktreeLimitEnabled: "worktrees.worktreeLimitEnabledHelp", worktreeInitCommand: "worktrees.shellCommandToRunInEachNewWorktree", recycleWorktrees: "worktrees.offByDefaultOptInWhenEnabledCompleted", showWorktreeGrouping: "worktrees.showWorktreeGroupingHelp", diff --git a/packages/dashboard/src/routes/__tests__/register-config-mcp-pi-settings-routes.test.ts b/packages/dashboard/src/routes/__tests__/register-config-mcp-pi-settings-routes.test.ts index de7df42873..ddeab6d9c7 100644 --- a/packages/dashboard/src/routes/__tests__/register-config-mcp-pi-settings-routes.test.ts +++ b/packages/dashboard/src/routes/__tests__/register-config-mcp-pi-settings-routes.test.ts @@ -8,7 +8,7 @@ import { registerConfigMcpPiSettingsRoutes } from "../register-config-mcp-pi-set import type { ApiRoutesContext } from "../types.js"; function createApp( - settings = { maxConcurrent: 6, maxTriageConcurrent: 3, maxWorktrees: 2 }, + settings = { maxConcurrent: 6, maxWorktrees: 2 }, pluginServers: Array<{ pluginId: string; server: unknown }> = [], ) { const app = express(); @@ -39,14 +39,14 @@ describe("registerConfigMcpPiSettingsRoutes", () => { const response = await request(createApp(), "GET", "/config"); expect(response.status).toBe(200); - expect(response.body).toEqual({ maxConcurrent: 6, maxTriageConcurrent: 3, maxWorktrees: 2, rootDir: "/workspace" }); + expect(response.body).toEqual({ maxConcurrent: 6, maxWorktrees: 2, rootDir: "/workspace" }); }); it("uses option and fixed defaults for missing scheduler settings", async () => { const response = await request(createApp({}), "GET", "/config"); expect(response.status).toBe(200); - expect(response.body).toEqual({ maxConcurrent: 9, maxTriageConcurrent: 2, maxWorktrees: 4, rootDir: "/workspace" }); + expect(response.body).toEqual({ maxConcurrent: 9, maxWorktrees: 4, rootDir: "/workspace" }); }); it("lists only provider-filtered valid project plugin MCP contributions", async () => { diff --git a/packages/dashboard/src/routes/register-config-mcp-pi-settings-routes.ts b/packages/dashboard/src/routes/register-config-mcp-pi-settings-routes.ts index 138f11f461..76e3c2e2c2 100644 --- a/packages/dashboard/src/routes/register-config-mcp-pi-settings-routes.ts +++ b/packages/dashboard/src/routes/register-config-mcp-pi-settings-routes.ts @@ -108,13 +108,12 @@ export const registerConfigMcpPiSettingsRoutes: ApiRouteRegistrar = (ctx) => { const settings = await scopedStore.getSettingsFast(); res.json({ maxConcurrent: settings.maxConcurrent ?? options?.maxConcurrent ?? 2, - maxTriageConcurrent: settings.maxTriageConcurrent ?? 2, maxWorktrees: settings.maxWorktrees ?? 4, rootDir: scopedStore.getRootDir(), }); } catch { const { store: scopedStore } = await getProjectContext(req); - res.json({ maxConcurrent: options?.maxConcurrent ?? 2, maxTriageConcurrent: 2, maxWorktrees: 4, rootDir: scopedStore.getRootDir() }); + res.json({ maxConcurrent: options?.maxConcurrent ?? 2, maxWorktrees: 4, rootDir: scopedStore.getRootDir() }); } }); diff --git a/packages/engine/src/__tests__/merge-single-flight-invariant.test.ts b/packages/engine/src/__tests__/merge-single-flight-invariant.test.ts new file mode 100644 index 0000000000..a64e55335e --- /dev/null +++ b/packages/engine/src/__tests__/merge-single-flight-invariant.test.ts @@ -0,0 +1,258 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { ProjectEngine } from "../project-engine.js"; + +/* +FNXC:CapacityModel 2026-07-28-09:40: +MERGE CONCURRENCY IS FIXED AT 1 AND IS NOT A SETTING. + +The capacity model is exactly two CONFIGURABLE numbers per project (total agents, +maxWorktrees) plus this one FIXED invariant. Merge is where the irreversible work +happens — main advances, branches are deleted, worktrees are torn down — so every +merge-safety guard in the repo (file-scope overlap, diff-volume shrinkage, +post-squash audit, contamination auto-recovery) is written against the assumption +that exactly one merge is in flight per project at a time. None of them are +concurrency-safe against a second merge advancing main underneath them. + +This file is the RATCHET for that assumption. It is deliberately NOT a test of the +merge-queue LEASE: `acquireMergeQueueLease` leases a per-task queue ROW +(`primaryKey([projectId, taskId])`), so two different tasks can hold leases +simultaneously by construction, and it has exactly one caller (the worktree-reuse +handoff in `merger-integration-worktree.ts`). Ordinary merges never take it. The +serialization lives HERE, in the pump: + + 1. `drainMergeQueue`'s `mergeRunning` re-entrancy latch (project-engine.ts) + 2. `activeMergeTaskId` — a single-slot identity, never a counter + 3. `mergeBodyInFlight` — blocks the NEXT generation until an aborted orphan settles + 4. one `ProjectEngine` per projectId (`project-engine-manager.ts` engines Map) + +Do not "fix" a failure here by adding a limiter. Nothing in the product may make +merge concurrency configurable — see `rejects any settings key that would make +merge concurrency configurable` below, which is the half of this ratchet that +fails when someone adds the knob. +*/ + +const mocks = vi.hoisted(() => ({ + runtimeStart: vi.fn(async () => undefined), + runtimeStop: vi.fn(async () => undefined), + currentStore: null as Record | null, +})); + +vi.mock("@fusion/core", async (importOriginal) => { + const { createEngineCoreMock } = await import("../test/mockCore.js"); + return createEngineCoreMock(() => importOriginal(), {}); +}); + +vi.mock("../merger.js", () => ({ aiMergeTask: vi.fn(), sweepStaleAutostashes: vi.fn(async () => undefined) })); +vi.mock("../pr-monitor.js", () => ({ PrMonitor: vi.fn().mockImplementation(function () { return { onNewComments: vi.fn() }; }) })); +vi.mock("../pr-comment-handler.js", () => ({ PrCommentHandler: vi.fn().mockImplementation(function () { return { handleNewComments: vi.fn() }; }) })); +vi.mock("../auth-storage.js", () => ({ + createFusionAuthStorage: vi.fn(() => ({ reload: vi.fn(), getOAuthProviders: vi.fn(() => []), get: vi.fn(() => undefined) })), + getFusionOAuthAlertStatePath: vi.fn(() => "/tmp/oauth-alert-state.json"), +})); +vi.mock("../notifier.js", () => ({ NtfyNotifier: vi.fn().mockImplementation(function () { return { start: vi.fn(), stop: vi.fn() }; }) })); +vi.mock("../notification/index.js", () => ({ + NotificationService: vi.fn().mockImplementation(function () { return { start: vi.fn(), stop: vi.fn() }; }), + OAuthAlertStateStore: vi.fn().mockImplementation(function () { return {}; }), + OAuthExpiryMonitor: vi.fn().mockImplementation(function () { return { start: vi.fn(), stop: vi.fn() }; }), + OAuthValidityLogger: vi.fn().mockImplementation(function () { return { start: vi.fn(), stop: vi.fn() }; }), +})); +vi.mock("../cron-runner.js", () => ({ + CronRunner: vi.fn().mockImplementation(function () { return { start: vi.fn(), stop: vi.fn() }; }), + createAiPromptExecutor: vi.fn(async () => vi.fn()), +})); +vi.mock("../runtimes/in-process-runtime.js", () => ({ + InProcessRuntime: vi.fn().mockImplementation(function () { + return { + start: mocks.runtimeStart, + stop: mocks.runtimeStop, + resumeAfterUnpause: vi.fn(async () => undefined), + getTaskStore: () => mocks.currentStore, + getAgentStore: vi.fn(), + getMessageStore: vi.fn(), + getRoutineStore: vi.fn(), + getRoutineRunner: vi.fn(), + getHeartbeatMonitor: vi.fn(), + getTriggerScheduler: vi.fn(), + configurePrMonitoring: vi.fn(), + setActiveMergeTaskIdProvider: vi.fn(), + setMergeEnqueuer: vi.fn(), + setMergeActiveClearer: vi.fn(), + }; + }), +})); + +type Deferred = { promise: Promise; resolve: () => void }; +function deferred(): Deferred { + let resolve!: () => void; + const promise = new Promise((r) => { resolve = r; }); + return { promise, resolve }; +} + +const SETTINGS = { autoMerge: true, globalPause: false, enginePaused: false }; + +/** + * A store whose `getSettings` parks on a gate the test controls. `getSettings` is + * the first await inside `drainMergeQueue`'s while-loop body, so parking it holds + * the pump mid-iteration with `mergeRunning === true` — exactly the window a + * second concurrent drain must be refused in. + * + * The gate is ARMED explicitly, because `ProjectEngine.start()` also reads settings + * — an always-on gate deadlocks startup instead of the pump, and every test in this + * file times out rather than asserting anything. + */ +function createGatedStore(gate: Deferred) { + const state = { armed: false, throwAfterGate: false }; + const getSettings = vi.fn(async () => { + if (!state.armed) return SETTINGS; + await gate.promise; + if (state.throwAfterGate) throw new Error("merge pump exploded"); + return SETTINGS; + }); + return { + state, + getSettings, + getRootDir: () => "/tmp/proj_test", + listTasks: vi.fn(async () => []), + getTask: vi.fn(async (taskId: string) => ({ + id: taskId, column: "in-review", paused: false, userPaused: false, + mergeRetries: 0, status: null, createdAt: "2026-01-01T00:00:00Z", priority: "normal", + })), + updateTask: vi.fn(async () => undefined), + moveTask: vi.fn(async () => undefined), + logEntry: vi.fn(async () => undefined), + addTaskComment: vi.fn(async () => undefined), + emit: vi.fn(), + getActiveMergingTask: vi.fn(async () => null), + on: vi.fn(), + off: vi.fn(), + }; +} + +function createEngine() { + return new ProjectEngine( + { + projectId: "proj_test", + workingDirectory: "/tmp/proj_test", + isolationMode: "in-process", + maxConcurrent: 2, + maxWorktrees: 2, + }, + {} as never, + { skipNotifier: true }, + ); +} + +describe("merge is single-flight per project — fixed at 1, not configurable", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + /* + THE CORE RATCHET. Reverting `if (this.mergeRunning) return;` in drainMergeQueue + makes the second drain enter the loop body and call getSettings a second time + while the first is still parked — this assertion goes red. + */ + it("refuses a second concurrent drain while one merge is in flight", async () => { + const gate = deferred(); + const store = createGatedStore(gate); + mocks.currentStore = store as never; + const engine = createEngine(); + const privateEngine = engine as unknown as { + mergeQueue: string[]; + mergeRunning: boolean; + drainMergeQueue(): Promise; + }; + + await engine.start(); + store.state.armed = true; + store.getSettings.mockClear(); + privateEngine.mergeQueue = ["FN-1", "FN-2"]; + + // First drain enters and parks on the gated getSettings. + const first = privateEngine.drainMergeQueue(); + await vi.waitFor(() => expect(store.getSettings).toHaveBeenCalledTimes(1)); + expect(privateEngine.mergeRunning).toBe(true); + + /* + Second drain, issued while the first is mid-flight, must be a no-op. + + Deliberately NOT awaited. Without the latch the second drain parks on the same + gate and never returns, so awaiting it would turn a reverted latch into a 30s + test TIMEOUT — a real failure, but one that reports "timed out" instead of + naming the defect. Firing it and letting the microtask queue drain makes the + reverted case fail on THIS assertion (getSettings called twice), which says + what actually broke. + */ + const second = privateEngine.drainMergeQueue(); + await new Promise((r) => setImmediate(r)); + expect(store.getSettings).toHaveBeenCalledTimes(1); + + gate.resolve(); + await Promise.allSettled([first, second]); + await engine.stop(); + }); + + /* + The latch must RELEASE on the failure path too. A merge body that throws must not + leave mergeRunning latched — that is the FNXC:MergeQueue 2026-07-15-09:50 wedge + (no merging badge board-wide, later enqueues silently no-op). Reverting the + `finally { this.mergeRunning = false; }` turns this red. + */ + it("releases the single-flight latch when the pump throws", async () => { + const gate = deferred(); + const store = createGatedStore(gate); + mocks.currentStore = store as never; + const engine = createEngine(); + const privateEngine = engine as unknown as { + mergeQueue: string[]; + mergeRunning: boolean; + drainMergeQueue(): Promise; + }; + + await engine.start(); + store.state.armed = true; + store.state.throwAfterGate = true; + store.getSettings.mockClear(); + privateEngine.mergeQueue = ["FN-1"]; + + const first = privateEngine.drainMergeQueue(); + await vi.waitFor(() => expect(store.getSettings).toHaveBeenCalledTimes(1)); + gate.resolve(); + await first.catch(() => undefined); + + expect(privateEngine.mergeRunning).toBe(false); + await engine.stop(); + }); + + /* + `activeMergeTaskId` is an IDENTITY, not a counter. If it ever became a count or a + set, "one merge at a time" would stop being expressible and the guard trio above + would silently admit a second. Pin the single-slot shape. + */ + it("tracks the active merge as a single-slot identity, never a count", async () => { + const store = createGatedStore(deferred()); + mocks.currentStore = store as never; + const engine = createEngine(); + const privateEngine = engine as unknown as { + claimActiveMerge(taskId: string): AbortSignal; + clearActiveMergeClaim(taskId: string): void; + activeMergeTaskId: string | null; + }; + + await engine.start(); + expect(engine.getActiveMergeTaskId()).toBeNull(); + + privateEngine.claimActiveMerge("FN-1"); + expect(engine.getActiveMergeTaskId()).toBe("FN-1"); + expect(typeof privateEngine.activeMergeTaskId).toBe("string"); + + // A second claim REPLACES rather than accumulating — there is exactly one slot. + privateEngine.claimActiveMerge("FN-2"); + expect(engine.getActiveMergeTaskId()).toBe("FN-2"); + + privateEngine.clearActiveMergeClaim("FN-2"); + expect(engine.getActiveMergeTaskId()).toBeNull(); + + await engine.stop(); + }); +}); diff --git a/packages/engine/src/__tests__/reliability-interactions/starved-refinement-x-triage-poll.test.ts b/packages/engine/src/__tests__/reliability-interactions/starved-refinement-x-triage-poll.test.ts index dd4baa8ca3..d3c6a784c7 100644 --- a/packages/engine/src/__tests__/reliability-interactions/starved-refinement-x-triage-poll.test.ts +++ b/packages/engine/src/__tests__/reliability-interactions/starved-refinement-x-triage-poll.test.ts @@ -51,7 +51,7 @@ describe("reliability interaction: starved refinement x triage poll", () => { ]; const store: any = { - getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 1, maxTriageConcurrent: 1, pollIntervalMs: 10_000, globalPause: false, enginePaused: false }), + getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 1, pollIntervalMs: 10_000, globalPause: false, enginePaused: false }), listTasks: vi.fn().mockImplementation(async () => tasks.map((t) => ({ ...t }))), updateTask: vi.fn().mockImplementation(async (id: string, patch: Partial) => { const idx = tasks.findIndex((t) => t.id === id); diff --git a/packages/engine/src/__tests__/scheduler-workflow-cutover.test.ts b/packages/engine/src/__tests__/scheduler-workflow-cutover.test.ts index e46144def3..48646edc97 100644 --- a/packages/engine/src/__tests__/scheduler-workflow-cutover.test.ts +++ b/packages/engine/src/__tests__/scheduler-workflow-cutover.test.ts @@ -460,6 +460,74 @@ describe("Scheduler workflow cutover", () => { expect(ready.column).toBe("todo"); }); + /* + FNXC:CapacityModel 2026-07-28-12:10: + WORKTREES OFF → "limit via total agents only". These three cases are the proof + that maxWorktrees is genuinely INERT rather than merely generous, and they use + the SAME fixture as the two maxWorktrees tests above (5 in-progress, limit 4) + which are proven to BLOCK — so a regression that quietly re-enables the worktree + gate flips these red while those stay green, and vice versa. + + "Inert" is asserted three ways because "set very high" and "skipped by + convention" both pass a naive does-it-dispatch check: + 1. the card actually releases where worktrees-on blocks it; + 2. maxWorktrees is absent from everything the operator reads — with worktrees + on this same fixture logs "gate=maxWorktrees; ... used=5/4"; + 3. an ABSURD maxWorktrees (0 — the value that DEADLOCKS the board when + worktrees are on, because `used >= 0` holds on an empty board) changes + nothing, proving the value is never consulted rather than merely large. + + Note on (2): a maxWorktrees-named queued reason is structurally UNREACHABLE in + OFF mode, so this asserts absence rather than a rewritten string. Measured, not + assumed — when maxConcurrent is the binding gate the sweep bails before the + per-task reason and logs nothing at all (the pre-existing "maxConcurrent is + full" test above likewise asserts no log line). Only maxWorktrees or the + semaphore binding produces that string, and OFF mode removes the first. + */ + it("worktrees off: releases despite being far over maxWorktrees (agents-only capacity)", async () => { + const active = Array.from({ length: 5 }, (_, index) => task({ id: `FN-10${index}`, column: "in-progress" })); + const ready = task({ id: "FN-200", status: "queued" }); + const store = storeWith([...active, ready], { + maxConcurrent: 10, + maxWorktrees: 4, + worktreeLimitEnabled: false, + }); + const onSchedule = vi.fn(); + const scheduler = new Scheduler(store, { onSchedule }); + (scheduler as unknown as { running: boolean }).running = true; + + await scheduler.schedule(); + + // 5 > 4 would have bound the worktree gate; with worktrees off only the agent + // count (5/10) is consulted, so the card releases. + expect(store.moveTaskIf).toHaveBeenCalledWith("FN-200", "in-progress", expect.any(Function), expect.anything()); + expect(onSchedule).toHaveBeenCalledTimes(1); + // ...and maxWorktrees is never named in anything the operator reads. With + // worktrees on, this exact fixture logs "gate=maxWorktrees; ... used=5/4". + const messages = vi.mocked(store.logEntry).mock.calls.map(([, m]) => String(m)); + expect(messages.some((m) => m.includes("maxWorktrees"))).toBe(false); + }); + + it("worktrees off: maxWorktrees=0 does not deadlock dispatch (the value is never read)", async () => { + // 0 is the value that makes the ON path refuse every release (`used >= 0` is + // true on an empty board). If anything still consulted the limit, this would + // dispatch nothing. + const ready = task({ id: "FN-700", status: "queued" }); + const store = storeWith([ready], { + maxConcurrent: 4, + maxWorktrees: 0, + worktreeLimitEnabled: false, + }); + const onSchedule = vi.fn(); + const scheduler = new Scheduler(store, { onSchedule }); + (scheduler as unknown as { running: boolean }).running = true; + + await scheduler.schedule(); + + expect(store.moveTaskIf).toHaveBeenCalledWith("FN-700", "in-progress", expect.any(Function), expect.anything()); + expect(onSchedule).toHaveBeenCalledTimes(1); + }); + it("releases one ready task when maxWorktrees has exactly one remaining slot and maxConcurrent is higher", async () => { const active = Array.from({ length: 3 }, (_, index) => task({ id: `FN-30${index}`, column: "in-progress" })); const first = task({ id: "FN-401", status: "queued" }); diff --git a/packages/engine/src/__tests__/triage-refinement-routing.test.ts b/packages/engine/src/__tests__/triage-refinement-routing.test.ts index de2376d196..1d374cc970 100644 --- a/packages/engine/src/__tests__/triage-refinement-routing.test.ts +++ b/packages/engine/src/__tests__/triage-refinement-routing.test.ts @@ -78,7 +78,6 @@ describe("refinement routing from triage", () => { const store: any = withStoreEvents({ getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, - maxTriageConcurrent: 2, pollIntervalMs: 10_000, groupOverlappingFiles: false, autoMerge: true, @@ -224,7 +223,6 @@ describe("refinement routing from triage", () => { const store: any = withStoreEvents({ getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, - maxTriageConcurrent: 2, pollIntervalMs: 10_000, groupOverlappingFiles: false, autoMerge: true, @@ -275,7 +273,6 @@ describe("refinement routing from triage", () => { const store: any = withStoreEvents({ getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 10, - maxTriageConcurrent: 10, pollIntervalMs: 10_000, groupOverlappingFiles: false, autoMerge: true, diff --git a/packages/engine/src/__tests__/triage.test.ts b/packages/engine/src/__tests__/triage.test.ts index d7a25eb820..b4f8e01dea 100644 --- a/packages/engine/src/__tests__/triage.test.ts +++ b/packages/engine/src/__tests__/triage.test.ts @@ -1751,7 +1751,6 @@ Planner rewrote mission without the raw request. listTasks: vi.fn().mockResolvedValue(tasks), getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 10, - maxTriageConcurrent: 10, pollIntervalMs: 10_000, groupOverlappingFiles: false, autoMerge: true, @@ -1789,7 +1788,6 @@ Planner rewrote mission without the raw request. listTasks: vi.fn().mockResolvedValue(tasks), getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 10, - maxTriageConcurrent: 10, pollIntervalMs: 10_000, groupOverlappingFiles: false, autoMerge: true, @@ -1827,7 +1825,6 @@ Planner rewrote mission without the raw request. listTasks: vi.fn().mockResolvedValue(tasks), getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 10, - maxTriageConcurrent: 10, pollIntervalMs: 10_000, groupOverlappingFiles: false, autoMerge: true, @@ -1847,7 +1844,7 @@ Planner rewrote mission without the raw request. /* FNXC:GlobalConcurrencyControls 2026-07-14-18:30: - When an in-progress executor already counts toward the live running-agent total, triage must leave room under the global cap instead of filling maxTriageConcurrent purely from semaphore.availableCount. + When an in-progress executor already counts toward the live running-agent total, triage must leave room under the global cap instead of filling the planning lane purely from semaphore.availableCount. */ it("leaves global concurrency room for live in-progress agents when admitting planners", async () => { const tasks: Task[] = [ @@ -1867,7 +1864,6 @@ Planner rewrote mission without the raw request. listTasks: vi.fn().mockResolvedValue(tasks), getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 4, - maxTriageConcurrent: 4, pollIntervalMs: 10_000, groupOverlappingFiles: false, autoMerge: true, @@ -1911,7 +1907,6 @@ Planner rewrote mission without the raw request. getTask: vi.fn().mockImplementation(async (id: string) => tasksById.get(id) ?? null), getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 10, - maxTriageConcurrent: 10, pollIntervalMs: 10_000, groupOverlappingFiles: false, autoMerge: true, @@ -1962,7 +1957,6 @@ Planner rewrote mission without the raw request. listTasks: vi.fn().mockResolvedValue(tasks), getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 10, - maxTriageConcurrent: 10, pollIntervalMs: 10_000, groupOverlappingFiles: false, autoMerge: true, @@ -2001,7 +1995,6 @@ Planner rewrote mission without the raw request. listTasks: vi.fn().mockResolvedValue([promotedTask]), getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 10, - maxTriageConcurrent: 10, pollIntervalMs: 10_000, groupOverlappingFiles: false, autoMerge: true, @@ -2044,7 +2037,6 @@ Planner rewrote mission without the raw request. listTasks: vi.fn().mockResolvedValue([plannedTask]), getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 10, - maxTriageConcurrent: 10, pollIntervalMs: 10_000, groupOverlappingFiles: false, autoMerge: true, @@ -2094,7 +2086,6 @@ Planner rewrote mission without the raw request. listTasks: vi.fn().mockResolvedValue([replanTask]), getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 10, - maxTriageConcurrent: 10, pollIntervalMs: 10_000, groupOverlappingFiles: false, autoMerge: true, @@ -2144,7 +2135,6 @@ Planner rewrote mission without the raw request. listTasks: vi.fn().mockResolvedValue([refineTask]), getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 10, - maxTriageConcurrent: 10, pollIntervalMs: 10_000, groupOverlappingFiles: false, autoMerge: true, diff --git a/packages/engine/src/replan-target.ts b/packages/engine/src/replan-target.ts index 1c0fc4352e..dfc6f8ac83 100644 --- a/packages/engine/src/replan-target.ts +++ b/packages/engine/src/replan-target.ts @@ -44,7 +44,7 @@ attached at the site rather than in a separate list that can drift from it. * REVISE -> needs-replan) legitimately carries the steps of its previous planning pass. * Counting steps>0 as "advanced" made the primary triage claim in specifyTask() skip its * status:"planning" write on every poll: the card was re-claimed forever, never planned, - * and — because wedged cards keep occupying maxTriageConcurrent slots — starved every + * and — because wedged cards keep occupying planning admission slots — starved every * healthy card queued behind them. Both planner surfaces must stay plannable: the "triage" * column, and plan-in-place workflows (Coding (Ideas)) that park needs-replan cards in * "todo" carrying a real spec. A planned-and-queued "todo" card with no planning status is @@ -112,12 +112,12 @@ export function hasAdvancedPastPlanning( worktree up front (so no lane runs in the shared checkout), which means a card being planned right now carries `worktree` — and reading that as "advanced" would make every planning write skip: `status:"planning"` never lands, the spec finalization is refused, and the card is re-claimed - forever while occupying a maxTriageConcurrent slot. Execution TIMESTAMPS are the durable evidence + forever while occupying a planning admission slot. Execution TIMESTAMPS are the durable evidence instead; they are written when implementation actually starts, never by worktree acquisition. A triage card carrying a timestamp with NO planning status is the stranded-advanced class that self-healing's advanced recovery owns (PR #2360): planning must exclude it so it cannot burn a - maxTriageConcurrent slot in a claim/skip loop. + planning admission slot in a claim/skip loop. */ if (task.firstExecutionAt != null || task.executionStartedAt != null) { /* diff --git a/packages/engine/src/scheduler.ts b/packages/engine/src/scheduler.ts index fef1342985..84c2b395e5 100644 --- a/packages/engine/src/scheduler.ts +++ b/packages/engine/src/scheduler.ts @@ -39,7 +39,7 @@ import { StaleTaskReporter } from "./stale-task-reporter.js"; import { BacklogPressureReporter } from "./backlog-pressure-reporter.js"; import { UnlinkedMissionsAdvisoryReporter } from "./unlinked-missions-advisory-reporter.js"; import { createRunAuditor, generateSyntheticRunId } from "./run-audit.js"; -import { resolveWorkflowIrForTask, resolveWorkflowIrById, resolveColumnFlags } from "@fusion/core"; +import { resolveWorkflowIrForTask, resolveWorkflowIrById, resolveColumnFlags, resolveWorktreeCapacityLimit } from "@fusion/core"; import type { WorkflowIr, WorkflowIrV2 } from "@fusion/core"; import { runHoldReleaseSweep, isUnplannedForExecution, type SlotReservation } from "./hold-release.js"; import { moveTaskToReplanColumn } from "./replan-target.js"; @@ -378,11 +378,24 @@ interface ConcurrencyGateDiagnostic { available: number; bindingGates: ConcurrencyGateName[]; maxConcurrentGate: ConcurrencyGateSnapshot; - maxWorktreesGate: ConcurrencyGateSnapshot; + /* + FNXC:CapacityModel 2026-07-28-11:35: + OPTIONAL because worktrees can be turned off as a capacity dimension + (`settings.worktreeLimitEnabled === false` → "limit via total agents only"). + + When off, this field is ABSENT — there is no worktree gate object, not a gate + holding Infinity and not a comparison skipped by convention. That is deliberate + and is the operator's explicit requirement: an inert limiter must be incapable + of binding, not merely unlikely to. Optionality moves the guarantee into the + type system — `diagnostic.maxWorktreesGate.limit` does not compile without a + presence check, so a future edit cannot reintroduce a silent worktree + comparison in OFF mode. `bindingGates` can never contain "maxWorktrees" there. + */ + maxWorktreesGate?: ConcurrencyGateSnapshot; semaphoreGate?: ConcurrencyGateSnapshot; holders: { maxConcurrent: string[]; - maxWorktrees: string[]; + maxWorktrees?: string[]; semaphore?: string[]; }; /** U6: additive per-column capacity gates (flag-ON only; omitted otherwise so @@ -416,7 +429,15 @@ function computeConcurrencyGateDiagnostic(params: { agentSlots: number; maxConcurrent: number; activeWorktrees: number; - maxWorktrees: number; + /* + FNXC:CapacityModel 2026-07-28-11:35: + `null` means worktrees are not a capacity dimension for this project + (`settings.worktreeLimitEnabled === false`) — NOT "unlimited". Callers resolve it + through `resolveWorktreeCapacityLimit` so the OFF convention has exactly one + expression; passing a raw number here cannot accidentally re-enable the gate + because the resolver is the only thing that produces this value. + */ + maxWorktrees: number | null; semaphore?: AgentSemaphore; inProgressTaskIds: string[]; startedThisTick?: number; @@ -438,11 +459,16 @@ function computeConcurrencyGateDiagnostic(params: { limit: params.maxConcurrent, slack: params.maxConcurrent - maxConcurrentUsed, }; - const maxWorktreesGate: ConcurrencyGateSnapshot = { - used: maxWorktreesUsed, - limit: params.maxWorktrees, - slack: params.maxWorktrees - maxWorktreesUsed, - }; + // Worktrees off → no gate object at all. See the FNXC on `maxWorktreesGate`. + const maxWorktreesLimit = params.maxWorktrees; + const maxWorktreesGate: ConcurrencyGateSnapshot | undefined = + maxWorktreesLimit === null + ? undefined + : { + used: maxWorktreesUsed, + limit: maxWorktreesLimit, + slack: maxWorktreesLimit - maxWorktreesUsed, + }; const semaphoreGate = params.semaphore ? (() => { /* @@ -461,13 +487,13 @@ function computeConcurrencyGateDiagnostic(params: { : undefined; const available = Math.min( maxConcurrentGate.slack, - maxWorktreesGate.slack, + maxWorktreesGate?.slack ?? Infinity, semaphoreGate?.slack ?? Infinity, ); const bindingGates: ConcurrencyGateName[] = []; if (maxConcurrentGate.used >= maxConcurrentGate.limit) bindingGates.push("maxConcurrent"); - if (maxWorktreesGate.used >= maxWorktreesGate.limit) bindingGates.push("maxWorktrees"); + if (maxWorktreesGate && maxWorktreesGate.used >= maxWorktreesGate.limit) bindingGates.push("maxWorktrees"); if (semaphoreGate && semaphoreGate.used >= semaphoreGate.limit) bindingGates.push("semaphore"); return { @@ -478,7 +504,7 @@ function computeConcurrencyGateDiagnostic(params: { semaphoreGate, holders: { maxConcurrent: [...params.inProgressTaskIds], - maxWorktrees: [...params.inProgressTaskIds], + maxWorktrees: maxWorktreesGate ? [...params.inProgressTaskIds] : undefined, semaphore: semaphoreGate ? [...params.inProgressTaskIds] : undefined, }, // U6: additive only — present when flag-ON, omitted otherwise. @@ -494,8 +520,20 @@ function formatConcurrencyLimitReason(diagnostic: ConcurrencyGateDiagnostic): st const gateLabel = diagnostic.bindingGates.join(", "); const details = [ `maxConcurrent used=${diagnostic.maxConcurrentGate.used}/${diagnostic.maxConcurrentGate.limit} (holders: ${holdersText("maxConcurrent")})`, - `maxWorktrees used=${diagnostic.maxWorktreesGate.used}/${diagnostic.maxWorktreesGate.limit} (holders: ${holdersText("maxWorktrees")})`, ]; + /* + FNXC:CapacityModel 2026-07-28-11:35: + Omit the worktree line entirely when worktrees are off. Printing + "maxWorktrees used=2/Infinity" would tell an operator a limiter is present and + merely generous, which is the opposite of true — it is not consulted at all. + This string is the operator's answer to "why is my card queued?", so an absent + dimension must be absent from the answer. + */ + if (diagnostic.maxWorktreesGate) { + details.push( + `maxWorktrees used=${diagnostic.maxWorktreesGate.used}/${diagnostic.maxWorktreesGate.limit} (holders: ${holdersText("maxWorktrees")})`, + ); + } if (diagnostic.semaphoreGate) { const semaphoreUsed = Math.max(0, diagnostic.semaphoreGate.used); details.push( @@ -1556,7 +1594,12 @@ export class Scheduler { private async runHoldReleaseSweepPass(tasks: Task[], settings: Settings): Promise { try { - const maxWorktrees = settings.maxWorktrees ?? this.options.maxWorktrees ?? 4; + // FNXC:CapacityModel 2026-07-28-11:35: null = worktrees are not a capacity + // dimension for this project (worktreeLimitEnabled false), NOT unlimited. + const maxWorktrees = resolveWorktreeCapacityLimit({ + maxWorktrees: settings.maxWorktrees ?? this.options.maxWorktrees ?? 4, + worktreeLimitEnabled: settings.worktreeLimitEnabled, + }); const maxConcurrent = settings.maxConcurrent ?? this.options.maxConcurrent ?? 2; /* FNXC:WorkflowScheduling 2026-07-19-02:35 (U4/KTD-9): diff --git a/packages/engine/src/triage.ts b/packages/engine/src/triage.ts index 92229e62ca..7c514bfe8f 100644 --- a/packages/engine/src/triage.ts +++ b/packages/engine/src/triage.ts @@ -762,7 +762,7 @@ export class TriageProcessor { private async clearStaleSpecifyingStatuses(): Promise { /* FNXC:CodingIdeasWorkflow 2026-07-04-12:00: - In the merged planner/capacity "todo" column a task can carry status "planning" when the triage service is specifying it in place. A crash/restart before planning completes leaves that status set, so the startup sweep must clear it from BOTH triage and todo — otherwise a stale planning todo task permanently occupies a maxTriageConcurrent slot and blocks new triage work. + In the merged planner/capacity "todo" column a task can carry status "planning" when the triage service is specifying it in place. A crash/restart before planning completes leaves that status set, so the startup sweep must clear it from BOTH triage and todo — otherwise a stale planning todo task permanently occupies a planning admission slot and blocks new triage work. (The separate maxTriageConcurrent pool AND its setting are both gone — FN-8453 removed the pool, the capacity simplification removed the dead key; planning shares the one agent count.) */ const triageTasks = await this.store.listTasks({ column: "triage", slim: true }); const todoTasks = await this.store.listTasks({ column: "todo", slim: true }); @@ -1562,7 +1562,7 @@ export class TriageProcessor { /* FNXC:ConcurrencyAdmission 2026-08-03-12:00: - FN-8453 removes the separate maxTriageConcurrent pool. Planning uses the + FN-8453 removed the separate maxTriageConcurrent pool, and the capacity simplification deleted the orphaned setting it left behind. Planning uses the same maxConcurrent live-agent claim as execute/review so a project cannot exceed its operator-facing top-level capacity in a different lane. */ @@ -1844,7 +1844,7 @@ export class TriageProcessor { FNXC:Triage 2026-07-16-05:35: A skip on this PRIMARY claim path is an anomaly, not a benign scheduler race: poll() already proved the card is an eligible planner candidate, so failing the guard here - means it is re-claimed every poll, never planned, and holds a maxTriageConcurrent slot + means it is re-claimed every poll, never planned, and holds a planning admission slot against healthy cards. Recovery-write skips stay silent by design (see updatePlanningStateIfStillCurrent); this one must be visible — the FN-7977 steps>0 wedge stalled the whole planner for hours precisely because it logged nothing. diff --git a/packages/i18n/locales/en/app.json b/packages/i18n/locales/en/app.json index 32cf548571..18526ad639 100644 --- a/packages/i18n/locales/en/app.json +++ b/packages/i18n/locales/en/app.json @@ -1571,7 +1571,6 @@ "description": "Tune live scheduler capacity.", "error": "Unable to load concurrency settings", "maxConcurrent": "Max concurrent tasks", - "maxTriageConcurrent": "Max triage concurrent", "maxWorktrees": "Max worktrees", "title": "Concurrency", "runningGlobal": "{{count}} running (all projects)", @@ -6769,7 +6768,6 @@ "maximumConcurrentPlanningAgents": "Maximum concurrent planning agents. Default: 2.", "maximumStuckDetectorRetriesBeforeATaskIs": "Maximum stuck-detector retries before a task is marked failed. Default: 6.", "maxStuckRetries": "Max Stuck Retries", - "maxTriageConcurrent": "Max Triage Concurrent", "numberOfDaysATaskCanStayIn": "Number of days a task can stay in Done before it is archived. Default: 2 days (48 hours).", "off": "Off", "optionalFileOrDirectoryPathsToIgnoreWhen": " No default — unset (empty). Optional file or directory paths to ignore when overlap serialization is enabled. Paths are project-relative (for example ", @@ -6831,6 +6829,8 @@ "keepsProgressMovingBySwitchingToFusionApos": " keeps progress moving by switching to Fusion's built-in worktree backend. ", "limitsTotalGitWorktreesIncludingInReviewTasks": "Limits total git worktrees including in-review tasks. Default: 4.", "maxWorktrees": "Max Worktrees", + "worktreeLimitEnabled": "Limit concurrent worktrees", + "worktreeLimitEnabledHelp": "When on, Max Worktrees caps how many tasks may hold a worktree at once. When off, Max Concurrent Tasks is the only limit. Tasks always run in their own git worktree either way — this does not change where work executes. Default: on.", "offByDefaultOptInWhenEnabledCompleted": "Off by default (opt-in). When enabled, completed task worktrees are returned to an idle pool instead of being deleted, preserving build caches for faster startup. Mutually exclusive with Task ID worktree naming.", "recycleNotApplicableWithTaskIdNaming": "Not available with Task ID worktree naming — that mode pins each task to its own worktree directory, which is mutually exclusive with the recycle pool. Switch naming to Random or Task title to enable recycling.", "openApprovals": "Open Approvals", diff --git a/packages/i18n/locales/es/app.json b/packages/i18n/locales/es/app.json index 4e0344ef82..147331cc11 100644 --- a/packages/i18n/locales/es/app.json +++ b/packages/i18n/locales/es/app.json @@ -1561,7 +1561,6 @@ "description": "", "error": "", "maxConcurrent": "", - "maxTriageConcurrent": "", "maxWorktrees": "", "title": "", "runningGlobal": "", @@ -6731,7 +6730,6 @@ "maximumConcurrentPlanningAgents": "", "maximumStuckDetectorRetriesBeforeATaskIs": "", "maxStuckRetries": "", - "maxTriageConcurrent": "", "numberOfDaysATaskCanStayIn": "", "off": "", "optionalFileOrDirectoryPathsToIgnoreWhen": "", @@ -6809,6 +6807,8 @@ "keepsProgressMovingBySwitchingToFusionApos": "", "limitsTotalGitWorktreesIncludingInReviewTasks": "", "maxWorktrees": "", + "worktreeLimitEnabled": "", + "worktreeLimitEnabledHelp": "", "offByDefaultOptInWhenEnabledCompleted": "", "openApprovals": "Abrir aprobaciones", "optionalLeaveBlankToAutoResolveFusionWill": "", diff --git a/packages/i18n/locales/fr/app.json b/packages/i18n/locales/fr/app.json index 0d8d4d8e6f..308c8bf994 100644 --- a/packages/i18n/locales/fr/app.json +++ b/packages/i18n/locales/fr/app.json @@ -1561,7 +1561,6 @@ "description": "", "error": "", "maxConcurrent": "", - "maxTriageConcurrent": "", "maxWorktrees": "", "title": "", "runningGlobal": "", @@ -6731,7 +6730,6 @@ "maximumConcurrentPlanningAgents": "", "maximumStuckDetectorRetriesBeforeATaskIs": "", "maxStuckRetries": "", - "maxTriageConcurrent": "", "numberOfDaysATaskCanStayIn": "", "off": "", "optionalFileOrDirectoryPathsToIgnoreWhen": "", @@ -6809,6 +6807,8 @@ "keepsProgressMovingBySwitchingToFusionApos": "", "limitsTotalGitWorktreesIncludingInReviewTasks": "", "maxWorktrees": "", + "worktreeLimitEnabled": "", + "worktreeLimitEnabledHelp": "", "offByDefaultOptInWhenEnabledCompleted": "", "openApprovals": "Ouvrir les approbations", "optionalLeaveBlankToAutoResolveFusionWill": "", diff --git a/packages/i18n/locales/ko/app.json b/packages/i18n/locales/ko/app.json index 973bad6a51..9634f705bc 100644 --- a/packages/i18n/locales/ko/app.json +++ b/packages/i18n/locales/ko/app.json @@ -1561,7 +1561,6 @@ "description": "", "error": "", "maxConcurrent": "", - "maxTriageConcurrent": "", "maxWorktrees": "", "title": "", "runningGlobal": "", @@ -6731,7 +6730,6 @@ "maximumConcurrentPlanningAgents": "", "maximumStuckDetectorRetriesBeforeATaskIs": "", "maxStuckRetries": "", - "maxTriageConcurrent": "", "numberOfDaysATaskCanStayIn": "", "off": "", "optionalFileOrDirectoryPathsToIgnoreWhen": "", @@ -6809,6 +6807,8 @@ "keepsProgressMovingBySwitchingToFusionApos": "", "limitsTotalGitWorktreesIncludingInReviewTasks": "", "maxWorktrees": "", + "worktreeLimitEnabled": "", + "worktreeLimitEnabledHelp": "", "offByDefaultOptInWhenEnabledCompleted": "", "openApprovals": "승인 열기", "optionalLeaveBlankToAutoResolveFusionWill": "", diff --git a/packages/i18n/locales/zh-CN/app.json b/packages/i18n/locales/zh-CN/app.json index 709a993e6c..1347a8c3c6 100644 --- a/packages/i18n/locales/zh-CN/app.json +++ b/packages/i18n/locales/zh-CN/app.json @@ -1561,7 +1561,6 @@ "description": "", "error": "", "maxConcurrent": "", - "maxTriageConcurrent": "", "maxWorktrees": "", "title": "", "runningGlobal": "", @@ -6731,7 +6730,6 @@ "maximumConcurrentPlanningAgents": "", "maximumStuckDetectorRetriesBeforeATaskIs": "", "maxStuckRetries": "", - "maxTriageConcurrent": "", "numberOfDaysATaskCanStayIn": "", "off": "", "optionalFileOrDirectoryPathsToIgnoreWhen": "", @@ -6809,6 +6807,8 @@ "keepsProgressMovingBySwitchingToFusionApos": "", "limitsTotalGitWorktreesIncludingInReviewTasks": "", "maxWorktrees": "", + "worktreeLimitEnabled": "", + "worktreeLimitEnabledHelp": "", "offByDefaultOptInWhenEnabledCompleted": "", "openApprovals": "打开审批", "optionalLeaveBlankToAutoResolveFusionWill": "", diff --git a/packages/i18n/locales/zh-TW/app.json b/packages/i18n/locales/zh-TW/app.json index 6c15d0910c..c246403a1d 100644 --- a/packages/i18n/locales/zh-TW/app.json +++ b/packages/i18n/locales/zh-TW/app.json @@ -1561,7 +1561,6 @@ "description": "", "error": "", "maxConcurrent": "", - "maxTriageConcurrent": "", "maxWorktrees": "", "title": "", "runningGlobal": "", @@ -6731,7 +6730,6 @@ "maximumConcurrentPlanningAgents": "", "maximumStuckDetectorRetriesBeforeATaskIs": "", "maxStuckRetries": "", - "maxTriageConcurrent": "", "numberOfDaysATaskCanStayIn": "", "off": "", "optionalFileOrDirectoryPathsToIgnoreWhen": "", @@ -6809,6 +6807,8 @@ "keepsProgressMovingBySwitchingToFusionApos": "", "limitsTotalGitWorktreesIncludingInReviewTasks": "", "maxWorktrees": "", + "worktreeLimitEnabled": "", + "worktreeLimitEnabledHelp": "", "offByDefaultOptInWhenEnabledCompleted": "", "openApprovals": "開啟審批", "optionalLeaveBlankToAutoResolveFusionWill": "", diff --git a/packages/i18n/src/resources.d.ts b/packages/i18n/src/resources.d.ts index 9825d93bae..336ebeefc8 100644 --- a/packages/i18n/src/resources.d.ts +++ b/packages/i18n/src/resources.d.ts @@ -1558,7 +1558,6 @@ export default interface Resources { "description": "Tune live scheduler capacity.", "error": "Unable to load concurrency settings", "maxConcurrent": "Max concurrent tasks", - "maxTriageConcurrent": "Max triage concurrent", "maxWorktrees": "Max worktrees", "runningGlobal": "{{count}} running (all projects)", "runningProject": "{{count}} running (this project)", @@ -6800,7 +6799,6 @@ export default interface Resources { "maxConcurrentVerifications": "Max Concurrent Verifications", "maxConcurrentVerificationsHint": "Caps stacked typecheck/build verification across tasks. Default: 1. Range: 1–8.", "maxStuckRetries": "Max Stuck Retries", - "maxTriageConcurrent": "Max Triage Concurrent", "maximumAgeInHoursBeforeAPlanIs": "Maximum age in hours before a plan is considered stale. Default: 6 hours.", "maximumConcurrentAgentsAcrossAllProjects": "Maximum concurrent agents across all projects. Default: 4.", "maximumConcurrentPlanningAgents": "Maximum concurrent planning agents. Default: 2.", @@ -6878,6 +6876,8 @@ export default interface Resources { "keepsProgressMovingBySwitchingToFusionApos": " keeps progress moving by switching to Fusion's built-in worktree backend. ", "limitsTotalGitWorktreesIncludingInReviewTasks": "Limits total git worktrees including in-review tasks. Default: 4.", "maxWorktrees": "Max Worktrees", + "worktreeLimitEnabled": "Limit concurrent worktrees", + "worktreeLimitEnabledHelp": "When on, Max Worktrees caps how many tasks may hold a worktree at once. When off, Max Concurrent Tasks is the only limit. Tasks always run in their own git worktree either way — this does not change where work executes. Default: on.", "namingStyleNotApplicableWhenRecycling": "Naming style is not applicable when recycling worktrees — pooled worktrees retain their existing names. \"Task ID\" is unavailable here because task-pinned worktrees are mutually exclusive with recycling; turn off Recycle worktrees to use it.", "offByDefaultOptInWhenEnabledCompleted": "Off by default (opt-in). When enabled, completed task worktrees are returned to an idle pool instead of being deleted, preserving build caches for faster startup. Mutually exclusive with Task ID worktree naming.", "openApprovals": "Open Approvals",