capacity, part 1: merge pinned at 1, worktrees-off mode, and one dead knob deleted (#2502)

First slice of the capacity simplification. Operator: *"just have two
capacity — overall per project agent count and max worktrees. Remove all
other capacities and counts."* Plus two later additions: **merge is
always 1, fixed**, and **worktrees off ⇒ limit by total agents only**.

Three independently revertable commits. No limiter is added anywhere;
one is deleted, one is made structurally absent, and one is pinned.

---

## 1. Merge concurrency ratcheted at 1 (test-only)

I was asked to add a limiter if merge concurrency could be raised. **It
cannot** — there is no setting, workflow property, pool or trait config
anywhere that raises it, so this adds no code and pins what already
holds.

Serialization lives in the **pump**: `drainMergeQueue`’s `mergeRunning`
re-entrancy latch, `activeMergeTaskId` as a single-slot identity, the
`mergeBodyInFlight` next-generation latch, and one `ProjectEngine` per
projectId.

**Not** in the merge-queue lease, which is a per-task ROW (`primaryKey
[projectId, taskId]`) — two tasks can hold leases simultaneously by
construction, and it has exactly one caller (the worktree-reuse
handoff). Ordinary merges never take it. A lease-level test would have
been describing an invariant that layer has never held.

The second half guards the other direction: a merge-concurrency
*setting* would not fail the pump ratchet — it would sit unread until
someone wired it up.

**Revert-proof:** deleting the latch → `expected 1 times, but got 2
times`; deleting the `finally` → latch-stuck; injecting
`maxConcurrentMerges: 2` → fails naming the key; injecting a
`maxParallelLanes` merge-trait field → fails naming the field. Sources
restored byte-identical after each injection.

## 2. `worktreesEnabled` — off means the worktree limit cannot bind

No worktrees-off mode existed (no
`worktreesEnabled`/`useWorktrees`/`worktreeMode` anywhere — only
worktree *configuration*).

**Why not `maxWorktrees: 0`, which needs no new key:** it deadlocks. `??
4` keeps `0` (not nullish), the gate is `used >= limit`, so `0 >= 0`
holds **on an empty board** and nothing ever dispatches — while the
operator-visible reason reads `gate=maxWorktrees; used=0/0`, a limiter
that looks like it is working while the board is dead. It also needs the
Command Center `{min:1}` clamp relaxed. So `0` costs the gate rewrite
*and* the clamp change *and* encodes a mode as a magic value.

**Off is absence, not a big number.** `resolveWorktreeCapacityLimit`
returns `number | null`; `ConcurrencyGateDiagnostic.maxWorktreesGate` is
now optional, so consulting a worktree limit in OFF mode does not
type-check. A gate holding `Infinity` can start binding again the moment
someone "fixes" a comparison; an absent gate cannot.

That paid for itself immediately: making it nullable surfaced a
**second, independent** worktree gate (`activeWorktrees >= maxWorktrees`
early-return) that a skip-by-convention approach would have missed
silently.

**Scope, deliberately:** this is a statement about *counting*, not
isolation. It does not make concurrent agents safe to share one checkout
and builds nothing toward that — the non-worktree paths that exist today
are fallbacks to the operator’s own tree, one of which caused FN-8600.

**Revert-proof:** a resolver ignoring the flag turns both OFF scheduler
tests red while every ON test stays green — they reuse the *same*
fixture (5 in-progress, limit 4) that pre-existing tests prove blocks,
so the pair moves in opposite directions. Removing `disabled:` reddens
the UI test.

## 3. `maxTriageConcurrent` deleted — it controlled nothing

**Measured: zero enforcement reads.** The only `.maxTriageConcurrent`
reference in the repo was a route echoing it back in `/config`. FN-8453
removed the pool it gated and left the knob shipping in
`DEFAULT_SETTINGS`, the settings type, the section registry, the API
response and six i18n catalogs, doing nothing, for releases.

Historical FNXC comments are **updated, not deleted** — they explain a
real past incident; they now say "planning admission slot" so they stop
implying a live setting. Tombstoned so it cannot return.

`/config` loses a field; safe in-repo since `fetchConfig`’s own return
type never declared it.

---

## Two corrections worth recording

- I earlier reported `maxWorktrees` had **no** Settings UI. Wrong —
`WorktreesSection.tsx:47`; my grep was truncated by `head`. It changed
the placement (toggle beside it, rather than a duplicate key in
Scheduling).
- I planned to assert the queued-reason string is rewritten in OFF mode.
Measured that it is **unreachable**: when `maxConcurrent` binds, the
sweep bails before the per-task reason and logs nothing. The test
asserts absence instead.

Two near-misses caught before commit: a pre-existing FN-7505 guard
caught my *new* key missing a description mapping; and editing i18n via
`json.load/dump` silently dropped unrelated duplicate keys
(`autoUpdateAndRestart` in `fr`) — Python keeps only the last of a
duplicated key. Redone textually, every catalog re-validated.

## Verification

`pnpm lint` clean · core/engine/dashboard/i18n typecheck clean · `pnpm
test:gate` green (309 + 10 + 71) · capacity/worktree suites 11/11 ·
engine merge-invariant + scheduler 45/45 · dashboard settings 114/114.
Rebased onto current main and re-verified.

Nothing was booted at any point.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

## Summary by CodeRabbit

* **New Features**
* Added a project setting to enable or disable running tasks in
worktrees.
* Disabling worktrees removes worktree capacity limits from task
scheduling.
* The “Max Worktrees” setting is disabled when worktree execution is
turned off.

* **Changes**
* Removed the unused triage concurrency setting from configuration and
dashboard responses.
* Updated scheduling diagnostics and queue messages to reflect disabled
worktree capacity limits.
  * Added localized labels and help text for the new setting.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-28 18:52:31 -07:00
committed by GitHub
parent 35b0df1838
commit 743df98aa4
39 changed files with 779 additions and 73 deletions

View File

@@ -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.

View File

@@ -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<string>();
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);
});
});

View File

@@ -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);
});
});

View File

@@ -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 {

View File

@@ -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";

View File

@@ -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,

View File

@@ -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).

View File

@@ -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<Settings, "maxWorktrees" | "worktreeLimitEnabled"> | 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). */

View File

@@ -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(
<WorktreesSection
form={{ recycleWorktrees: false, worktreeCopyFiles: [], maxWorktrees: 4, worktreeLimitEnabled: true } as SettingsFormState}
setForm={vi.fn()}
{...worktreeCapacityProps}
/>,
);
expect(screen.getByLabelText("Max Worktrees")).not.toBeDisabled();
});
it("disables the Max Worktrees control while worktrees are off", () => {
render(
<WorktreesSection
form={{ recycleWorktrees: false, worktreeCopyFiles: [], maxWorktrees: 4, worktreeLimitEnabled: false } as SettingsFormState}
setForm={vi.fn()}
{...worktreeCapacityProps}
/>,
);
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(
<WorktreesSection
form={{ recycleWorktrees: false, worktreeCopyFiles: [], maxWorktrees: 4 } as SettingsFormState}
setForm={vi.fn()}
{...worktreeCapacityProps}
/>,
);
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(
<WorktreesSection
form={{ recycleWorktrees: false, worktreeCopyFiles: [], maxWorktrees: 4, worktreeLimitEnabled: true } as SettingsFormState}
setForm={setForm}
{...worktreeCapacityProps}
/>,
);
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") {

View File

@@ -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,

View File

@@ -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();

View File

@@ -57,7 +57,6 @@ function buildSettings() {
return {
autoMerge: true,
maxConcurrent: 2,
maxTriageConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 15000,
heartbeatMultiplier: 1,

View File

@@ -58,7 +58,6 @@ function buildSettings() {
autoMerge: true,
testMode: false,
maxConcurrent: 2,
maxTriageConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 15000,
heartbeatMultiplier: 1,

View File

@@ -46,7 +46,6 @@ function buildSettings(testMode: boolean) {
autoMerge: true,
testMode,
maxConcurrent: 2,
maxTriageConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 15000,
heartbeatMultiplier: 1,

View File

@@ -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({}),
}));

View File

@@ -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({}),
}));

View File

@@ -27,7 +27,6 @@ vi.mock("../../../hooks/useAppSettings", () => ({
const defaultSettings = {
maxConcurrent: 12,
maxTriageConcurrent: 1,
maxWorktrees: 4,
};

View File

@@ -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 }),

View File

@@ -119,6 +119,7 @@ export const PROJECT_SECTION_KEYS: Readonly<Record<string, readonly string[]>> =
worktrees: [
"executorAllowSiblingBranchRename",
"maxWorktrees",
"worktreeLimitEnabled",
"recycleWorktrees",
"showWorktreeGrouping",
"worktreeCopyFiles",
@@ -148,7 +149,6 @@ export const PROJECT_SECTION_KEYS: Readonly<Record<string, readonly string[]>> =
"maxConcurrent",
"maxConcurrentVerifications",
"maxStuckKills",
"maxTriageConcurrent",
"overlapIgnorePaths",
"pollIntervalMs",
"preserveProgressOnStuckRequeue",

View File

@@ -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",

View File

@@ -41,15 +41,44 @@ export function WorktreesSection({ form, setForm, gitRemotes, worktrunkInstall,
const worktreeCopyFileRows = (form.worktreeCopyFiles?.length ?? 0) > 0 ? form.worktreeCopyFiles ?? [] : [""];
return (<>
<h4 className="settings-section-heading">{t("settings.worktrees.worktrees", "Worktrees")}</h4>
{/*
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.
*/}
<SettingsToggleRow
descriptor={{
key: "worktreeLimitEnabled",
label: t("settings.worktrees.worktreeLimitEnabled", "Limit concurrent worktrees"),
help: t("settings.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."),
scope: "project",
}}
value={form.worktreeLimitEnabled !== false}
onChange={(value) => 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. */}
<SettingsNumberRow
descriptor={{
key: "maxWorktrees",
label: t("settings.worktrees.maxWorktrees", "Max Worktrees"),
help: t("settings.worktrees.limitsTotalGitWorktreesIncludingInReviewTasks", "Limits total git worktrees including in-review tasks. Default: 4."),
help: t("settings.worktrees.limitsTotalGitWorktreesIncludingInReviewTasks", "Limits total git worktrees including in-review tasks. Ignored while \u201cLimit concurrent worktrees\u201d is off. Default: 4."),
scope: "project",
min: 1,
max: 20,
disabled: form.worktreeLimitEnabled === false,
}}
value={form.maxWorktrees ?? null}
onChange={(v) => setForm((f) => ({ ...f, maxWorktrees: v ?? undefined } as SettingsFormState))}

View File

@@ -214,7 +214,6 @@ const SETTING_DESCRIPTION_KEYS: Record<string, string> = {
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<string, string> = {
overlapIgnorePaths: "scheduling.optionalFileOrDirectoryPathsToIgnoreWhen",
// WorktreesSection
maxWorktrees: "worktrees.limitsTotalGitWorktreesIncludingInReviewTasks",
worktreeLimitEnabled: "worktrees.worktreeLimitEnabledHelp",
worktreeInitCommand: "worktrees.shellCommandToRunInEachNewWorktree",
recycleWorktrees: "worktrees.offByDefaultOptInWhenEnabledCompleted",
showWorktreeGrouping: "worktrees.showWorktreeGroupingHelp",

View File

@@ -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 () => {

View File

@@ -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() });
}
});

View File

@@ -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<string, unknown> | null,
}));
vi.mock("@fusion/core", async (importOriginal) => {
const { createEngineCoreMock } = await import("../test/mockCore.js");
return createEngineCoreMock(() => importOriginal<typeof import("@fusion/core")>(), {});
});
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<void>; resolve: () => void };
function deferred(): Deferred {
let resolve!: () => void;
const promise = new Promise<void>((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<void>;
};
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<void>;
};
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();
});
});

View File

@@ -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<Task>) => {
const idx = tasks.findIndex((t) => t.id === id);

View File

@@ -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" });

View File

@@ -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,

View File

@@ -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,

View File

@@ -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) {
/*

View File

@@ -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<void> {
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):

View File

@@ -762,7 +762,7 @@ export class TriageProcessor {
private async clearStaleSpecifyingStatuses(): Promise<void> {
/*
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.

View File

@@ -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&apos;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",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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&apos;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",