diff --git a/packages/server/src/tasks/execution-order.ts b/packages/server/src/tasks/execution-order.ts index 197d6c9c1..29a03b6e8 100644 --- a/packages/server/src/tasks/execution-order.ts +++ b/packages/server/src/tasks/execution-order.ts @@ -1,5 +1,11 @@ import type { Task, TaskStore } from "./types.js"; -import { buildChildrenMap, loadScopedTaskGraph, sortByPriorityThenCreated } from "./task-graph.js"; +import { + buildChildrenMap, + getTasksById, + isTaskExecutableInOrder, + loadScopedTaskGraph, + sortByPriorityThenCreated, +} from "./task-graph.js"; export interface ExecutionOrderResult { /** Tasks in execution order (done first, then pending) */ @@ -24,24 +30,13 @@ export async function computeExecutionOrder( ): Promise { const graph = await loadScopedTaskGraph(store, scopeId); - const simDone = new Set(graph.allTasks.filter((t) => t.status === "done").map((t) => t.id)); + const simDone = new Set(graph.doneTaskIds); const remaining = new Set( graph.candidates .filter((t) => t.status === "open" || t.status === "in_progress") .map((t) => t.id), ); - const isReady = (taskId: string): boolean => { - const task = graph.taskMap.get(taskId); - if (!task) return false; - const depsOk = task.deps.every((depId) => simDone.has(depId)); - const children = (graph.childrenMap.get(taskId) ?? []).filter((c) => - graph.candidateIds.has(c.id), - ); - const childrenOk = children.every((c) => simDone.has(c.id)); - return depsOk && childrenOk; - }; - const timeline: Task[] = []; const orderMap = new Map(); let orderIdx = 0; @@ -56,9 +51,8 @@ export async function computeExecutionOrder( // Then pending tasks in execution order while (remaining.size > 0) { - const readyNow = [...remaining] - .filter(isReady) - .map((tid) => graph.taskMap.get(tid)!) + const readyNow = getTasksById(graph, remaining) + .filter((task) => isTaskExecutableInOrder(graph, task.id, simDone)) .sort(sortByPriorityThenCreated); if (readyNow.length === 0) break; diff --git a/packages/server/src/tasks/task-graph.ts b/packages/server/src/tasks/task-graph.ts index fb7472cbb..4fc0672d4 100644 --- a/packages/server/src/tasks/task-graph.ts +++ b/packages/server/src/tasks/task-graph.ts @@ -6,6 +6,7 @@ export interface TaskGraph { taskMap: Map; childrenMap: Map; candidateIds: Set; + doneTaskIds: Set; } type TaskGraphStore = Pick; @@ -35,6 +36,47 @@ export function buildChildrenMap(tasks: Task[]): Map { return childrenMap; } +export function isReadyTask(graph: TaskGraph, task: Task): boolean { + return ( + task.status === "open" && + areTaskDepsDone(graph, task, graph.doneTaskIds) && + areTaskChildrenDone(graph, task.id, graph.doneTaskIds) + ); +} + +export function isBlockedTask(graph: TaskGraph, task: Task): boolean { + return ( + task.status !== "draft" && + task.status !== "done" && + task.deps.length > 0 && + !areTaskDepsDone(graph, task, graph.doneTaskIds) + ); +} + +export function isTaskExecutableInOrder( + graph: TaskGraph, + taskId: string, + completedTaskIds: Set, +): boolean { + const task = graph.taskMap.get(taskId); + return ( + task !== undefined && + areTaskDepsDone(graph, task, completedTaskIds) && + areTaskChildrenDone(graph, task.id, completedTaskIds, { scoped: true }) + ); +} + +export function getTasksById(graph: TaskGraph, taskIds: Iterable): Task[] { + const tasks: Task[] = []; + for (const taskId of taskIds) { + const task = graph.taskMap.get(taskId); + if (task) { + tasks.push(task); + } + } + return tasks; +} + export async function loadScopedTaskGraph( store: TaskGraphStore, scopeId?: string, @@ -48,9 +90,32 @@ export async function loadScopedTaskGraph( taskMap: buildTaskMap(allTasks), childrenMap: buildChildrenMap(allTasks), candidateIds: new Set(candidates.map((task) => task.id)), + doneTaskIds: new Set(allTasks.filter((task) => task.status === "done").map((task) => task.id)), }; } +function areTaskDepsDone(graph: TaskGraph, task: Task, completedTaskIds: Set): boolean { + return task.deps.every((depId) => { + const dep = graph.taskMap.get(depId); + return dep !== undefined && completedTaskIds.has(depId); + }); +} + +function areTaskChildrenDone( + graph: TaskGraph, + taskId: string, + completedTaskIds: Set, + options?: { scoped: boolean }, +): boolean { + const children = graph.childrenMap.get(taskId) ?? []; + return children.every((child) => { + if (options?.scoped === true && !graph.candidateIds.has(child.id)) { + return true; + } + return completedTaskIds.has(child.id); + }); +} + async function loadScopedCandidates( store: TaskGraphStore, allTasks: Task[], diff --git a/packages/server/src/tasks/task-store.ts b/packages/server/src/tasks/task-store.ts index 1adc8bda5..ed12021e2 100644 --- a/packages/server/src/tasks/task-store.ts +++ b/packages/server/src/tasks/task-store.ts @@ -2,7 +2,12 @@ import { readdir, readFile, writeFile, mkdir, unlink } from "node:fs/promises"; import { join } from "node:path"; import { randomBytes } from "node:crypto"; import type { Task, TaskStore, CreateTaskOptions, TaskStatus } from "./types.js"; -import { loadScopedTaskGraph, sortByPriorityThenCreated } from "./task-graph.js"; +import { + isBlockedTask, + isReadyTask, + loadScopedTaskGraph, + sortByPriorityThenCreated, +} from "./task-graph.js"; function generateId(): string { return randomBytes(4).toString("hex"); @@ -253,34 +258,14 @@ export class FileTaskStore implements TaskStore { async getReady(scopeId?: string): Promise { const graph = await loadScopedTaskGraph(this, scopeId); - - const isReady = (task: Task): boolean => { - if (task.status !== "open") return false; - const depsReady = task.deps.every((depId) => { - const dep = graph.taskMap.get(depId); - return dep?.status === "done"; - }); - if (!depsReady) return false; - const children = graph.childrenMap.get(task.id) ?? []; - return children.every((c) => c.status === "done"); - }; - - return graph.candidates.filter(isReady).sort(sortByPriorityThenCreated); + return graph.candidates + .filter((task) => isReadyTask(graph, task)) + .sort(sortByPriorityThenCreated); } async getBlocked(scopeId?: string): Promise { const graph = await loadScopedTaskGraph(this, scopeId); - - const isBlocked = (task: Task): boolean => { - if (task.status === "draft" || task.status === "done") return false; - if (task.deps.length === 0) return false; - return task.deps.some((depId) => { - const dep = graph.taskMap.get(depId); - return dep?.status !== "done"; - }); - }; - - return graph.candidates.filter(isBlocked); + return graph.candidates.filter((task) => isBlockedTask(graph, task)); } async getClosed(scopeId?: string): Promise {