feat(FN-1808): merge fusion/fn-1808
This commit is contained in:
@@ -110,7 +110,8 @@ Triage → Todo → In Progress → In Review → Done → Archived
|
|||||||
|
|
||||||
| Setting | Default | Description |
|
| Setting | Default | Description |
|
||||||
|---------|---------|-------------|
|
|---------|---------|-------------|
|
||||||
| `maxConcurrent` | 2 | Concurrent task execution lanes (triage, executor, merge). Utility AI workflows such as planning, subtask breakdown, interviews, and title summarization bypass this limit. |
|
| `maxConcurrent` | 2 | Concurrent task execution lanes (executor + merge). Triage/specification is controlled by `maxTriageConcurrent`. |
|
||||||
|
| `maxTriageConcurrent` | 2 | Concurrent triage/specification agents. Falls back to `maxConcurrent` when undefined. |
|
||||||
| `autoMerge` | true | Auto-merge completed tasks |
|
| `autoMerge` | true | Auto-merge completed tasks |
|
||||||
| `requirePlanApproval` | false | Manual approval for specs |
|
| `requirePlanApproval` | false | Manual approval for specs |
|
||||||
| `prCompletionMode` | direct | Completion: direct/pr-first |
|
| `prCompletionMode` | direct | Completion: direct/pr-first |
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export const DEFAULT_PROJECT_SETTINGS = {
|
|||||||
globalPause: false,
|
globalPause: false,
|
||||||
enginePaused: false,
|
enginePaused: false,
|
||||||
maxConcurrent: 2,
|
maxConcurrent: 2,
|
||||||
|
maxTriageConcurrent: 2,
|
||||||
globalMaxConcurrent: 4,
|
globalMaxConcurrent: 4,
|
||||||
maxWorktrees: 4,
|
maxWorktrees: 4,
|
||||||
pollIntervalMs: 15000,
|
pollIntervalMs: 15000,
|
||||||
|
|||||||
@@ -234,4 +234,94 @@ describe("getTaskCompletionBlocker", () => {
|
|||||||
}, { resolveTask }))
|
}, { resolveTask }))
|
||||||
.resolves.toBeUndefined();
|
.resolves.toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── in-review as resolved dependency ───────────────────────────────────
|
||||||
|
|
||||||
|
it("returns undefined when a dependency is in-review", async () => {
|
||||||
|
const resolveTask = async (taskId: string) => {
|
||||||
|
if (taskId === "FN-001") {
|
||||||
|
return { id: "FN-001", column: "in-review" as const };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(getTaskCompletionBlocker({
|
||||||
|
...baseCompletionTask,
|
||||||
|
dependencies: ["FN-001"],
|
||||||
|
}, { resolveTask }))
|
||||||
|
.resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined when dependencies are a mix of done and in-review", async () => {
|
||||||
|
const resolveTask = async (taskId: string) => {
|
||||||
|
if (taskId === "FN-001") {
|
||||||
|
return { id: "FN-001", column: "done" as const };
|
||||||
|
}
|
||||||
|
if (taskId === "FN-002") {
|
||||||
|
return { id: "FN-002", column: "in-review" as const };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(getTaskCompletionBlocker({
|
||||||
|
...baseCompletionTask,
|
||||||
|
dependencies: ["FN-001", "FN-002"],
|
||||||
|
}, { resolveTask }))
|
||||||
|
.resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a reason when a dependency is in-progress", async () => {
|
||||||
|
const resolveTask = async (taskId: string) => {
|
||||||
|
if (taskId === "FN-001") {
|
||||||
|
return { id: "FN-001", column: "in-progress" as const };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(getTaskCompletionBlocker({
|
||||||
|
...baseCompletionTask,
|
||||||
|
dependencies: ["FN-001"],
|
||||||
|
}, { resolveTask }))
|
||||||
|
.resolves.toBe("task has unresolved dependencies: FN-001");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a reason when a dependency is in triage", async () => {
|
||||||
|
const resolveTask = async (taskId: string) => {
|
||||||
|
if (taskId === "FN-001") {
|
||||||
|
return { id: "FN-001", column: "triage" as const };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(getTaskCompletionBlocker({
|
||||||
|
...baseCompletionTask,
|
||||||
|
dependencies: ["FN-001"],
|
||||||
|
}, { resolveTask }))
|
||||||
|
.resolves.toBe("task has unresolved dependencies: FN-001");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a reason when a dependency is in todo", async () => {
|
||||||
|
const resolveTask = async (taskId: string) => {
|
||||||
|
if (taskId === "FN-001") {
|
||||||
|
return { id: "FN-001", column: "todo" as const };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(getTaskCompletionBlocker({
|
||||||
|
...baseCompletionTask,
|
||||||
|
dependencies: ["FN-001"],
|
||||||
|
}, { resolveTask }))
|
||||||
|
.resolves.toBe("task has unresolved dependencies: FN-001");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a reason when a dependency task does not exist", async () => {
|
||||||
|
const resolveTask = async (_taskId: string) => null;
|
||||||
|
|
||||||
|
await expect(getTaskCompletionBlocker({
|
||||||
|
...baseCompletionTask,
|
||||||
|
dependencies: ["FN-999"],
|
||||||
|
}, { resolveTask }))
|
||||||
|
.resolves.toBe("task has unresolved dependencies: FN-999");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ export async function getTaskCompletionBlocker(
|
|||||||
|
|
||||||
for (const dependencyId of dependencies) {
|
for (const dependencyId of dependencies) {
|
||||||
const dependency = await options.resolveTask(dependencyId);
|
const dependency = await options.resolveTask(dependencyId);
|
||||||
if (!dependency || (dependency.column !== "done" && dependency.column !== "archived")) {
|
if (!dependency || (dependency.column !== "done" && dependency.column !== "in-review" && dependency.column !== "archived")) {
|
||||||
unresolvedDependencies.push(dependencyId);
|
unresolvedDependencies.push(dependencyId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -982,6 +982,9 @@ export interface ProjectSettings {
|
|||||||
/** Maximum number of concurrent AI agents across all activity types
|
/** Maximum number of concurrent AI agents across all activity types
|
||||||
* (triage specification, task execution, and merge operations). */
|
* (triage specification, task execution, and merge operations). */
|
||||||
maxConcurrent: number;
|
maxConcurrent: 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.
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ export function SettingsModal({
|
|||||||
onColorThemeChange,
|
onColorThemeChange,
|
||||||
onReopenOnboarding,
|
onReopenOnboarding,
|
||||||
}: SettingsModalProps) {
|
}: SettingsModalProps) {
|
||||||
const [form, setForm] = useState<Settings & { worktreeInitCommand?: string }>({ maxConcurrent: 2, maxWorktrees: 4, pollIntervalMs: 15000, groupOverlappingFiles: true, autoMerge: true, mergeStrategy: "direct", recycleWorktrees: false, worktreeNaming: "random", includeTaskIdInCommit: true, worktreeInitCommand: "", ntfyEnabled: false, ntfyTopic: undefined });
|
const [form, setForm] = useState<Settings & { worktreeInitCommand?: string }>({ maxConcurrent: 2, maxTriageConcurrent: 2, maxWorktrees: 4, pollIntervalMs: 15000, groupOverlappingFiles: true, autoMerge: true, mergeStrategy: "direct", recycleWorktrees: false, worktreeNaming: "random", includeTaskIdInCommit: true, worktreeInitCommand: "", ntfyEnabled: false, ntfyTopic: undefined });
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
// Track initial values to detect explicit clears for null-as-delete semantics
|
// Track initial values to detect explicit clears for null-as-delete semantics
|
||||||
const [initialValues, setInitialValues] = useState<Settings | null>(null);
|
const [initialValues, setInitialValues] = useState<Settings | null>(null);
|
||||||
@@ -1568,6 +1568,21 @@ export function SettingsModal({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="maxTriageConcurrent">Max Triage Concurrent</label>
|
||||||
|
<input
|
||||||
|
id="maxTriageConcurrent"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={10}
|
||||||
|
value={form.maxTriageConcurrent ?? ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = e.target.value;
|
||||||
|
setForm((f) => ({ ...f, maxTriageConcurrent: val === "" ? undefined : Number(val) } as any));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<small>Maximum concurrent triage/specification agents</small>
|
||||||
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="pollIntervalMs">Poll Interval (ms)</label>
|
<label htmlFor="pollIntervalMs">Poll Interval (ms)</label>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type { Settings, ThemeMode, ColorTheme } from "@fusion/core";
|
|||||||
|
|
||||||
const defaultSettings: Settings = {
|
const defaultSettings: Settings = {
|
||||||
maxConcurrent: 2,
|
maxConcurrent: 2,
|
||||||
|
maxTriageConcurrent: 2,
|
||||||
maxWorktrees: 4,
|
maxWorktrees: 4,
|
||||||
pollIntervalMs: 15000,
|
pollIntervalMs: 15000,
|
||||||
groupOverlappingFiles: false,
|
groupOverlappingFiles: false,
|
||||||
|
|||||||
@@ -2208,19 +2208,20 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Scheduler config (includes persisted settings — only needs maxConcurrent/maxWorktrees)
|
// Scheduler config (includes persisted settings — only needs maxConcurrent/maxTriageConcurrent/maxWorktrees)
|
||||||
router.get("/config", async (req, res) => {
|
router.get("/config", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { store: scopedStore } = await getProjectContext(req);
|
const { store: scopedStore } = await getProjectContext(req);
|
||||||
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 ?? settings.maxConcurrent ?? 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, maxWorktrees: 4, rootDir: scopedStore.getRootDir() });
|
res.json({ maxConcurrent: options?.maxConcurrent ?? 2, maxTriageConcurrent: options?.maxConcurrent ?? 2, maxWorktrees: 4, rootDir: scopedStore.getRootDir() });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -503,27 +503,13 @@ export class Scheduler {
|
|||||||
|
|
||||||
const inProgress = tasks.filter((t) => t.column === "in-progress");
|
const inProgress = tasks.filter((t) => t.column === "in-progress");
|
||||||
|
|
||||||
// Specifying tasks (triage column, status "specifying") run full PI
|
// Execution tasks occupy concurrency slots governed by maxConcurrent.
|
||||||
// agent sessions that consume the same resources as execution agents,
|
// Triage/specification tasks have their own limit (maxTriageConcurrent)
|
||||||
// so they must occupy concurrency slots alongside in-progress tasks.
|
// and do not count against this slot.
|
||||||
// Paused specifying tasks don't count toward slots.
|
const agentSlots = inProgress.length;
|
||||||
const specifying = tasks.filter(
|
|
||||||
(t) => t.column === "triage" && t.status === "specifying" && !t.paused,
|
|
||||||
);
|
|
||||||
|
|
||||||
// When a semaphore is provided, it is the single source of truth for
|
|
||||||
// global concurrency — its availableCount already accounts for ALL
|
|
||||||
// slot holders (executors, specifiers, mergers). Counting specifying
|
|
||||||
// tasks in agentSlots as well would double-count them. Without a
|
|
||||||
// semaphore (fallback mode), count specifying tasks directly.
|
|
||||||
const agentSlots = this.options.semaphore
|
|
||||||
? inProgress.length
|
|
||||||
: inProgress.length + specifying.length;
|
|
||||||
|
|
||||||
// When a semaphore is provided, factor in its available slots so we
|
// When a semaphore is provided, factor in its available slots so we
|
||||||
// don't schedule more tasks than the global limit allows. Triage and
|
// don't schedule more tasks than the global limit allows.
|
||||||
// merge agents also hold semaphore slots, so availableCount may be
|
|
||||||
// lower than what maxConcurrent - inProgress.length would suggest.
|
|
||||||
const semaphoreAvailable = this.options.semaphore
|
const semaphoreAvailable = this.options.semaphore
|
||||||
? this.options.semaphore.availableCount
|
? this.options.semaphore.availableCount
|
||||||
: Infinity;
|
: Infinity;
|
||||||
|
|||||||
@@ -545,16 +545,15 @@ export class TriageProcessor {
|
|||||||
&& !(t.nextRecoveryAt && new Date(t.nextRecoveryAt).getTime() > now),
|
&& !(t.nextRecoveryAt && new Date(t.nextRecoveryAt).getTime() > now),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Respect both per-project maxConcurrent and the global semaphore.
|
// Respect both per-project maxTriageConcurrent and the global semaphore.
|
||||||
// Count all active agent slots: in-progress tasks + already-specifying tasks.
|
// Only specifying tasks count against the triage limit; execution is governed by maxConcurrent.
|
||||||
const maxConcurrent = settings.maxConcurrent ?? 2;
|
const maxTriageConcurrent = settings.maxTriageConcurrent ?? settings.maxConcurrent ?? 2;
|
||||||
const inProgress = allTasks.filter((t) => t.column === "in-progress").length;
|
|
||||||
const specifying = allTasks.filter(
|
const specifying = allTasks.filter(
|
||||||
(t) => t.column === "triage" && t.status === "specifying" && !t.paused,
|
(t) => t.column === "triage" && t.status === "specifying" && !t.paused,
|
||||||
).length;
|
).length;
|
||||||
const activeAgents = inProgress + specifying;
|
const activeAgents = specifying;
|
||||||
|
|
||||||
const perProjectAvailable = Math.max(0, maxConcurrent - activeAgents);
|
const perProjectAvailable = Math.max(0, maxTriageConcurrent - activeAgents);
|
||||||
const semaphoreAvailable = this.options.semaphore
|
const semaphoreAvailable = this.options.semaphore
|
||||||
? Math.max(0, this.options.semaphore.availableCount)
|
? Math.max(0, this.options.semaphore.availableCount)
|
||||||
: Infinity;
|
: Infinity;
|
||||||
@@ -562,7 +561,7 @@ export class TriageProcessor {
|
|||||||
|
|
||||||
if (maxToStart <= 0 && triageTasks.length > 0) {
|
if (maxToStart <= 0 && triageTasks.length > 0) {
|
||||||
triageLog.log(
|
triageLog.log(
|
||||||
`Triage throttled: ${activeAgents} active agents (${inProgress} executing, ${specifying} specifying), limit ${maxConcurrent}`,
|
`Triage throttled: ${activeAgents} specifying agents, limit ${maxTriageConcurrent}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user