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") {
|
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);
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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++;
|
||||||
|
|||||||
Reference in New Issue
Block a user