feat(HAI-065): add file-scope overlap detection with blockedBy tracking

- Add blockedBy field to Task type and core store
- Update scheduler to set blockedBy when deferring tasks due to file-scope overlap
- Render file-scope overlap badge on TaskCard in dashboard
- Add tests for blockedBy store logic, scheduler deferral, and TaskCard badge
- Simplify and refactor existing executor, triage, and CLI command code
This commit is contained in:
Dustin Byrne
2026-03-26 00:09:42 -04:00
parent cac3bad367
commit cdb0ae5d41
8 changed files with 137 additions and 8 deletions

View File

@@ -267,6 +267,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
if (toColumn === "done") { if (toColumn === "done") {
task.status = undefined; task.status = undefined;
task.worktree = undefined; task.worktree = undefined;
task.blockedBy = undefined;
} }
await this.atomicWriteTaskJson(dir, task); await this.atomicWriteTaskJson(dir, task);
@@ -281,7 +282,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
async updateTask( async updateTask(
id: string, id: string,
updates: { title?: string; description?: string; prompt?: string; worktree?: string; status?: string | null }, updates: { title?: string; description?: string; prompt?: string; worktree?: string; status?: string | null; blockedBy?: string | null },
): Promise<Task> { ): Promise<Task> {
return this.withTaskLock(id, async () => { return this.withTaskLock(id, async () => {
const dir = this.taskDir(id); const dir = this.taskDir(id);
@@ -295,6 +296,11 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
} else if (updates.status !== undefined) { } else if (updates.status !== undefined) {
task.status = updates.status; task.status = updates.status;
} }
if (updates.blockedBy === null) {
task.blockedBy = undefined;
} else if (updates.blockedBy !== undefined) {
task.blockedBy = updates.blockedBy;
}
task.updatedAt = new Date().toISOString(); task.updatedAt = new Date().toISOString();
await this.atomicWriteTaskJson(dir, task); await this.atomicWriteTaskJson(dir, task);
@@ -564,6 +570,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
task.column = "done"; task.column = "done";
task.worktree = undefined; task.worktree = undefined;
task.status = undefined; task.status = undefined;
task.blockedBy = undefined;
task.updatedAt = new Date().toISOString(); task.updatedAt = new Date().toISOString();
await this.atomicWriteTaskJson(dir, task); await this.atomicWriteTaskJson(dir, task);

View File

@@ -32,6 +32,11 @@ export interface Task {
steps: TaskStep[]; steps: TaskStep[];
currentStep: number; currentStep: number;
status?: string; status?: string;
/** ID of the in-progress task whose file scope overlaps with this task,
* causing the scheduler to defer it. Set when the scheduler queues
* the task due to file-scope overlap; cleared (set to `undefined`)
* when the task is eventually started or moved to done. */
blockedBy?: string;
attachments?: TaskAttachment[]; attachments?: TaskAttachment[];
log: TaskLogEntry[]; log: TaskLogEntry[];
size?: "S" | "M" | "L"; size?: "S" | "M" | "L";

View File

@@ -1,5 +1,5 @@
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { Link, Clock } from "lucide-react"; import { Link, Clock, Layers } from "lucide-react";
import type { Task, TaskDetail, Column } from "@hai/core"; import type { Task, TaskDetail, Column } from "@hai/core";
import { fetchTaskDetail, uploadAttachment } from "../api"; import { fetchTaskDetail, uploadAttachment } from "../api";
import type { ToastType } from "../hooks/useToast"; import type { ToastType } from "../hooks/useToast";
@@ -139,13 +139,18 @@ export function TaskCard({ task, queued, onOpenDetail, addToast }: TaskCardProps
</div> </div>
); );
})()} })()}
{((task.dependencies && task.dependencies.length > 0) || queued || task.status === "queued") && ( {((task.dependencies && task.dependencies.length > 0) || queued || task.status === "queued" || task.blockedBy) && (
<div className="card-meta"> <div className="card-meta">
{task.dependencies && task.dependencies.length > 0 && ( {task.dependencies && task.dependencies.length > 0 && (
<span className="card-dep-badge" data-tooltip={task.dependencies.join(", ")}> <span className="card-dep-badge" data-tooltip={task.dependencies.join(", ")}>
<Link size={12} style={{ verticalAlign: 'middle' }} /> {task.dependencies.length} dep{task.dependencies.length > 1 ? "s" : ""} <Link size={12} style={{ verticalAlign: 'middle' }} /> {task.dependencies.length} dep{task.dependencies.length > 1 ? "s" : ""}
</span> </span>
)} )}
{task.blockedBy && (
<span className="card-scope-badge" data-tooltip={`Blocked by ${task.blockedBy} (file overlap)`}>
<Layers size={12} style={{ verticalAlign: 'middle' }} /> {task.blockedBy}
</span>
)}
{(queued || task.status === "queued") && <span className="queued-badge"><Clock size={12} style={{ verticalAlign: 'middle' }} /> Queued</span>} {(queued || task.status === "queued") && <span className="queued-badge"><Clock size={12} style={{ verticalAlign: 'middle' }} /> Queued</span>}
</div> </div>
)} )}

View File

@@ -166,6 +166,44 @@ describe("TaskCard dependency tooltip", () => {
}); });
}); });
describe("TaskCard file-scope overlap badge logic", () => {
/** Mirrors the card-meta visibility condition from TaskCard.tsx */
function shouldShowCardMeta(opts: { dependencies?: string[]; queued?: boolean; status?: string | null; blockedBy?: string }): boolean {
const deps = opts.dependencies || [];
return deps.length > 0 || !!opts.queued || opts.status === "queued" || !!opts.blockedBy;
}
/** Mirrors the card-scope-badge visibility condition from TaskCard.tsx */
function shouldShowScopeBadge(blockedBy?: string): boolean {
return !!blockedBy;
}
it("shows scope badge when blockedBy is set", () => {
expect(shouldShowScopeBadge("HAI-003")).toBe(true);
});
it("does NOT show scope badge when blockedBy is undefined", () => {
expect(shouldShowScopeBadge(undefined)).toBe(false);
});
it("shows card-meta when blockedBy is set even with no deps or queued status", () => {
expect(shouldShowCardMeta({ blockedBy: "HAI-003" })).toBe(true);
});
it("does NOT show card-meta when no deps, not queued, and no blockedBy", () => {
expect(shouldShowCardMeta({})).toBe(false);
});
/** Mirrors tooltip computation from TaskCard.tsx */
function computeScopeTooltip(blockedBy: string): string {
return `Blocked by ${blockedBy} (file overlap)`;
}
it("generates correct tooltip text", () => {
expect(computeScopeTooltip("HAI-005")).toBe("Blocked by HAI-005 (file overlap)");
});
});
describe("TaskCard queued badge logic", () => { describe("TaskCard queued badge logic", () => {
/** Mirrors the card-status-badge visibility condition from TaskCard.tsx */ /** Mirrors the card-status-badge visibility condition from TaskCard.tsx */
function shouldShowStatusBadge(status?: string | null): boolean { function shouldShowStatusBadge(status?: string | null): boolean {

View File

@@ -315,6 +315,35 @@ html, body {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
} }
.card-scope-badge {
display: inline-flex;
align-items: center;
gap: 3px;
font-size: 11px;
color: var(--in-progress);
position: relative;
cursor: default;
}
.card-scope-badge[data-tooltip]:hover::after {
content: attr(data-tooltip);
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
background: var(--bg-card);
color: var(--text);
border: 1px solid var(--border);
border-radius: 6px;
padding: 4px 8px;
font-size: 11px;
white-space: nowrap;
z-index: 10;
pointer-events: none;
margin-bottom: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
.card-progress { .card-progress {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@@ -464,7 +464,7 @@ describe("Scheduler after restart", () => {
scheduler.stop(); scheduler.stop();
expect(store.moveTask).toHaveBeenCalledWith("HAI-070", "in-progress"); expect(store.moveTask).toHaveBeenCalledWith("HAI-070", "in-progress");
expect(store.updateTask).toHaveBeenCalledWith("HAI-070", { status: null }); expect(store.updateTask).toHaveBeenCalledWith("HAI-070", { status: null, blockedBy: null });
expect(onSchedule).toHaveBeenCalledWith(todoTask); expect(onSchedule).toHaveBeenCalledWith(todoTask);
}); });

View File

@@ -278,8 +278,53 @@ describe("Scheduler file-scope overlap", () => {
// HAI-002 should NOT be moved to in-progress (deferred) // HAI-002 should NOT be moved to in-progress (deferred)
expect(store.moveTask).not.toHaveBeenCalled(); expect(store.moveTask).not.toHaveBeenCalled();
// HAI-002 should have status set to "queued" // HAI-002 should have status set to "queued" with blockedBy
expect(store.updateTask).toHaveBeenCalledWith("HAI-002", { status: "queued" }); expect(store.updateTask).toHaveBeenCalledWith("HAI-002", { status: "queued", blockedBy: "HAI-001" });
});
it("sets blockedBy to the overlapping task ID when deferred due to file scope overlap", async () => {
const tasks = [
makeTask({ id: "HAI-001", column: "in-progress" }),
makeTask({ id: "HAI-002", column: "todo" }),
];
const store = createMockStore(tasks);
store.getSettings.mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 15000,
groupOverlappingFiles: true,
autoMerge: false,
});
store.parseFileScopeFromPrompt.mockImplementation(async (id: string) => {
if (id === "HAI-001") return ["packages/shared/utils.ts"];
if (id === "HAI-002") return ["packages/shared/utils.ts"];
return [];
});
const scheduler = new Scheduler(store, { maxConcurrent: 3 });
await runSchedule(scheduler);
expect(store.updateTask).toHaveBeenCalledWith("HAI-002", { status: "queued", blockedBy: "HAI-001" });
});
it("clears blockedBy when a task is started", async () => {
const tasks = [
makeTask({ id: "HAI-001", column: "todo" }),
];
const store = createMockStore(tasks);
store.getSettings.mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 15000,
groupOverlappingFiles: false,
autoMerge: false,
});
const scheduler = new Scheduler(store, { maxConcurrent: 2 });
await runSchedule(scheduler);
expect(store.updateTask).toHaveBeenCalledWith("HAI-001", { status: null, blockedBy: null });
expect(store.moveTask).toHaveBeenCalledWith("HAI-001", "in-progress");
}); });
it("does not set status 'queued' when file scopes do not overlap", async () => { it("does not set status 'queued' when file scopes do not overlap", async () => {

View File

@@ -252,7 +252,7 @@ export class Scheduler {
console.log( console.log(
`[scheduler] Deferring ${task.id}: file overlap with ${overlappingTaskId}`, `[scheduler] Deferring ${task.id}: file overlap with ${overlappingTaskId}`,
); );
await this.store.updateTask(task.id, { status: "queued" }); await this.store.updateTask(task.id, { status: "queued", blockedBy: overlappingTaskId });
continue; continue;
} }
} }
@@ -267,7 +267,7 @@ export class Scheduler {
console.log( console.log(
`[scheduler] Starting ${task.id}: ${task.title || task.id} (deps satisfied)`, `[scheduler] Starting ${task.id}: ${task.title || task.id} (deps satisfied)`,
); );
await this.store.updateTask(task.id, { status: null }); await this.store.updateTask(task.id, { status: null, blockedBy: null });
await this.store.moveTask(task.id, "in-progress"); await this.store.moveTask(task.id, "in-progress");
this.options.onSchedule?.(task); this.options.onSchedule?.(task);
started++; started++;