From 1aef9861279864cf55a39567f11cc83128cffff2 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Wed, 4 Mar 2026 10:18:26 +0100 Subject: [PATCH] fix: Sort pipeline tasks by dependency order instead of priority+createdAt The Execution tab's PipelinePhaseGroup was using sortByPriorityAndQueueTime which ignores task dependencies entirely. Tasks now sort topologically via topologicalSortPhases, matching the Plan tab's TaskGraph behavior. - Add listInitiativeTaskDependencies tRPC procedure (bulk fetch) - Fetch task deps in PipelineTab, group by phase, pass through - Replace priority sort with topological sort in PipelinePhaseGroup - Show "blocked by N" count on PipelineTaskCard --- apps/server/trpc/routers/task.ts | 13 ++++++ .../src/components/pipeline/PipelineGraph.tsx | 4 +- .../pipeline/PipelinePhaseGroup.tsx | 40 +++++++++++++++++-- .../pipeline/PipelineStageColumn.tsx | 4 +- .../src/components/pipeline/PipelineTab.tsx | 27 ++++++++++++- .../components/pipeline/PipelineTaskCard.tsx | 8 +++- docs/frontend.md | 8 ++-- docs/server-api.md | 1 + 8 files changed, 94 insertions(+), 11 deletions(-) diff --git a/apps/server/trpc/routers/task.ts b/apps/server/trpc/routers/task.ts index 595323f..e02f30c 100644 --- a/apps/server/trpc/routers/task.ts +++ b/apps/server/trpc/routers/task.ts @@ -183,6 +183,19 @@ export function taskProcedures(publicProcedure: ProcedureBuilder) { return edges; }), + listInitiativeTaskDependencies: publicProcedure + .input(z.object({ initiativeId: z.string().min(1) })) + .query(async ({ ctx, input }) => { + const taskRepo = requireTaskRepository(ctx); + const tasks = await taskRepo.findByInitiativeId(input.initiativeId); + const edges: Array<{ taskId: string; dependsOn: string[] }> = []; + for (const t of tasks) { + const deps = await taskRepo.getDependencies(t.id); + if (deps.length > 0) edges.push({ taskId: t.id, dependsOn: deps }); + } + return edges; + }), + approveTask: publicProcedure .input(z.object({ taskId: z.string().min(1) })) .mutation(async ({ ctx, input }) => { diff --git a/apps/web/src/components/pipeline/PipelineGraph.tsx b/apps/web/src/components/pipeline/PipelineGraph.tsx index 2fac66b..790cb4b 100644 --- a/apps/web/src/components/pipeline/PipelineGraph.tsx +++ b/apps/web/src/components/pipeline/PipelineGraph.tsx @@ -12,11 +12,12 @@ interface PipelineGraphProps { createdAt: string | Date; }>[]; tasksByPhase: Record; + taskDepsByPhase: Record>; dependencyEdges: DependencyEdge[]; detailAgentByPhase?: Map; } -export function PipelineGraph({ columns, tasksByPhase, dependencyEdges, detailAgentByPhase }: PipelineGraphProps) { +export function PipelineGraph({ columns, tasksByPhase, taskDepsByPhase, dependencyEdges, detailAgentByPhase }: PipelineGraphProps) { // Build a set of phase IDs whose dependencies are all completed const blockedPhaseIds = useMemo(() => { const phaseStatusMap = new Map(); @@ -50,6 +51,7 @@ export function PipelineGraph({ columns, tasksByPhase, dependencyEdges, detailAg diff --git a/apps/web/src/components/pipeline/PipelinePhaseGroup.tsx b/apps/web/src/components/pipeline/PipelinePhaseGroup.tsx index 4daebc7..191c307 100644 --- a/apps/web/src/components/pipeline/PipelinePhaseGroup.tsx +++ b/apps/web/src/components/pipeline/PipelinePhaseGroup.tsx @@ -1,7 +1,11 @@ +import { useMemo } from "react"; import { Loader2, Play } from "lucide-react"; import { StatusDot } from "@/components/StatusDot"; import { trpc } from "@/lib/trpc"; -import { sortByPriorityAndQueueTime } from "@codewalk-district/shared"; +import { + topologicalSortPhases, + type DependencyEdge, +} from "@codewalk-district/shared"; import { PipelineTaskCard } from "./PipelineTaskCard"; import type { SerializedTask } from "@/components/TaskRow"; @@ -17,13 +21,37 @@ interface PipelinePhaseGroupProps { status: string; }; tasks: SerializedTask[]; + taskDepsRaw: Array<{ taskId: string; dependsOn: string[] }>; isBlocked: boolean; detailAgent?: DetailAgentInfo | null; } -export function PipelinePhaseGroup({ phase, tasks, isBlocked, detailAgent }: PipelinePhaseGroupProps) { +export function PipelinePhaseGroup({ phase, tasks, taskDepsRaw, isBlocked, detailAgent }: PipelinePhaseGroupProps) { const queuePhase = trpc.queuePhase.useMutation(); - const sorted = sortByPriorityAndQueueTime(tasks); + + // Sort tasks topologically by dependency order + const { sorted, blockedByCountMap } = useMemo(() => { + const edges: DependencyEdge[] = []; + for (const raw of taskDepsRaw) { + for (const depId of raw.dependsOn) { + edges.push({ phaseId: raw.taskId, dependsOnPhaseId: depId }); + } + } + const sortedTasks = topologicalSortPhases(tasks, edges); + + // Compute blocked-by counts (incomplete dependencies) + const countMap = new Map(); + for (const raw of taskDepsRaw) { + const incomplete = raw.dependsOn.filter((depId) => { + const dep = tasks.find((t) => t.id === depId); + return dep && dep.status !== "completed"; + }).length; + if (incomplete > 0) countMap.set(raw.taskId, incomplete); + } + + return { sorted: sortedTasks, blockedByCountMap: countMap }; + }, [tasks, taskDepsRaw]); + const canExecute = phase.status === "approved" && !isBlocked; const isDetailing = detailAgent?.status === "running" || @@ -62,7 +90,11 @@ export function PipelinePhaseGroup({ phase, tasks, isBlocked, detailAgent }: Pip

No tasks

) : ( sorted.map((task) => ( - + )) )} diff --git a/apps/web/src/components/pipeline/PipelineStageColumn.tsx b/apps/web/src/components/pipeline/PipelineStageColumn.tsx index 7b9fc03..78f7868 100644 --- a/apps/web/src/components/pipeline/PipelineStageColumn.tsx +++ b/apps/web/src/components/pipeline/PipelineStageColumn.tsx @@ -8,11 +8,12 @@ interface PipelineStageColumnProps { status: string; }>; tasksByPhase: Record; + taskDepsByPhase: Record>; blockedPhaseIds: Set; detailAgentByPhase?: Map; } -export function PipelineStageColumn({ phases, tasksByPhase, blockedPhaseIds, detailAgentByPhase }: PipelineStageColumnProps) { +export function PipelineStageColumn({ phases, tasksByPhase, taskDepsByPhase, blockedPhaseIds, detailAgentByPhase }: PipelineStageColumnProps) { return (
{phases.map((phase) => ( @@ -20,6 +21,7 @@ export function PipelineStageColumn({ phases, tasksByPhase, blockedPhaseIds, det key={phase.id} phase={phase} tasks={tasksByPhase[phase.id] ?? []} + taskDepsRaw={taskDepsByPhase[phase.id] ?? []} isBlocked={blockedPhaseIds.has(phase.id)} detailAgent={detailAgentByPhase?.get(phase.id) ?? null} /> diff --git a/apps/web/src/components/pipeline/PipelineTab.tsx b/apps/web/src/components/pipeline/PipelineTab.tsx index ea6fe12..db8326d 100644 --- a/apps/web/src/components/pipeline/PipelineTab.tsx +++ b/apps/web/src/components/pipeline/PipelineTab.tsx @@ -47,13 +47,20 @@ function PipelineTabInner({ initiativeId, phases, phasesLoading }: PipelineTabPr ); const allTasks = (tasksQuery.data ?? []) as SerializedTask[]; - // Fetch dependency edges + // Fetch dependency edges (phase-level) const depsQuery = trpc.listInitiativePhaseDependencies.useQuery( { initiativeId }, { enabled: phases.length > 0 }, ); const dependencyEdges: DependencyEdge[] = depsQuery.data ?? []; + // Fetch task-level dependency edges + const taskDepsQuery = trpc.listInitiativeTaskDependencies.useQuery( + { initiativeId }, + { enabled: phases.length > 0 }, + ); + const taskDepsRaw = taskDepsQuery.data ?? []; + // Fetch agents for detail agent tracking const agentsQuery = trpc.listAgents.useQuery(); const allAgents = agentsQuery.data ?? []; @@ -76,6 +83,23 @@ function PipelineTabInner({ initiativeId, phases, phasesLoading }: PipelineTabPr return map; }, [displayTasks]); + // Group task dependency edges by phaseId + const taskDepsByPhase = useMemo(() => { + const taskPhaseMap = new Map(); + for (const t of allTasks) { + if (t.phaseId) taskPhaseMap.set(t.id, t.phaseId); + } + const map: Record> = {}; + for (const edge of taskDepsRaw) { + const phaseId = taskPhaseMap.get(edge.taskId); + if (phaseId) { + if (!map[phaseId]) map[phaseId] = []; + map[phaseId].push(edge); + } + } + return map; + }, [allTasks, taskDepsRaw]); + // Map phaseId → most recent active detail agent const detailAgentByPhase = useMemo(() => { const map = new Map(); @@ -180,6 +204,7 @@ function PipelineTabInner({ initiativeId, phases, phasesLoading }: PipelineTabPr diff --git a/apps/web/src/components/pipeline/PipelineTaskCard.tsx b/apps/web/src/components/pipeline/PipelineTaskCard.tsx index 1035fb2..4c5b135 100644 --- a/apps/web/src/components/pipeline/PipelineTaskCard.tsx +++ b/apps/web/src/components/pipeline/PipelineTaskCard.tsx @@ -14,9 +14,10 @@ const statusConfig: Record {task.name} + {blockedByCount > 0 && ( + + blocked by {blockedByCount} + + )} {task.status === "pending" && (