feat(HAI-006): add groupOverlappingFiles setting for file-scope-aware task scheduling

- Add groupOverlappingFiles toggle to Settings types and store with default enabled
- Parse File Scope sections from PROMPT.md to extract per-task file lists
- Implement overlap detection in scheduler to group tasks sharing files
- Add groupOverlappingFiles toggle to the Settings UI modal
- Update README and remove obsolete triage overlap logic
This commit is contained in:
Dustin Byrne
2026-03-25 20:46:07 -04:00
5 changed files with 114 additions and 2 deletions

View File

@@ -114,7 +114,7 @@ When enabled, three components run:
- **TriageProcessor** — Watches triage column. Spawns a pi agent session that reads the project, understands context, and writes a full PROMPT.md specification. Moves task to todo.
- **Scheduler** — Watches todo column. Resolves dependency graphs. Moves tasks to in-progress when deps are satisfied and concurrency allows (default: 2 concurrent).
- **Scheduler** — Watches todo column. Resolves dependency graphs. Moves tasks to in-progress when deps are satisfied and concurrency allows (default: 2 concurrent). When `groupOverlappingFiles` is enabled in settings, tasks whose `## File Scope` sections share files are serialized to prevent merge conflicts.
- **TaskExecutor** — Listens for tasks entering in-progress. Creates a git worktree, spawns a pi agent session with full coding tools scoped to the worktree, and executes the specification. Moves to in-review on completion.

View File

@@ -317,6 +317,35 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
return steps;
}
/**
* Parse the `## File Scope` section from a task's PROMPT.md and extract
* backtick-quoted file paths. Glob patterns ending in `/*` are stored
* as directory prefixes for overlap comparison.
*/
async parseFileScopeFromPrompt(id: string): Promise<string[]> {
const dir = this.taskDir(id);
const promptPath = join(dir, "PROMPT.md");
if (!existsSync(promptPath)) return [];
const content = await readFile(promptPath, "utf-8");
// Find the ## File Scope section
const fileScopeMatch = content.match(
/^##\s+File\s+Scope\s*\n([\s\S]*?)(?=\n##\s|\n#\s|$)/m,
);
if (!fileScopeMatch) return [];
const section = fileScopeMatch[1];
const paths: string[] = [];
const backtickRegex = /`([^`]+)`/g;
let match;
while ((match = backtickRegex.exec(section)) !== null) {
paths.push(match[1]);
}
return paths;
}
async deleteTask(id: string): Promise<Task> {
const dir = this.taskDir(id);
const data = await readFile(join(dir, "task.json"), "utf-8");

View File

@@ -23,6 +23,7 @@ export interface Task {
worktree?: string;
steps: TaskStep[];
currentStep: number;
status?: string;
log: TaskLogEntry[];
size?: "S" | "M" | "L";
reviewLevel?: number;
@@ -45,12 +46,14 @@ export interface Settings {
maxConcurrent: number;
maxWorktrees: number;
pollIntervalMs: number;
groupOverlappingFiles: boolean;
}
export const DEFAULT_SETTINGS: Settings = {
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 15000,
groupOverlappingFiles: false,
};
export interface BoardConfig {

View File

@@ -9,7 +9,7 @@ interface SettingsModalProps {
}
export function SettingsModal({ onClose, addToast }: SettingsModalProps) {
const [form, setForm] = useState<Settings>({ maxConcurrent: 2, maxWorktrees: 4, pollIntervalMs: 15000 });
const [form, setForm] = useState<Settings>({ maxConcurrent: 2, maxWorktrees: 4, pollIntervalMs: 15000, groupOverlappingFiles: false });
const [loading, setLoading] = useState(true);
useEffect(() => {
@@ -102,6 +102,20 @@ export function SettingsModal({ onClose, addToast }: SettingsModalProps) {
}
/>
</div>
<div className="form-group">
<label htmlFor="groupOverlappingFiles" style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<input
id="groupOverlappingFiles"
type="checkbox"
checked={form.groupOverlappingFiles}
onChange={(e) =>
setForm((f) => ({ ...f, groupOverlappingFiles: e.target.checked }))
}
/>
Serialize tasks with overlapping files
</label>
<small>When enabled, tasks that modify the same files are queued serially to avoid merge conflicts</small>
</div>
</div>
)}
<div className="modal-actions">

View File

@@ -51,12 +51,43 @@ export class Scheduler {
console.log("[scheduler] Stopped");
}
/**
* Check whether two sets of file scope paths overlap.
* Paths overlap if they are identical, or if one is a directory prefix of the other.
* Glob patterns (ending with /*) are treated as directory prefixes.
*/
private pathsOverlap(a: string[], b: string[]): boolean {
for (const pa of a) {
const prefixA = pa.endsWith("/*") ? pa.slice(0, -1) : null;
for (const pb of b) {
const prefixB = pb.endsWith("/*") ? pb.slice(0, -1) : null;
// Exact match (ignoring glob suffix)
const cleanA = prefixA ? pa.slice(0, -2) : pa;
const cleanB = prefixB ? pb.slice(0, -2) : pb;
if (cleanA === cleanB) return true;
// Check prefix overlap
if (prefixA && pb.startsWith(prefixA)) return true;
if (prefixB && pa.startsWith(prefixB)) return true;
if (prefixA && prefixB) {
if (prefixA.startsWith(prefixB) || prefixB.startsWith(prefixA)) return true;
}
// Exact file path match
if (pa === pb) return true;
}
}
return false;
}
/** Run one scheduling pass. */
async schedule(): Promise<void> {
if (!this.running) return;
try {
const tasks = await this.store.listTasks();
const settings = await this.store.getSettings();
const maxConcurrent = this.options.maxConcurrent ?? 2;
const maxWorktrees = this.options.maxWorktrees ?? 4;
@@ -84,6 +115,15 @@ export class Scheduler {
const todo = tasks.filter((t) => t.column === "todo");
if (todo.length === 0) return;
// Pre-compute file scopes for in-progress tasks when overlap detection is enabled
const inProgressScopes = new Map<string, string[]>();
if (settings.groupOverlappingFiles) {
for (const t of inProgress) {
const scope = await this.store.parseFileScopeFromPrompt(t.id);
if (scope.length > 0) inProgressScopes.set(t.id, scope);
}
}
// Resolve dependency order among todo tasks
const ordered = resolveDependencyOrder(todo);
let started = 0;
@@ -103,6 +143,26 @@ export class Scheduler {
continue;
}
// Check file scope overlap when enabled
if (settings.groupOverlappingFiles) {
const taskScope = await this.store.parseFileScopeFromPrompt(task.id);
if (taskScope.length > 0) {
let overlappingTaskId: string | null = null;
for (const [ipId, ipScope] of inProgressScopes) {
if (this.pathsOverlap(taskScope, ipScope)) {
overlappingTaskId = ipId;
break;
}
}
if (overlappingTaskId) {
console.log(
`[scheduler] Deferring ${task.id}: file overlap with ${overlappingTaskId}`,
);
continue;
}
}
}
// Dependencies met — check concurrency
if (started >= available) {
continue;
@@ -116,6 +176,12 @@ export class Scheduler {
await this.store.moveTask(task.id, "in-progress");
this.options.onSchedule?.(task);
started++;
// Track newly started task's file scope for overlap with remaining todo tasks
if (settings.groupOverlappingFiles) {
const scope = await this.store.parseFileScopeFromPrompt(task.id);
if (scope.length > 0) inProgressScopes.set(task.id, scope);
}
}
} catch (err) {
console.error("[scheduler] Scheduling error:", err);