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:
Dustin Byrne
2026-03-25 20:32:07 -04:00
parent 93651835b6
commit 6fbf3519b5
5 changed files with 48 additions and 6 deletions

View File

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

View File

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

View File

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

View File

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

View File

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