Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 126 additions & 0 deletions web-ui/src/hooks/use-task-start-service-prompts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ import {
buildTaskStartServicePromptContent,
collectPendingTaskStartServicePrompts,
detectTaskStartServicePromptIds,
getStartableBacklogTaskIds,
getTaskStartServicePromptKey,
isTaskStartServicePromptAlreadyConfigured,
} from "@/hooks/use-task-start-service-prompts";
import type { BoardCard, BoardData, BoardDependency } from "@/types";

describe("detectTaskStartServicePromptIds", () => {
it("detects linear links", () => {
Expand Down Expand Up @@ -188,3 +190,127 @@ describe("collectPendingTaskStartServicePrompts", () => {
).toEqual([]);
});
});

describe("getStartableBacklogTaskIds", () => {
function createCard(id: string, prompt = "Do something"): BoardCard {
return {
id,
prompt,
startInPlanMode: false,
autoReviewEnabled: false,
autoReviewMode: "commit",
baseRef: "main",
createdAt: Date.now(),
updatedAt: Date.now(),
};
}

function createBoard({
backlogCards,
dependencies = [],
inProgressCards = [],
}: {
backlogCards: BoardCard[];
dependencies?: BoardDependency[];
inProgressCards?: BoardCard[];
}): BoardData {
return {
columns: [
{ id: "backlog", title: "Backlog", cards: backlogCards },
{ id: "in_progress", title: "In Progress", cards: inProgressCards },
{ id: "review", title: "Review", cards: [] },
{ id: "trash", title: "Trash", cards: [] },
],
dependencies,
};
}

it("returns all backlog task ids when there are no dependencies", () => {
const board = createBoard({ backlogCards: [createCard("task-1"), createCard("task-2"), createCard("task-3")] });
expect(getStartableBacklogTaskIds(board)).toEqual(["task-1", "task-2", "task-3"]);
});

it("returns empty array when backlog is empty", () => {
const board = createBoard({ backlogCards: [] });
expect(getStartableBacklogTaskIds(board)).toEqual([]);
});

it("excludes a parent task whose child is also in the backlog", () => {
// A → B means A is parent, B is child. Child B should run first, so A is excluded.
const board = createBoard({
backlogCards: [createCard("task-a"), createCard("task-b")],
dependencies: [{ id: "dep-1", fromTaskId: "task-a", toTaskId: "task-b", createdAt: 1 }],
});
expect(getStartableBacklogTaskIds(board)).toEqual(["task-b"]);
});

it("excludes a parent task whose child is in progress", () => {
// A → B, B is in progress. A should wait for B to finish, so A is excluded.
const board = createBoard({
backlogCards: [createCard("task-a")],
dependencies: [{ id: "dep-1", fromTaskId: "task-a", toTaskId: "task-b", createdAt: 1 }],
inProgressCards: [createCard("task-b")],
});
expect(getStartableBacklogTaskIds(board)).toEqual([]);
});

it("includes a parent task whose child is in review (neither backlog nor in_progress)", () => {
// A → B, B is in review. Child is done, so parent A can start.
const board: BoardData = {
columns: [
{ id: "backlog", title: "Backlog", cards: [createCard("task-a")] },
{ id: "in_progress", title: "In Progress", cards: [] },
{ id: "review", title: "Review", cards: [createCard("task-b")] },
{ id: "trash", title: "Trash", cards: [] },
],
dependencies: [{ id: "dep-1", fromTaskId: "task-a", toTaskId: "task-b", createdAt: 1 }],
};
expect(getStartableBacklogTaskIds(board)).toEqual(["task-a"]);
});

it("only starts the leaf of a chain when all tasks are in the backlog (A → B → C)", () => {
// A → B → C: C is the leaf child, should run first. A and B are excluded.
const board = createBoard({
backlogCards: [createCard("task-a"), createCard("task-b"), createCard("task-c")],
dependencies: [
{ id: "dep-1", fromTaskId: "task-a", toTaskId: "task-b", createdAt: 1 },
{ id: "dep-2", fromTaskId: "task-b", toTaskId: "task-c", createdAt: 2 },
],
});
expect(getStartableBacklogTaskIds(board)).toEqual(["task-c"]);
});

it("starts multiple independent leaf children while excluding their parents", () => {
const board = createBoard({
backlogCards: [createCard("parent-1"), createCard("child-1"), createCard("parent-2"), createCard("child-2")],
dependencies: [
{ id: "dep-1", fromTaskId: "parent-1", toTaskId: "child-1", createdAt: 1 },
{ id: "dep-2", fromTaskId: "parent-2", toTaskId: "child-2", createdAt: 2 },
],
});
expect(getStartableBacklogTaskIds(board)).toEqual(["child-1", "child-2"]);
});

it("excludes both parents in a diamond where two parents share a child in the backlog", () => {
// task-a → task-c, task-b → task-c: both parents have a child in backlog, so both are excluded
const board = createBoard({
backlogCards: [createCard("task-a"), createCard("task-b"), createCard("task-c")],
dependencies: [
{ id: "dep-1", fromTaskId: "task-a", toTaskId: "task-c", createdAt: 1 },
{ id: "dep-2", fromTaskId: "task-b", toTaskId: "task-c", createdAt: 2 },
],
});
const result = getStartableBacklogTaskIds(board);
expect(result).not.toContain("task-a");
expect(result).not.toContain("task-b");
expect(result).toContain("task-c");
});

it("includes a task whose dependency points to a non-existent child", () => {
const board = createBoard({
backlogCards: [createCard("task-a")],
dependencies: [{ id: "dep-1", fromTaskId: "task-a", toTaskId: "ghost", createdAt: 1 }],
});
expect(getStartableBacklogTaskIds(board)).toEqual(["task-a"]);
});
});
40 changes: 32 additions & 8 deletions web-ui/src/hooks/use-task-start-service-prompts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,34 @@ export function buildTaskStartServicePromptContent(
}
}

export function getStartableBacklogTaskIds(board: BoardData): string[] {
const allBacklogTasks = new Set<string>();
const allInProgressTasks = new Set<string>();
const startableTaskIds: string[] = [];

const backlogCards = board.columns.find((column) => column.id === "backlog")?.cards;
const inProgressTasks = board.columns.find((column) => column.id === "in_progress")?.cards;

backlogCards?.forEach((card) => {
allBacklogTasks.add(card.id);
});
inProgressTasks?.forEach((card) => {
allInProgressTasks.add(card.id);
});

backlogCards?.forEach((card) => {
const dependency = board.dependencies.find((d) => d.fromTaskId === card.id);
const isChildTaskInBacklog = dependency && allBacklogTasks.has(dependency.toTaskId);
const isChildTaskInProgress = dependency && allInProgressTasks.has(dependency.toTaskId);

if (!isChildTaskInBacklog && !isChildTaskInProgress) {
startableTaskIds.push(card.id);
}
});

return startableTaskIds;
}

export function collectPendingTaskStartServicePrompts(input: {
tasks: TaskStartServicePromptTask[];
taskStartSetupAvailability: RuntimeTaskStartSetupAvailability | null | undefined;
Expand Down Expand Up @@ -554,22 +582,18 @@ export function useTaskStartServicePrompts({
);

const handleStartAllBacklogTasksWithServiceSetupPrompt = useCallback(() => {
const backlogTaskIds =
board.columns.find((column) => column.id === "backlog")?.cards.map((card) => card.id) ?? [];
const backlogTaskIds = getStartableBacklogTaskIds(board);

if (backlogTaskIds.length === 0) {
return;
}

if (queueTaskStartServicePrompts(backlogTaskIds)) {
return;
}
clearTaskStartServicePromptAcknowledgements(backlogTaskIds);
handleStartAllBacklogTasks(backlogTaskIds);
}, [
board.columns,
clearTaskStartServicePromptAcknowledgements,
handleStartAllBacklogTasks,
queueTaskStartServicePrompts,
]);
}, [board, clearTaskStartServicePromptAcknowledgements, handleStartAllBacklogTasks, queueTaskStartServicePrompts]);

const handleCreateAndStartTask = useCallback(() => {
const taskId = handleCreateTask();
Expand Down
Loading