feat(HAI-002): add maxWorktrees setting to limit total git worktrees
- Add maxWorktrees field to Settings interface with default of 4 - Enforce worktree limit in Scheduler.schedule() alongside maxConcurrent - Wire setting through dashboard startup from persisted settings - Include maxWorktrees in /api/config response - Add Max Worktrees input to Settings Modal UI - Backward compatible: existing configs without maxWorktrees use default
This commit is contained in:
@@ -49,8 +49,11 @@ export async function runDashboard(port: number, opts: { engine?: boolean; open?
|
|||||||
onError: (t, e) => console.log(`[engine] ✗ ${t.id}: ${e.message}`),
|
onError: (t, e) => console.log(`[engine] ✗ ${t.id}: ${e.message}`),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const settings = await store.getSettings();
|
||||||
|
|
||||||
const scheduler = new Scheduler(store, {
|
const scheduler = new Scheduler(store, {
|
||||||
maxConcurrent: 2,
|
maxConcurrent: settings.maxConcurrent,
|
||||||
|
maxWorktrees: settings.maxWorktrees,
|
||||||
onSchedule: (t) => console.log(`[engine] Scheduled ${t.id}`),
|
onSchedule: (t) => console.log(`[engine] Scheduled ${t.id}`),
|
||||||
onBlocked: (t, deps) => console.log(`[engine] ${t.id} blocked by ${deps.join(", ")}`),
|
onBlocked: (t, deps) => console.log(`[engine] ${t.id} blocked by ${deps.join(", ")}`),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -43,11 +43,13 @@ export interface TaskCreateInput {
|
|||||||
|
|
||||||
export interface Settings {
|
export interface Settings {
|
||||||
maxConcurrent: number;
|
maxConcurrent: number;
|
||||||
|
maxWorktrees: number;
|
||||||
pollIntervalMs: number;
|
pollIntervalMs: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_SETTINGS: Settings = {
|
export const DEFAULT_SETTINGS: Settings = {
|
||||||
maxConcurrent: 2,
|
maxConcurrent: 2,
|
||||||
|
maxWorktrees: 4,
|
||||||
pollIntervalMs: 15000,
|
pollIntervalMs: 15000,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ interface SettingsModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SettingsModal({ onClose, addToast }: SettingsModalProps) {
|
export function SettingsModal({ onClose, addToast }: SettingsModalProps) {
|
||||||
const [form, setForm] = useState<Settings>({ maxConcurrent: 2, pollIntervalMs: 15000 });
|
const [form, setForm] = useState<Settings>({ maxConcurrent: 2, maxWorktrees: 4, pollIntervalMs: 15000 });
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -75,6 +75,20 @@ export function SettingsModal({ onClose, addToast }: SettingsModalProps) {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="maxWorktrees">Max Worktrees</label>
|
||||||
|
<input
|
||||||
|
id="maxWorktrees"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={20}
|
||||||
|
value={form.maxWorktrees}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((f) => ({ ...f, maxWorktrees: Number(e.target.value) }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<small>Limits total git worktrees including in-review tasks</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
|
||||||
|
|||||||
@@ -10,9 +10,12 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
|||||||
router.get("/config", async (_req, res) => {
|
router.get("/config", async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
const settings = await store.getSettings();
|
const settings = await store.getSettings();
|
||||||
res.json({ maxConcurrent: settings.maxConcurrent ?? options?.maxConcurrent ?? 2 });
|
res.json({
|
||||||
|
maxConcurrent: settings.maxConcurrent ?? options?.maxConcurrent ?? 2,
|
||||||
|
maxWorktrees: settings.maxWorktrees ?? 4,
|
||||||
|
});
|
||||||
} catch {
|
} catch {
|
||||||
res.json({ maxConcurrent: options?.maxConcurrent ?? 2 });
|
res.json({ maxConcurrent: options?.maxConcurrent ?? 2, maxWorktrees: 4 });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { resolveDependencyOrder, type TaskStore, type Task } from "@hai/core";
|
|||||||
export interface SchedulerOptions {
|
export interface SchedulerOptions {
|
||||||
/** Max concurrent in-progress tasks. Default: 2 */
|
/** Max concurrent in-progress tasks. Default: 2 */
|
||||||
maxConcurrent?: number;
|
maxConcurrent?: number;
|
||||||
|
/** Max total worktrees (in-progress + in-review with worktree). Default: 4 */
|
||||||
|
maxWorktrees?: number;
|
||||||
/** Milliseconds between scheduling polls. Default: 15000 */
|
/** Milliseconds between scheduling polls. Default: 15000 */
|
||||||
pollIntervalMs?: number;
|
pollIntervalMs?: number;
|
||||||
/** Called when scheduler starts a task */
|
/** Called when scheduler starts a task */
|
||||||
@@ -36,7 +38,7 @@ export class Scheduler {
|
|||||||
this.pollInterval = setInterval(() => this.schedule(), interval);
|
this.pollInterval = setInterval(() => this.schedule(), interval);
|
||||||
this.schedule();
|
this.schedule();
|
||||||
console.log(
|
console.log(
|
||||||
`[scheduler] Started (max concurrent: ${this.options.maxConcurrent ?? 2})`,
|
`[scheduler] Started (max concurrent: ${this.options.maxConcurrent ?? 2}, max worktrees: ${this.options.maxWorktrees ?? 4})`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,9 +58,27 @@ export class Scheduler {
|
|||||||
try {
|
try {
|
||||||
const tasks = await this.store.listTasks();
|
const tasks = await this.store.listTasks();
|
||||||
const maxConcurrent = this.options.maxConcurrent ?? 2;
|
const maxConcurrent = this.options.maxConcurrent ?? 2;
|
||||||
|
const maxWorktrees = this.options.maxWorktrees ?? 4;
|
||||||
|
|
||||||
|
// Count all tasks with active worktrees (in-progress or in-review with worktree set)
|
||||||
|
const activeWorktrees = tasks.filter(
|
||||||
|
(t) =>
|
||||||
|
t.column === "in-progress" ||
|
||||||
|
(t.column === "in-review" && t.worktree),
|
||||||
|
).length;
|
||||||
|
|
||||||
|
if (activeWorktrees >= maxWorktrees) {
|
||||||
|
console.log(
|
||||||
|
`[scheduler] Worktree limit reached (${activeWorktrees}/${maxWorktrees})`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const inProgress = tasks.filter((t) => t.column === "in-progress");
|
const inProgress = tasks.filter((t) => t.column === "in-progress");
|
||||||
const available = maxConcurrent - inProgress.length;
|
const available = Math.min(
|
||||||
|
maxConcurrent - inProgress.length,
|
||||||
|
maxWorktrees - activeWorktrees,
|
||||||
|
);
|
||||||
if (available <= 0) return;
|
if (available <= 0) return;
|
||||||
|
|
||||||
const todo = tasks.filter((t) => t.column === "todo");
|
const todo = tasks.filter((t) => t.column === "todo");
|
||||||
|
|||||||
Reference in New Issue
Block a user