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:
7
.changeset/capacity-worktrees-toggle.md
Normal file
7
.changeset/capacity-worktrees-toggle.md
Normal 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.
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
51
packages/core/src/__tests__/worktree-capacity-limit.test.ts
Normal file
51
packages/core/src/__tests__/worktree-capacity-limit.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -416,7 +416,7 @@ export {
|
|||||||
} from "./plugin-gate-verdict.js";
|
} from "./plugin-gate-verdict.js";
|
||||||
export type { PluginGateVerdict, ColumnPluginGate } from "./plugin-gate-verdict.js";
|
export type { PluginGateVerdict, ColumnPluginGate } from "./plugin-gate-verdict.js";
|
||||||
// ── U6: workflow capacity (WIP) resolution shared by store + sweep ───────────
|
// ── 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";
|
export type { ColumnCapacity } from "./workflow-capacity.js";
|
||||||
// ── U5: workflow lifecycle reconciliation (switch / edit / delete) ───────────
|
// ── U5: workflow lifecycle reconciliation (switch / edit / delete) ───────────
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -447,7 +447,7 @@ export {
|
|||||||
} from "./plugin-gate-verdict.js";
|
} from "./plugin-gate-verdict.js";
|
||||||
export type { PluginGateVerdict, ColumnPluginGate } from "./plugin-gate-verdict.js";
|
export type { PluginGateVerdict, ColumnPluginGate } from "./plugin-gate-verdict.js";
|
||||||
// ── U6: workflow capacity (WIP) resolution shared by store + sweep ───────────
|
// ── 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 { createWorkflowEventBus, getWorkflowEventBus, emitWorkflowLifecycleEvent, resetWorkflowEventBusForTesting } from "./workflow-events.js";
|
||||||
export type { WorkflowEventBus, WorkflowEventSubscriber, WorkflowEventSubscription } 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";
|
export { findWorkflowEventShapeViolations, isIdsOnlyWorkflowEvent, MAX_ID_VALUE_LENGTH, IMPLEMENTATION_EXITS } from "./types/workflow-events.js";
|
||||||
|
|||||||
@@ -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).
|
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,
|
maxConcurrentVerifications: 1,
|
||||||
maxTriageConcurrent: 2,
|
|
||||||
globalMaxConcurrent: 4,
|
globalMaxConcurrent: 4,
|
||||||
maxWorktrees: 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,
|
pollIntervalMs: 15000,
|
||||||
heartbeatMultiplier: 1,
|
heartbeatMultiplier: 1,
|
||||||
autoClaimCandidatesInPrompt: 5,
|
autoClaimCandidatesInPrompt: 5,
|
||||||
|
|||||||
@@ -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.
|
* 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;
|
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.
|
/** System-wide maximum concurrent agents across ALL projects.
|
||||||
* When multiple projects are active, the sum of their in-flight agents
|
* When multiple projects are active, the sum of their in-flight agents
|
||||||
* will not exceed this limit. Applies to triage, execution, and merge.
|
* will not exceed this limit. Applies to triage, execution, and merge.
|
||||||
* Default: 4. When undefined, falls back to CentralCore default (4). */
|
* Default: 4. When undefined, falls back to CentralCore default (4). */
|
||||||
globalMaxConcurrent?: number;
|
globalMaxConcurrent?: number;
|
||||||
maxWorktrees: 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;
|
pollIntervalMs: number;
|
||||||
/** Global multiplier applied to all agent heartbeat intervals.
|
/** Global multiplier applied to all agent heartbeat intervals.
|
||||||
* For example, 0.5 halves the interval (faster checks), 2.0 doubles it (slower checks).
|
* For example, 0.5 halves the interval (faster checks), 2.0 doubles it (slower checks).
|
||||||
|
|||||||
@@ -29,6 +29,42 @@ import { getTraitRegistry } from "./trait-registry.js";
|
|||||||
* `settings.maxConcurrent` (the legacy "N agents in-progress" gate). */
|
* `settings.maxConcurrent` (the legacy "N agents in-progress" gate). */
|
||||||
const DEFAULT_WIP_COLUMN_ID = "in-progress";
|
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
|
/** U6 (KTD-10): sentinel effective-workflow id for default-workflow
|
||||||
* (null-selection) tasks, so they all share one per-column capacity pool. It
|
* (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). */
|
* is not a real workflow row id (no `builtin:`/custom collision possible). */
|
||||||
|
|||||||
@@ -296,6 +296,80 @@ describe("WorktreesSection", () => {
|
|||||||
expect(onAdd).toHaveBeenCalledTimes(1);
|
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", () => {
|
it("renders and toggles the board worktree grouping checkbox", () => {
|
||||||
const setForm = vi.fn((updater: SettingsFormState | ((prev: SettingsFormState) => SettingsFormState)) => {
|
const setForm = vi.fn((updater: SettingsFormState | ((prev: SettingsFormState) => SettingsFormState)) => {
|
||||||
if (typeof updater === "function") {
|
if (typeof updater === "function") {
|
||||||
|
|||||||
@@ -973,6 +973,9 @@ export function SettingsModal({
|
|||||||
maxConcurrent: 2,
|
maxConcurrent: 2,
|
||||||
maxConcurrentVerifications: 1,
|
maxConcurrentVerifications: 1,
|
||||||
maxWorktrees: 4,
|
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,
|
pollIntervalMs: 15000,
|
||||||
heartbeatMultiplier: 1,
|
heartbeatMultiplier: 1,
|
||||||
groupOverlappingFiles: true,
|
groupOverlappingFiles: true,
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ const commandCenterControlsCss = readAppFile("components/command-center/CommandC
|
|||||||
|
|
||||||
const defaultSettings = {
|
const defaultSettings = {
|
||||||
maxConcurrent: 2,
|
maxConcurrent: 2,
|
||||||
maxTriageConcurrent: 1,
|
|
||||||
maxWorktrees: 4,
|
maxWorktrees: 4,
|
||||||
globalPause: false,
|
globalPause: false,
|
||||||
enginePaused: false,
|
enginePaused: false,
|
||||||
@@ -293,7 +292,6 @@ describe("EngineControlMenu", () => {
|
|||||||
legacyMocks.fetchSettings.mockResolvedValue({
|
legacyMocks.fetchSettings.mockResolvedValue({
|
||||||
...defaultSettings,
|
...defaultSettings,
|
||||||
maxConcurrent: 60,
|
maxConcurrent: 60,
|
||||||
maxTriageConcurrent: 70,
|
|
||||||
maxWorktrees: 80,
|
maxWorktrees: 80,
|
||||||
});
|
});
|
||||||
await openMenu();
|
await openMenu();
|
||||||
@@ -335,7 +333,6 @@ describe("EngineControlMenu", () => {
|
|||||||
legacyMocks.fetchSettings.mockResolvedValue({
|
legacyMocks.fetchSettings.mockResolvedValue({
|
||||||
...defaultSettings,
|
...defaultSettings,
|
||||||
maxConcurrent: 12,
|
maxConcurrent: 12,
|
||||||
maxTriageConcurrent: 3,
|
|
||||||
maxWorktrees: 25,
|
maxWorktrees: 25,
|
||||||
});
|
});
|
||||||
await openMenu();
|
await openMenu();
|
||||||
|
|||||||
@@ -57,7 +57,6 @@ function buildSettings() {
|
|||||||
return {
|
return {
|
||||||
autoMerge: true,
|
autoMerge: true,
|
||||||
maxConcurrent: 2,
|
maxConcurrent: 2,
|
||||||
maxTriageConcurrent: 2,
|
|
||||||
maxWorktrees: 4,
|
maxWorktrees: 4,
|
||||||
pollIntervalMs: 15000,
|
pollIntervalMs: 15000,
|
||||||
heartbeatMultiplier: 1,
|
heartbeatMultiplier: 1,
|
||||||
|
|||||||
@@ -58,7 +58,6 @@ function buildSettings() {
|
|||||||
autoMerge: true,
|
autoMerge: true,
|
||||||
testMode: false,
|
testMode: false,
|
||||||
maxConcurrent: 2,
|
maxConcurrent: 2,
|
||||||
maxTriageConcurrent: 2,
|
|
||||||
maxWorktrees: 4,
|
maxWorktrees: 4,
|
||||||
pollIntervalMs: 15000,
|
pollIntervalMs: 15000,
|
||||||
heartbeatMultiplier: 1,
|
heartbeatMultiplier: 1,
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ function buildSettings(testMode: boolean) {
|
|||||||
autoMerge: true,
|
autoMerge: true,
|
||||||
testMode,
|
testMode,
|
||||||
maxConcurrent: 2,
|
maxConcurrent: 2,
|
||||||
maxTriageConcurrent: 2,
|
|
||||||
maxWorktrees: 4,
|
maxWorktrees: 4,
|
||||||
pollIntervalMs: 15000,
|
pollIntervalMs: 15000,
|
||||||
heartbeatMultiplier: 1,
|
heartbeatMultiplier: 1,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ vi.mock("../../../api/legacy", () => ({
|
|||||||
projectId ? `${path}${path.includes("?") ? "&" : "?"}projectId=${encodeURIComponent(projectId)}` : path,
|
projectId ? `${path}${path.includes("?") ? "&" : "?"}projectId=${encodeURIComponent(projectId)}` : path,
|
||||||
fetchOrgTree: vi.fn().mockResolvedValue([]),
|
fetchOrgTree: vi.fn().mockResolvedValue([]),
|
||||||
fetchExecutorStats: vi.fn().mockResolvedValue({ globalPause: false, enginePaused: false, maxConcurrent: 2 }),
|
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: "/" }),
|
fetchConfig: vi.fn().mockResolvedValue({ maxConcurrent: 2, rootDir: "/" }),
|
||||||
updateSettings: vi.fn().mockResolvedValue({}),
|
updateSettings: vi.fn().mockResolvedValue({}),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ vi.mock("../../../api/legacy", () => ({
|
|||||||
projectId ? `${path}${path.includes("?") ? "&" : "?"}projectId=${encodeURIComponent(projectId)}` : path,
|
projectId ? `${path}${path.includes("?") ? "&" : "?"}projectId=${encodeURIComponent(projectId)}` : path,
|
||||||
fetchOrgTree: vi.fn().mockResolvedValue([]),
|
fetchOrgTree: vi.fn().mockResolvedValue([]),
|
||||||
fetchExecutorStats: vi.fn().mockResolvedValue({ globalPause: false, enginePaused: false, maxConcurrent: 2 }),
|
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: "/" }),
|
fetchConfig: vi.fn().mockResolvedValue({ maxConcurrent: 2, rootDir: "/" }),
|
||||||
updateSettings: vi.fn().mockResolvedValue({}),
|
updateSettings: vi.fn().mockResolvedValue({}),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ vi.mock("../../../hooks/useAppSettings", () => ({
|
|||||||
|
|
||||||
const defaultSettings = {
|
const defaultSettings = {
|
||||||
maxConcurrent: 12,
|
maxConcurrent: 12,
|
||||||
maxTriageConcurrent: 1,
|
|
||||||
maxWorktrees: 4,
|
maxWorktrees: 4,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ vi.mock("../../../api/legacy", () => ({
|
|||||||
projectId ? `${path}${path.includes("?") ? "&" : "?"}projectId=${encodeURIComponent(projectId)}` : path,
|
projectId ? `${path}${path.includes("?") ? "&" : "?"}projectId=${encodeURIComponent(projectId)}` : path,
|
||||||
fetchOrgTree: vi.fn().mockResolvedValue([]),
|
fetchOrgTree: vi.fn().mockResolvedValue([]),
|
||||||
fetchExecutorStats: vi.fn().mockResolvedValue({ globalPause: false, enginePaused: false, maxConcurrent: 2 }),
|
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: "/" }),
|
fetchConfig: vi.fn().mockResolvedValue({ maxConcurrent: 2, rootDir: "/" }),
|
||||||
updateSettings: vi.fn().mockResolvedValue({}),
|
updateSettings: vi.fn().mockResolvedValue({}),
|
||||||
createBackup: vi.fn().mockResolvedValue({ ok: true }),
|
createBackup: vi.fn().mockResolvedValue({ ok: true }),
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ export const PROJECT_SECTION_KEYS: Readonly<Record<string, readonly string[]>> =
|
|||||||
worktrees: [
|
worktrees: [
|
||||||
"executorAllowSiblingBranchRename",
|
"executorAllowSiblingBranchRename",
|
||||||
"maxWorktrees",
|
"maxWorktrees",
|
||||||
|
"worktreeLimitEnabled",
|
||||||
"recycleWorktrees",
|
"recycleWorktrees",
|
||||||
"showWorktreeGrouping",
|
"showWorktreeGrouping",
|
||||||
"worktreeCopyFiles",
|
"worktreeCopyFiles",
|
||||||
@@ -148,7 +149,6 @@ export const PROJECT_SECTION_KEYS: Readonly<Record<string, readonly string[]>> =
|
|||||||
"maxConcurrent",
|
"maxConcurrent",
|
||||||
"maxConcurrentVerifications",
|
"maxConcurrentVerifications",
|
||||||
"maxStuckKills",
|
"maxStuckKills",
|
||||||
"maxTriageConcurrent",
|
|
||||||
"overlapIgnorePaths",
|
"overlapIgnorePaths",
|
||||||
"pollIntervalMs",
|
"pollIntervalMs",
|
||||||
"preserveProgressOnStuckRequeue",
|
"preserveProgressOnStuckRequeue",
|
||||||
|
|||||||
@@ -9,6 +9,22 @@
|
|||||||
import type { SettingsSearchEntry } from "../search/types";
|
import type { SettingsSearchEntry } from "../search/types";
|
||||||
|
|
||||||
export const worktreesSearchEntries: SettingsSearchEntry[] = [
|
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",
|
sectionId: "worktrees",
|
||||||
key: "maxWorktrees",
|
key: "maxWorktrees",
|
||||||
|
|||||||
@@ -41,15 +41,44 @@ export function WorktreesSection({ form, setForm, gitRemotes, worktrunkInstall,
|
|||||||
const worktreeCopyFileRows = (form.worktreeCopyFiles?.length ?? 0) > 0 ? form.worktreeCopyFiles ?? [] : [""];
|
const worktreeCopyFileRows = (form.worktreeCopyFiles?.length ?? 0) > 0 ? form.worktreeCopyFiles ?? [] : [""];
|
||||||
return (<>
|
return (<>
|
||||||
<h4 className="settings-section-heading">{t("settings.worktrees.worktrees", "Worktrees")}</h4>
|
<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. */}
|
{/* 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
|
<SettingsNumberRow
|
||||||
descriptor={{
|
descriptor={{
|
||||||
key: "maxWorktrees",
|
key: "maxWorktrees",
|
||||||
label: t("settings.worktrees.maxWorktrees", "Max Worktrees"),
|
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",
|
scope: "project",
|
||||||
min: 1,
|
min: 1,
|
||||||
max: 20,
|
max: 20,
|
||||||
|
disabled: form.worktreeLimitEnabled === false,
|
||||||
}}
|
}}
|
||||||
value={form.maxWorktrees ?? null}
|
value={form.maxWorktrees ?? null}
|
||||||
onChange={(v) => setForm((f) => ({ ...f, maxWorktrees: v ?? undefined } as SettingsFormState))}
|
onChange={(v) => setForm((f) => ({ ...f, maxWorktrees: v ?? undefined } as SettingsFormState))}
|
||||||
|
|||||||
@@ -214,7 +214,6 @@ const SETTING_DESCRIPTION_KEYS: Record<string, string> = {
|
|||||||
globalMaxConcurrent: "scheduling.maximumConcurrentAgentsAcrossAllProjects",
|
globalMaxConcurrent: "scheduling.maximumConcurrentAgentsAcrossAllProjects",
|
||||||
maxConcurrent: "scheduling.maxConcurrentTasksHint",
|
maxConcurrent: "scheduling.maxConcurrentTasksHint",
|
||||||
maxConcurrentVerifications: "scheduling.maxConcurrentVerificationsHint",
|
maxConcurrentVerifications: "scheduling.maxConcurrentVerificationsHint",
|
||||||
maxTriageConcurrent: "scheduling.maximumConcurrentPlanningAgents",
|
|
||||||
pollIntervalMs: "scheduling.pollIntervalMsHint",
|
pollIntervalMs: "scheduling.pollIntervalMsHint",
|
||||||
heartbeatScopeDiscipline: "scheduling.strictDefault",
|
heartbeatScopeDiscipline: "scheduling.strictDefault",
|
||||||
engineerBacklogAutoClaim: "scheduling.backlogNoTaskAutoClaimIsExecutorOnly",
|
engineerBacklogAutoClaim: "scheduling.backlogNoTaskAutoClaimIsExecutorOnly",
|
||||||
@@ -241,6 +240,7 @@ const SETTING_DESCRIPTION_KEYS: Record<string, string> = {
|
|||||||
overlapIgnorePaths: "scheduling.optionalFileOrDirectoryPathsToIgnoreWhen",
|
overlapIgnorePaths: "scheduling.optionalFileOrDirectoryPathsToIgnoreWhen",
|
||||||
// WorktreesSection
|
// WorktreesSection
|
||||||
maxWorktrees: "worktrees.limitsTotalGitWorktreesIncludingInReviewTasks",
|
maxWorktrees: "worktrees.limitsTotalGitWorktreesIncludingInReviewTasks",
|
||||||
|
worktreeLimitEnabled: "worktrees.worktreeLimitEnabledHelp",
|
||||||
worktreeInitCommand: "worktrees.shellCommandToRunInEachNewWorktree",
|
worktreeInitCommand: "worktrees.shellCommandToRunInEachNewWorktree",
|
||||||
recycleWorktrees: "worktrees.offByDefaultOptInWhenEnabledCompleted",
|
recycleWorktrees: "worktrees.offByDefaultOptInWhenEnabledCompleted",
|
||||||
showWorktreeGrouping: "worktrees.showWorktreeGroupingHelp",
|
showWorktreeGrouping: "worktrees.showWorktreeGroupingHelp",
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { registerConfigMcpPiSettingsRoutes } from "../register-config-mcp-pi-set
|
|||||||
import type { ApiRoutesContext } from "../types.js";
|
import type { ApiRoutesContext } from "../types.js";
|
||||||
|
|
||||||
function createApp(
|
function createApp(
|
||||||
settings = { maxConcurrent: 6, maxTriageConcurrent: 3, maxWorktrees: 2 },
|
settings = { maxConcurrent: 6, maxWorktrees: 2 },
|
||||||
pluginServers: Array<{ pluginId: string; server: unknown }> = [],
|
pluginServers: Array<{ pluginId: string; server: unknown }> = [],
|
||||||
) {
|
) {
|
||||||
const app = express();
|
const app = express();
|
||||||
@@ -39,14 +39,14 @@ describe("registerConfigMcpPiSettingsRoutes", () => {
|
|||||||
const response = await request(createApp(), "GET", "/config");
|
const response = await request(createApp(), "GET", "/config");
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
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 () => {
|
it("uses option and fixed defaults for missing scheduler settings", async () => {
|
||||||
const response = await request(createApp({}), "GET", "/config");
|
const response = await request(createApp({}), "GET", "/config");
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
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 () => {
|
it("lists only provider-filtered valid project plugin MCP contributions", async () => {
|
||||||
|
|||||||
@@ -108,13 +108,12 @@ export const registerConfigMcpPiSettingsRoutes: ApiRouteRegistrar = (ctx) => {
|
|||||||
const settings = await scopedStore.getSettingsFast();
|
const settings = await scopedStore.getSettingsFast();
|
||||||
res.json({
|
res.json({
|
||||||
maxConcurrent: settings.maxConcurrent ?? options?.maxConcurrent ?? 2,
|
maxConcurrent: settings.maxConcurrent ?? options?.maxConcurrent ?? 2,
|
||||||
maxTriageConcurrent: settings.maxTriageConcurrent ?? 2,
|
|
||||||
maxWorktrees: settings.maxWorktrees ?? 4,
|
maxWorktrees: settings.maxWorktrees ?? 4,
|
||||||
rootDir: scopedStore.getRootDir(),
|
rootDir: scopedStore.getRootDir(),
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
const { store: scopedStore } = await getProjectContext(req);
|
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() });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -51,7 +51,7 @@ describe("reliability interaction: starved refinement x triage poll", () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const store: any = {
|
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 }))),
|
listTasks: vi.fn().mockImplementation(async () => tasks.map((t) => ({ ...t }))),
|
||||||
updateTask: vi.fn().mockImplementation(async (id: string, patch: Partial<Task>) => {
|
updateTask: vi.fn().mockImplementation(async (id: string, patch: Partial<Task>) => {
|
||||||
const idx = tasks.findIndex((t) => t.id === id);
|
const idx = tasks.findIndex((t) => t.id === id);
|
||||||
|
|||||||
@@ -460,6 +460,74 @@ describe("Scheduler workflow cutover", () => {
|
|||||||
expect(ready.column).toBe("todo");
|
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 () => {
|
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 active = Array.from({ length: 3 }, (_, index) => task({ id: `FN-30${index}`, column: "in-progress" }));
|
||||||
const first = task({ id: "FN-401", status: "queued" });
|
const first = task({ id: "FN-401", status: "queued" });
|
||||||
|
|||||||
@@ -78,7 +78,6 @@ describe("refinement routing from triage", () => {
|
|||||||
const store: any = withStoreEvents({
|
const store: any = withStoreEvents({
|
||||||
getSettings: vi.fn().mockResolvedValue({
|
getSettings: vi.fn().mockResolvedValue({
|
||||||
maxConcurrent: 2,
|
maxConcurrent: 2,
|
||||||
maxTriageConcurrent: 2,
|
|
||||||
pollIntervalMs: 10_000,
|
pollIntervalMs: 10_000,
|
||||||
groupOverlappingFiles: false,
|
groupOverlappingFiles: false,
|
||||||
autoMerge: true,
|
autoMerge: true,
|
||||||
@@ -224,7 +223,6 @@ describe("refinement routing from triage", () => {
|
|||||||
const store: any = withStoreEvents({
|
const store: any = withStoreEvents({
|
||||||
getSettings: vi.fn().mockResolvedValue({
|
getSettings: vi.fn().mockResolvedValue({
|
||||||
maxConcurrent: 2,
|
maxConcurrent: 2,
|
||||||
maxTriageConcurrent: 2,
|
|
||||||
pollIntervalMs: 10_000,
|
pollIntervalMs: 10_000,
|
||||||
groupOverlappingFiles: false,
|
groupOverlappingFiles: false,
|
||||||
autoMerge: true,
|
autoMerge: true,
|
||||||
@@ -275,7 +273,6 @@ describe("refinement routing from triage", () => {
|
|||||||
const store: any = withStoreEvents({
|
const store: any = withStoreEvents({
|
||||||
getSettings: vi.fn().mockResolvedValue({
|
getSettings: vi.fn().mockResolvedValue({
|
||||||
maxConcurrent: 10,
|
maxConcurrent: 10,
|
||||||
maxTriageConcurrent: 10,
|
|
||||||
pollIntervalMs: 10_000,
|
pollIntervalMs: 10_000,
|
||||||
groupOverlappingFiles: false,
|
groupOverlappingFiles: false,
|
||||||
autoMerge: true,
|
autoMerge: true,
|
||||||
|
|||||||
@@ -1751,7 +1751,6 @@ Planner rewrote mission without the raw request.
|
|||||||
listTasks: vi.fn().mockResolvedValue(tasks),
|
listTasks: vi.fn().mockResolvedValue(tasks),
|
||||||
getSettings: vi.fn().mockResolvedValue({
|
getSettings: vi.fn().mockResolvedValue({
|
||||||
maxConcurrent: 10,
|
maxConcurrent: 10,
|
||||||
maxTriageConcurrent: 10,
|
|
||||||
pollIntervalMs: 10_000,
|
pollIntervalMs: 10_000,
|
||||||
groupOverlappingFiles: false,
|
groupOverlappingFiles: false,
|
||||||
autoMerge: true,
|
autoMerge: true,
|
||||||
@@ -1789,7 +1788,6 @@ Planner rewrote mission without the raw request.
|
|||||||
listTasks: vi.fn().mockResolvedValue(tasks),
|
listTasks: vi.fn().mockResolvedValue(tasks),
|
||||||
getSettings: vi.fn().mockResolvedValue({
|
getSettings: vi.fn().mockResolvedValue({
|
||||||
maxConcurrent: 10,
|
maxConcurrent: 10,
|
||||||
maxTriageConcurrent: 10,
|
|
||||||
pollIntervalMs: 10_000,
|
pollIntervalMs: 10_000,
|
||||||
groupOverlappingFiles: false,
|
groupOverlappingFiles: false,
|
||||||
autoMerge: true,
|
autoMerge: true,
|
||||||
@@ -1827,7 +1825,6 @@ Planner rewrote mission without the raw request.
|
|||||||
listTasks: vi.fn().mockResolvedValue(tasks),
|
listTasks: vi.fn().mockResolvedValue(tasks),
|
||||||
getSettings: vi.fn().mockResolvedValue({
|
getSettings: vi.fn().mockResolvedValue({
|
||||||
maxConcurrent: 10,
|
maxConcurrent: 10,
|
||||||
maxTriageConcurrent: 10,
|
|
||||||
pollIntervalMs: 10_000,
|
pollIntervalMs: 10_000,
|
||||||
groupOverlappingFiles: false,
|
groupOverlappingFiles: false,
|
||||||
autoMerge: true,
|
autoMerge: true,
|
||||||
@@ -1847,7 +1844,7 @@ Planner rewrote mission without the raw request.
|
|||||||
|
|
||||||
/*
|
/*
|
||||||
FNXC:GlobalConcurrencyControls 2026-07-14-18:30:
|
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 () => {
|
it("leaves global concurrency room for live in-progress agents when admitting planners", async () => {
|
||||||
const tasks: Task[] = [
|
const tasks: Task[] = [
|
||||||
@@ -1867,7 +1864,6 @@ Planner rewrote mission without the raw request.
|
|||||||
listTasks: vi.fn().mockResolvedValue(tasks),
|
listTasks: vi.fn().mockResolvedValue(tasks),
|
||||||
getSettings: vi.fn().mockResolvedValue({
|
getSettings: vi.fn().mockResolvedValue({
|
||||||
maxConcurrent: 4,
|
maxConcurrent: 4,
|
||||||
maxTriageConcurrent: 4,
|
|
||||||
pollIntervalMs: 10_000,
|
pollIntervalMs: 10_000,
|
||||||
groupOverlappingFiles: false,
|
groupOverlappingFiles: false,
|
||||||
autoMerge: true,
|
autoMerge: true,
|
||||||
@@ -1911,7 +1907,6 @@ Planner rewrote mission without the raw request.
|
|||||||
getTask: vi.fn().mockImplementation(async (id: string) => tasksById.get(id) ?? null),
|
getTask: vi.fn().mockImplementation(async (id: string) => tasksById.get(id) ?? null),
|
||||||
getSettings: vi.fn().mockResolvedValue({
|
getSettings: vi.fn().mockResolvedValue({
|
||||||
maxConcurrent: 10,
|
maxConcurrent: 10,
|
||||||
maxTriageConcurrent: 10,
|
|
||||||
pollIntervalMs: 10_000,
|
pollIntervalMs: 10_000,
|
||||||
groupOverlappingFiles: false,
|
groupOverlappingFiles: false,
|
||||||
autoMerge: true,
|
autoMerge: true,
|
||||||
@@ -1962,7 +1957,6 @@ Planner rewrote mission without the raw request.
|
|||||||
listTasks: vi.fn().mockResolvedValue(tasks),
|
listTasks: vi.fn().mockResolvedValue(tasks),
|
||||||
getSettings: vi.fn().mockResolvedValue({
|
getSettings: vi.fn().mockResolvedValue({
|
||||||
maxConcurrent: 10,
|
maxConcurrent: 10,
|
||||||
maxTriageConcurrent: 10,
|
|
||||||
pollIntervalMs: 10_000,
|
pollIntervalMs: 10_000,
|
||||||
groupOverlappingFiles: false,
|
groupOverlappingFiles: false,
|
||||||
autoMerge: true,
|
autoMerge: true,
|
||||||
@@ -2001,7 +1995,6 @@ Planner rewrote mission without the raw request.
|
|||||||
listTasks: vi.fn().mockResolvedValue([promotedTask]),
|
listTasks: vi.fn().mockResolvedValue([promotedTask]),
|
||||||
getSettings: vi.fn().mockResolvedValue({
|
getSettings: vi.fn().mockResolvedValue({
|
||||||
maxConcurrent: 10,
|
maxConcurrent: 10,
|
||||||
maxTriageConcurrent: 10,
|
|
||||||
pollIntervalMs: 10_000,
|
pollIntervalMs: 10_000,
|
||||||
groupOverlappingFiles: false,
|
groupOverlappingFiles: false,
|
||||||
autoMerge: true,
|
autoMerge: true,
|
||||||
@@ -2044,7 +2037,6 @@ Planner rewrote mission without the raw request.
|
|||||||
listTasks: vi.fn().mockResolvedValue([plannedTask]),
|
listTasks: vi.fn().mockResolvedValue([plannedTask]),
|
||||||
getSettings: vi.fn().mockResolvedValue({
|
getSettings: vi.fn().mockResolvedValue({
|
||||||
maxConcurrent: 10,
|
maxConcurrent: 10,
|
||||||
maxTriageConcurrent: 10,
|
|
||||||
pollIntervalMs: 10_000,
|
pollIntervalMs: 10_000,
|
||||||
groupOverlappingFiles: false,
|
groupOverlappingFiles: false,
|
||||||
autoMerge: true,
|
autoMerge: true,
|
||||||
@@ -2094,7 +2086,6 @@ Planner rewrote mission without the raw request.
|
|||||||
listTasks: vi.fn().mockResolvedValue([replanTask]),
|
listTasks: vi.fn().mockResolvedValue([replanTask]),
|
||||||
getSettings: vi.fn().mockResolvedValue({
|
getSettings: vi.fn().mockResolvedValue({
|
||||||
maxConcurrent: 10,
|
maxConcurrent: 10,
|
||||||
maxTriageConcurrent: 10,
|
|
||||||
pollIntervalMs: 10_000,
|
pollIntervalMs: 10_000,
|
||||||
groupOverlappingFiles: false,
|
groupOverlappingFiles: false,
|
||||||
autoMerge: true,
|
autoMerge: true,
|
||||||
@@ -2144,7 +2135,6 @@ Planner rewrote mission without the raw request.
|
|||||||
listTasks: vi.fn().mockResolvedValue([refineTask]),
|
listTasks: vi.fn().mockResolvedValue([refineTask]),
|
||||||
getSettings: vi.fn().mockResolvedValue({
|
getSettings: vi.fn().mockResolvedValue({
|
||||||
maxConcurrent: 10,
|
maxConcurrent: 10,
|
||||||
maxTriageConcurrent: 10,
|
|
||||||
pollIntervalMs: 10_000,
|
pollIntervalMs: 10_000,
|
||||||
groupOverlappingFiles: false,
|
groupOverlappingFiles: false,
|
||||||
autoMerge: true,
|
autoMerge: true,
|
||||||
|
|||||||
@@ -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.
|
* 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
|
* 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,
|
* 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"
|
* 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
|
* 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
|
* "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
|
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:
|
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
|
`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.
|
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
|
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
|
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) {
|
if (task.firstExecutionAt != null || task.executionStartedAt != null) {
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ import { StaleTaskReporter } from "./stale-task-reporter.js";
|
|||||||
import { BacklogPressureReporter } from "./backlog-pressure-reporter.js";
|
import { BacklogPressureReporter } from "./backlog-pressure-reporter.js";
|
||||||
import { UnlinkedMissionsAdvisoryReporter } from "./unlinked-missions-advisory-reporter.js";
|
import { UnlinkedMissionsAdvisoryReporter } from "./unlinked-missions-advisory-reporter.js";
|
||||||
import { createRunAuditor, generateSyntheticRunId } from "./run-audit.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 type { WorkflowIr, WorkflowIrV2 } from "@fusion/core";
|
||||||
import { runHoldReleaseSweep, isUnplannedForExecution, type SlotReservation } from "./hold-release.js";
|
import { runHoldReleaseSweep, isUnplannedForExecution, type SlotReservation } from "./hold-release.js";
|
||||||
import { moveTaskToReplanColumn } from "./replan-target.js";
|
import { moveTaskToReplanColumn } from "./replan-target.js";
|
||||||
@@ -378,11 +378,24 @@ interface ConcurrencyGateDiagnostic {
|
|||||||
available: number;
|
available: number;
|
||||||
bindingGates: ConcurrencyGateName[];
|
bindingGates: ConcurrencyGateName[];
|
||||||
maxConcurrentGate: ConcurrencyGateSnapshot;
|
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;
|
semaphoreGate?: ConcurrencyGateSnapshot;
|
||||||
holders: {
|
holders: {
|
||||||
maxConcurrent: string[];
|
maxConcurrent: string[];
|
||||||
maxWorktrees: string[];
|
maxWorktrees?: string[];
|
||||||
semaphore?: string[];
|
semaphore?: string[];
|
||||||
};
|
};
|
||||||
/** U6: additive per-column capacity gates (flag-ON only; omitted otherwise so
|
/** U6: additive per-column capacity gates (flag-ON only; omitted otherwise so
|
||||||
@@ -416,7 +429,15 @@ function computeConcurrencyGateDiagnostic(params: {
|
|||||||
agentSlots: number;
|
agentSlots: number;
|
||||||
maxConcurrent: number;
|
maxConcurrent: number;
|
||||||
activeWorktrees: 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;
|
semaphore?: AgentSemaphore;
|
||||||
inProgressTaskIds: string[];
|
inProgressTaskIds: string[];
|
||||||
startedThisTick?: number;
|
startedThisTick?: number;
|
||||||
@@ -438,11 +459,16 @@ function computeConcurrencyGateDiagnostic(params: {
|
|||||||
limit: params.maxConcurrent,
|
limit: params.maxConcurrent,
|
||||||
slack: params.maxConcurrent - maxConcurrentUsed,
|
slack: params.maxConcurrent - maxConcurrentUsed,
|
||||||
};
|
};
|
||||||
const maxWorktreesGate: ConcurrencyGateSnapshot = {
|
// Worktrees off → no gate object at all. See the FNXC on `maxWorktreesGate`.
|
||||||
used: maxWorktreesUsed,
|
const maxWorktreesLimit = params.maxWorktrees;
|
||||||
limit: params.maxWorktrees,
|
const maxWorktreesGate: ConcurrencyGateSnapshot | undefined =
|
||||||
slack: params.maxWorktrees - maxWorktreesUsed,
|
maxWorktreesLimit === null
|
||||||
};
|
? undefined
|
||||||
|
: {
|
||||||
|
used: maxWorktreesUsed,
|
||||||
|
limit: maxWorktreesLimit,
|
||||||
|
slack: maxWorktreesLimit - maxWorktreesUsed,
|
||||||
|
};
|
||||||
const semaphoreGate = params.semaphore
|
const semaphoreGate = params.semaphore
|
||||||
? (() => {
|
? (() => {
|
||||||
/*
|
/*
|
||||||
@@ -461,13 +487,13 @@ function computeConcurrencyGateDiagnostic(params: {
|
|||||||
: undefined;
|
: undefined;
|
||||||
const available = Math.min(
|
const available = Math.min(
|
||||||
maxConcurrentGate.slack,
|
maxConcurrentGate.slack,
|
||||||
maxWorktreesGate.slack,
|
maxWorktreesGate?.slack ?? Infinity,
|
||||||
semaphoreGate?.slack ?? Infinity,
|
semaphoreGate?.slack ?? Infinity,
|
||||||
);
|
);
|
||||||
|
|
||||||
const bindingGates: ConcurrencyGateName[] = [];
|
const bindingGates: ConcurrencyGateName[] = [];
|
||||||
if (maxConcurrentGate.used >= maxConcurrentGate.limit) bindingGates.push("maxConcurrent");
|
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");
|
if (semaphoreGate && semaphoreGate.used >= semaphoreGate.limit) bindingGates.push("semaphore");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -478,7 +504,7 @@ function computeConcurrencyGateDiagnostic(params: {
|
|||||||
semaphoreGate,
|
semaphoreGate,
|
||||||
holders: {
|
holders: {
|
||||||
maxConcurrent: [...params.inProgressTaskIds],
|
maxConcurrent: [...params.inProgressTaskIds],
|
||||||
maxWorktrees: [...params.inProgressTaskIds],
|
maxWorktrees: maxWorktreesGate ? [...params.inProgressTaskIds] : undefined,
|
||||||
semaphore: semaphoreGate ? [...params.inProgressTaskIds] : undefined,
|
semaphore: semaphoreGate ? [...params.inProgressTaskIds] : undefined,
|
||||||
},
|
},
|
||||||
// U6: additive only — present when flag-ON, omitted otherwise.
|
// U6: additive only — present when flag-ON, omitted otherwise.
|
||||||
@@ -494,8 +520,20 @@ function formatConcurrencyLimitReason(diagnostic: ConcurrencyGateDiagnostic): st
|
|||||||
const gateLabel = diagnostic.bindingGates.join(", ");
|
const gateLabel = diagnostic.bindingGates.join(", ");
|
||||||
const details = [
|
const details = [
|
||||||
`maxConcurrent used=${diagnostic.maxConcurrentGate.used}/${diagnostic.maxConcurrentGate.limit} (holders: ${holdersText("maxConcurrent")})`,
|
`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) {
|
if (diagnostic.semaphoreGate) {
|
||||||
const semaphoreUsed = Math.max(0, diagnostic.semaphoreGate.used);
|
const semaphoreUsed = Math.max(0, diagnostic.semaphoreGate.used);
|
||||||
details.push(
|
details.push(
|
||||||
@@ -1556,7 +1594,12 @@ export class Scheduler {
|
|||||||
|
|
||||||
private async runHoldReleaseSweepPass(tasks: Task[], settings: Settings): Promise<void> {
|
private async runHoldReleaseSweepPass(tasks: Task[], settings: Settings): Promise<void> {
|
||||||
try {
|
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;
|
const maxConcurrent = settings.maxConcurrent ?? this.options.maxConcurrent ?? 2;
|
||||||
/*
|
/*
|
||||||
FNXC:WorkflowScheduling 2026-07-19-02:35 (U4/KTD-9):
|
FNXC:WorkflowScheduling 2026-07-19-02:35 (U4/KTD-9):
|
||||||
|
|||||||
@@ -762,7 +762,7 @@ export class TriageProcessor {
|
|||||||
private async clearStaleSpecifyingStatuses(): Promise<void> {
|
private async clearStaleSpecifyingStatuses(): Promise<void> {
|
||||||
/*
|
/*
|
||||||
FNXC:CodingIdeasWorkflow 2026-07-04-12:00:
|
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 triageTasks = await this.store.listTasks({ column: "triage", slim: true });
|
||||||
const todoTasks = await this.store.listTasks({ column: "todo", 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:
|
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
|
same maxConcurrent live-agent claim as execute/review so a project cannot
|
||||||
exceed its operator-facing top-level capacity in a different lane.
|
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:
|
FNXC:Triage 2026-07-16-05:35:
|
||||||
A skip on this PRIMARY claim path is an anomaly, not a benign scheduler race: poll()
|
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
|
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
|
against healthy cards. Recovery-write skips stay silent by design (see
|
||||||
updatePlanningStateIfStillCurrent); this one must be visible — the FN-7977 steps>0
|
updatePlanningStateIfStillCurrent); this one must be visible — the FN-7977 steps>0
|
||||||
wedge stalled the whole planner for hours precisely because it logged nothing.
|
wedge stalled the whole planner for hours precisely because it logged nothing.
|
||||||
|
|||||||
@@ -1571,7 +1571,6 @@
|
|||||||
"description": "Tune live scheduler capacity.",
|
"description": "Tune live scheduler capacity.",
|
||||||
"error": "Unable to load concurrency settings",
|
"error": "Unable to load concurrency settings",
|
||||||
"maxConcurrent": "Max concurrent tasks",
|
"maxConcurrent": "Max concurrent tasks",
|
||||||
"maxTriageConcurrent": "Max triage concurrent",
|
|
||||||
"maxWorktrees": "Max worktrees",
|
"maxWorktrees": "Max worktrees",
|
||||||
"title": "Concurrency",
|
"title": "Concurrency",
|
||||||
"runningGlobal": "{{count}} running (all projects)",
|
"runningGlobal": "{{count}} running (all projects)",
|
||||||
@@ -6769,7 +6768,6 @@
|
|||||||
"maximumConcurrentPlanningAgents": "Maximum concurrent planning agents. Default: 2.",
|
"maximumConcurrentPlanningAgents": "Maximum concurrent planning agents. Default: 2.",
|
||||||
"maximumStuckDetectorRetriesBeforeATaskIs": "Maximum stuck-detector retries before a task is marked failed. Default: 6.",
|
"maximumStuckDetectorRetriesBeforeATaskIs": "Maximum stuck-detector retries before a task is marked failed. Default: 6.",
|
||||||
"maxStuckRetries": "Max Stuck Retries",
|
"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).",
|
"numberOfDaysATaskCanStayIn": "Number of days a task can stay in Done before it is archived. Default: 2 days (48 hours).",
|
||||||
"off": "Off",
|
"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 ",
|
"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. ",
|
"keepsProgressMovingBySwitchingToFusionApos": " keeps progress moving by switching to Fusion's built-in worktree backend. ",
|
||||||
"limitsTotalGitWorktreesIncludingInReviewTasks": "Limits total git worktrees including in-review tasks. Default: 4.",
|
"limitsTotalGitWorktreesIncludingInReviewTasks": "Limits total git worktrees including in-review tasks. Default: 4.",
|
||||||
"maxWorktrees": "Max Worktrees",
|
"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.",
|
"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.",
|
"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",
|
"openApprovals": "Open Approvals",
|
||||||
|
|||||||
@@ -1561,7 +1561,6 @@
|
|||||||
"description": "",
|
"description": "",
|
||||||
"error": "",
|
"error": "",
|
||||||
"maxConcurrent": "",
|
"maxConcurrent": "",
|
||||||
"maxTriageConcurrent": "",
|
|
||||||
"maxWorktrees": "",
|
"maxWorktrees": "",
|
||||||
"title": "",
|
"title": "",
|
||||||
"runningGlobal": "",
|
"runningGlobal": "",
|
||||||
@@ -6731,7 +6730,6 @@
|
|||||||
"maximumConcurrentPlanningAgents": "",
|
"maximumConcurrentPlanningAgents": "",
|
||||||
"maximumStuckDetectorRetriesBeforeATaskIs": "",
|
"maximumStuckDetectorRetriesBeforeATaskIs": "",
|
||||||
"maxStuckRetries": "",
|
"maxStuckRetries": "",
|
||||||
"maxTriageConcurrent": "",
|
|
||||||
"numberOfDaysATaskCanStayIn": "",
|
"numberOfDaysATaskCanStayIn": "",
|
||||||
"off": "",
|
"off": "",
|
||||||
"optionalFileOrDirectoryPathsToIgnoreWhen": "",
|
"optionalFileOrDirectoryPathsToIgnoreWhen": "",
|
||||||
@@ -6809,6 +6807,8 @@
|
|||||||
"keepsProgressMovingBySwitchingToFusionApos": "",
|
"keepsProgressMovingBySwitchingToFusionApos": "",
|
||||||
"limitsTotalGitWorktreesIncludingInReviewTasks": "",
|
"limitsTotalGitWorktreesIncludingInReviewTasks": "",
|
||||||
"maxWorktrees": "",
|
"maxWorktrees": "",
|
||||||
|
"worktreeLimitEnabled": "",
|
||||||
|
"worktreeLimitEnabledHelp": "",
|
||||||
"offByDefaultOptInWhenEnabledCompleted": "",
|
"offByDefaultOptInWhenEnabledCompleted": "",
|
||||||
"openApprovals": "Abrir aprobaciones",
|
"openApprovals": "Abrir aprobaciones",
|
||||||
"optionalLeaveBlankToAutoResolveFusionWill": "",
|
"optionalLeaveBlankToAutoResolveFusionWill": "",
|
||||||
|
|||||||
@@ -1561,7 +1561,6 @@
|
|||||||
"description": "",
|
"description": "",
|
||||||
"error": "",
|
"error": "",
|
||||||
"maxConcurrent": "",
|
"maxConcurrent": "",
|
||||||
"maxTriageConcurrent": "",
|
|
||||||
"maxWorktrees": "",
|
"maxWorktrees": "",
|
||||||
"title": "",
|
"title": "",
|
||||||
"runningGlobal": "",
|
"runningGlobal": "",
|
||||||
@@ -6731,7 +6730,6 @@
|
|||||||
"maximumConcurrentPlanningAgents": "",
|
"maximumConcurrentPlanningAgents": "",
|
||||||
"maximumStuckDetectorRetriesBeforeATaskIs": "",
|
"maximumStuckDetectorRetriesBeforeATaskIs": "",
|
||||||
"maxStuckRetries": "",
|
"maxStuckRetries": "",
|
||||||
"maxTriageConcurrent": "",
|
|
||||||
"numberOfDaysATaskCanStayIn": "",
|
"numberOfDaysATaskCanStayIn": "",
|
||||||
"off": "",
|
"off": "",
|
||||||
"optionalFileOrDirectoryPathsToIgnoreWhen": "",
|
"optionalFileOrDirectoryPathsToIgnoreWhen": "",
|
||||||
@@ -6809,6 +6807,8 @@
|
|||||||
"keepsProgressMovingBySwitchingToFusionApos": "",
|
"keepsProgressMovingBySwitchingToFusionApos": "",
|
||||||
"limitsTotalGitWorktreesIncludingInReviewTasks": "",
|
"limitsTotalGitWorktreesIncludingInReviewTasks": "",
|
||||||
"maxWorktrees": "",
|
"maxWorktrees": "",
|
||||||
|
"worktreeLimitEnabled": "",
|
||||||
|
"worktreeLimitEnabledHelp": "",
|
||||||
"offByDefaultOptInWhenEnabledCompleted": "",
|
"offByDefaultOptInWhenEnabledCompleted": "",
|
||||||
"openApprovals": "Ouvrir les approbations",
|
"openApprovals": "Ouvrir les approbations",
|
||||||
"optionalLeaveBlankToAutoResolveFusionWill": "",
|
"optionalLeaveBlankToAutoResolveFusionWill": "",
|
||||||
|
|||||||
@@ -1561,7 +1561,6 @@
|
|||||||
"description": "",
|
"description": "",
|
||||||
"error": "",
|
"error": "",
|
||||||
"maxConcurrent": "",
|
"maxConcurrent": "",
|
||||||
"maxTriageConcurrent": "",
|
|
||||||
"maxWorktrees": "",
|
"maxWorktrees": "",
|
||||||
"title": "",
|
"title": "",
|
||||||
"runningGlobal": "",
|
"runningGlobal": "",
|
||||||
@@ -6731,7 +6730,6 @@
|
|||||||
"maximumConcurrentPlanningAgents": "",
|
"maximumConcurrentPlanningAgents": "",
|
||||||
"maximumStuckDetectorRetriesBeforeATaskIs": "",
|
"maximumStuckDetectorRetriesBeforeATaskIs": "",
|
||||||
"maxStuckRetries": "",
|
"maxStuckRetries": "",
|
||||||
"maxTriageConcurrent": "",
|
|
||||||
"numberOfDaysATaskCanStayIn": "",
|
"numberOfDaysATaskCanStayIn": "",
|
||||||
"off": "",
|
"off": "",
|
||||||
"optionalFileOrDirectoryPathsToIgnoreWhen": "",
|
"optionalFileOrDirectoryPathsToIgnoreWhen": "",
|
||||||
@@ -6809,6 +6807,8 @@
|
|||||||
"keepsProgressMovingBySwitchingToFusionApos": "",
|
"keepsProgressMovingBySwitchingToFusionApos": "",
|
||||||
"limitsTotalGitWorktreesIncludingInReviewTasks": "",
|
"limitsTotalGitWorktreesIncludingInReviewTasks": "",
|
||||||
"maxWorktrees": "",
|
"maxWorktrees": "",
|
||||||
|
"worktreeLimitEnabled": "",
|
||||||
|
"worktreeLimitEnabledHelp": "",
|
||||||
"offByDefaultOptInWhenEnabledCompleted": "",
|
"offByDefaultOptInWhenEnabledCompleted": "",
|
||||||
"openApprovals": "승인 열기",
|
"openApprovals": "승인 열기",
|
||||||
"optionalLeaveBlankToAutoResolveFusionWill": "",
|
"optionalLeaveBlankToAutoResolveFusionWill": "",
|
||||||
|
|||||||
@@ -1561,7 +1561,6 @@
|
|||||||
"description": "",
|
"description": "",
|
||||||
"error": "",
|
"error": "",
|
||||||
"maxConcurrent": "",
|
"maxConcurrent": "",
|
||||||
"maxTriageConcurrent": "",
|
|
||||||
"maxWorktrees": "",
|
"maxWorktrees": "",
|
||||||
"title": "",
|
"title": "",
|
||||||
"runningGlobal": "",
|
"runningGlobal": "",
|
||||||
@@ -6731,7 +6730,6 @@
|
|||||||
"maximumConcurrentPlanningAgents": "",
|
"maximumConcurrentPlanningAgents": "",
|
||||||
"maximumStuckDetectorRetriesBeforeATaskIs": "",
|
"maximumStuckDetectorRetriesBeforeATaskIs": "",
|
||||||
"maxStuckRetries": "",
|
"maxStuckRetries": "",
|
||||||
"maxTriageConcurrent": "",
|
|
||||||
"numberOfDaysATaskCanStayIn": "",
|
"numberOfDaysATaskCanStayIn": "",
|
||||||
"off": "",
|
"off": "",
|
||||||
"optionalFileOrDirectoryPathsToIgnoreWhen": "",
|
"optionalFileOrDirectoryPathsToIgnoreWhen": "",
|
||||||
@@ -6809,6 +6807,8 @@
|
|||||||
"keepsProgressMovingBySwitchingToFusionApos": "",
|
"keepsProgressMovingBySwitchingToFusionApos": "",
|
||||||
"limitsTotalGitWorktreesIncludingInReviewTasks": "",
|
"limitsTotalGitWorktreesIncludingInReviewTasks": "",
|
||||||
"maxWorktrees": "",
|
"maxWorktrees": "",
|
||||||
|
"worktreeLimitEnabled": "",
|
||||||
|
"worktreeLimitEnabledHelp": "",
|
||||||
"offByDefaultOptInWhenEnabledCompleted": "",
|
"offByDefaultOptInWhenEnabledCompleted": "",
|
||||||
"openApprovals": "打开审批",
|
"openApprovals": "打开审批",
|
||||||
"optionalLeaveBlankToAutoResolveFusionWill": "",
|
"optionalLeaveBlankToAutoResolveFusionWill": "",
|
||||||
|
|||||||
@@ -1561,7 +1561,6 @@
|
|||||||
"description": "",
|
"description": "",
|
||||||
"error": "",
|
"error": "",
|
||||||
"maxConcurrent": "",
|
"maxConcurrent": "",
|
||||||
"maxTriageConcurrent": "",
|
|
||||||
"maxWorktrees": "",
|
"maxWorktrees": "",
|
||||||
"title": "",
|
"title": "",
|
||||||
"runningGlobal": "",
|
"runningGlobal": "",
|
||||||
@@ -6731,7 +6730,6 @@
|
|||||||
"maximumConcurrentPlanningAgents": "",
|
"maximumConcurrentPlanningAgents": "",
|
||||||
"maximumStuckDetectorRetriesBeforeATaskIs": "",
|
"maximumStuckDetectorRetriesBeforeATaskIs": "",
|
||||||
"maxStuckRetries": "",
|
"maxStuckRetries": "",
|
||||||
"maxTriageConcurrent": "",
|
|
||||||
"numberOfDaysATaskCanStayIn": "",
|
"numberOfDaysATaskCanStayIn": "",
|
||||||
"off": "",
|
"off": "",
|
||||||
"optionalFileOrDirectoryPathsToIgnoreWhen": "",
|
"optionalFileOrDirectoryPathsToIgnoreWhen": "",
|
||||||
@@ -6809,6 +6807,8 @@
|
|||||||
"keepsProgressMovingBySwitchingToFusionApos": "",
|
"keepsProgressMovingBySwitchingToFusionApos": "",
|
||||||
"limitsTotalGitWorktreesIncludingInReviewTasks": "",
|
"limitsTotalGitWorktreesIncludingInReviewTasks": "",
|
||||||
"maxWorktrees": "",
|
"maxWorktrees": "",
|
||||||
|
"worktreeLimitEnabled": "",
|
||||||
|
"worktreeLimitEnabledHelp": "",
|
||||||
"offByDefaultOptInWhenEnabledCompleted": "",
|
"offByDefaultOptInWhenEnabledCompleted": "",
|
||||||
"openApprovals": "開啟審批",
|
"openApprovals": "開啟審批",
|
||||||
"optionalLeaveBlankToAutoResolveFusionWill": "",
|
"optionalLeaveBlankToAutoResolveFusionWill": "",
|
||||||
|
|||||||
4
packages/i18n/src/resources.d.ts
vendored
4
packages/i18n/src/resources.d.ts
vendored
@@ -1558,7 +1558,6 @@ export default interface Resources {
|
|||||||
"description": "Tune live scheduler capacity.",
|
"description": "Tune live scheduler capacity.",
|
||||||
"error": "Unable to load concurrency settings",
|
"error": "Unable to load concurrency settings",
|
||||||
"maxConcurrent": "Max concurrent tasks",
|
"maxConcurrent": "Max concurrent tasks",
|
||||||
"maxTriageConcurrent": "Max triage concurrent",
|
|
||||||
"maxWorktrees": "Max worktrees",
|
"maxWorktrees": "Max worktrees",
|
||||||
"runningGlobal": "{{count}} running (all projects)",
|
"runningGlobal": "{{count}} running (all projects)",
|
||||||
"runningProject": "{{count}} running (this project)",
|
"runningProject": "{{count}} running (this project)",
|
||||||
@@ -6800,7 +6799,6 @@ export default interface Resources {
|
|||||||
"maxConcurrentVerifications": "Max Concurrent Verifications",
|
"maxConcurrentVerifications": "Max Concurrent Verifications",
|
||||||
"maxConcurrentVerificationsHint": "Caps stacked typecheck/build verification across tasks. Default: 1. Range: 1–8.",
|
"maxConcurrentVerificationsHint": "Caps stacked typecheck/build verification across tasks. Default: 1. Range: 1–8.",
|
||||||
"maxStuckRetries": "Max Stuck Retries",
|
"maxStuckRetries": "Max Stuck Retries",
|
||||||
"maxTriageConcurrent": "Max Triage Concurrent",
|
|
||||||
"maximumAgeInHoursBeforeAPlanIs": "Maximum age in hours before a plan is considered stale. Default: 6 hours.",
|
"maximumAgeInHoursBeforeAPlanIs": "Maximum age in hours before a plan is considered stale. Default: 6 hours.",
|
||||||
"maximumConcurrentAgentsAcrossAllProjects": "Maximum concurrent agents across all projects. Default: 4.",
|
"maximumConcurrentAgentsAcrossAllProjects": "Maximum concurrent agents across all projects. Default: 4.",
|
||||||
"maximumConcurrentPlanningAgents": "Maximum concurrent planning agents. Default: 2.",
|
"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. ",
|
"keepsProgressMovingBySwitchingToFusionApos": " keeps progress moving by switching to Fusion's built-in worktree backend. ",
|
||||||
"limitsTotalGitWorktreesIncludingInReviewTasks": "Limits total git worktrees including in-review tasks. Default: 4.",
|
"limitsTotalGitWorktreesIncludingInReviewTasks": "Limits total git worktrees including in-review tasks. Default: 4.",
|
||||||
"maxWorktrees": "Max Worktrees",
|
"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.",
|
"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.",
|
"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",
|
"openApprovals": "Open Approvals",
|
||||||
|
|||||||
Reference in New Issue
Block a user