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:
@@ -267,6 +267,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
if (toColumn === "done") {
|
||||
task.status = undefined;
|
||||
task.worktree = undefined;
|
||||
task.blockedBy = undefined;
|
||||
}
|
||||
|
||||
await this.atomicWriteTaskJson(dir, task);
|
||||
@@ -281,7 +282,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
|
||||
async updateTask(
|
||||
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> {
|
||||
return this.withTaskLock(id, async () => {
|
||||
const dir = this.taskDir(id);
|
||||
@@ -295,6 +296,11 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
} else if (updates.status !== undefined) {
|
||||
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();
|
||||
|
||||
await this.atomicWriteTaskJson(dir, task);
|
||||
@@ -564,6 +570,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
task.column = "done";
|
||||
task.worktree = undefined;
|
||||
task.status = undefined;
|
||||
task.blockedBy = undefined;
|
||||
task.updatedAt = new Date().toISOString();
|
||||
|
||||
await this.atomicWriteTaskJson(dir, task);
|
||||
|
||||
@@ -32,6 +32,11 @@ export interface Task {
|
||||
steps: TaskStep[];
|
||||
currentStep: number;
|
||||
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[];
|
||||
log: TaskLogEntry[];
|
||||
size?: "S" | "M" | "L";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { fetchTaskDetail, uploadAttachment } from "../api";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
@@ -139,13 +139,18 @@ export function TaskCard({ task, queued, onOpenDetail, addToast }: TaskCardProps
|
||||
</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">
|
||||
{task.dependencies && task.dependencies.length > 0 && (
|
||||
<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" : ""}
|
||||
</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>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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", () => {
|
||||
/** Mirrors the card-status-badge visibility condition from TaskCard.tsx */
|
||||
function shouldShowStatusBadge(status?: string | null): boolean {
|
||||
|
||||
@@ -315,6 +315,35 @@ html, body {
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -464,7 +464,7 @@ describe("Scheduler after restart", () => {
|
||||
scheduler.stop();
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
|
||||
@@ -278,8 +278,53 @@ describe("Scheduler file-scope overlap", () => {
|
||||
|
||||
// HAI-002 should NOT be moved to in-progress (deferred)
|
||||
expect(store.moveTask).not.toHaveBeenCalled();
|
||||
// HAI-002 should have status set to "queued"
|
||||
expect(store.updateTask).toHaveBeenCalledWith("HAI-002", { status: "queued" });
|
||||
// HAI-002 should have status set to "queued" with blockedBy
|
||||
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 () => {
|
||||
|
||||
@@ -252,7 +252,7 @@ export class Scheduler {
|
||||
console.log(
|
||||
`[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;
|
||||
}
|
||||
}
|
||||
@@ -267,7 +267,7 @@ export class Scheduler {
|
||||
console.log(
|
||||
`[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");
|
||||
this.options.onSchedule?.(task);
|
||||
started++;
|
||||
|
||||
Reference in New Issue
Block a user