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
This commit is contained in:
Lukas May
2026-03-04 10:18:26 +01:00
parent fcf822363c
commit 1aef986127
8 changed files with 94 additions and 11 deletions

View File

@@ -12,11 +12,12 @@ interface PipelineGraphProps {
createdAt: string | Date;
}>[];
tasksByPhase: Record<string, SerializedTask[]>;
taskDepsByPhase: Record<string, Array<{ taskId: string; dependsOn: string[] }>>;
dependencyEdges: DependencyEdge[];
detailAgentByPhase?: Map<string, DetailAgentInfo>;
}
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<string, string>();
@@ -50,6 +51,7 @@ export function PipelineGraph({ columns, tasksByPhase, dependencyEdges, detailAg
<PipelineStageColumn
phases={column.phases}
tasksByPhase={tasksByPhase}
taskDepsByPhase={taskDepsByPhase}
blockedPhaseIds={blockedPhaseIds}
detailAgentByPhase={detailAgentByPhase}
/>

View File

@@ -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<string, number>();
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
<p className="px-3 py-1 text-xs text-muted-foreground">No tasks</p>
) : (
sorted.map((task) => (
<PipelineTaskCard key={task.id} task={task} />
<PipelineTaskCard
key={task.id}
task={task}
blockedByCount={blockedByCountMap.get(task.id) ?? 0}
/>
))
)}
</div>

View File

@@ -8,11 +8,12 @@ interface PipelineStageColumnProps {
status: string;
}>;
tasksByPhase: Record<string, SerializedTask[]>;
taskDepsByPhase: Record<string, Array<{ taskId: string; dependsOn: string[] }>>;
blockedPhaseIds: Set<string>;
detailAgentByPhase?: Map<string, DetailAgentInfo>;
}
export function PipelineStageColumn({ phases, tasksByPhase, blockedPhaseIds, detailAgentByPhase }: PipelineStageColumnProps) {
export function PipelineStageColumn({ phases, tasksByPhase, taskDepsByPhase, blockedPhaseIds, detailAgentByPhase }: PipelineStageColumnProps) {
return (
<div className="flex w-64 shrink-0 flex-col gap-3">
{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}
/>

View File

@@ -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<string, string>();
for (const t of allTasks) {
if (t.phaseId) taskPhaseMap.set(t.id, t.phaseId);
}
const map: Record<string, Array<{ taskId: string; dependsOn: string[] }>> = {};
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<string, DetailAgentInfo>();
@@ -180,6 +204,7 @@ function PipelineTabInner({ initiativeId, phases, phasesLoading }: PipelineTabPr
<PipelineGraph
columns={columns}
tasksByPhase={tasksByPhase}
taskDepsByPhase={taskDepsByPhase}
dependencyEdges={dependencyEdges}
detailAgentByPhase={detailAgentByPhase}
/>

View File

@@ -14,9 +14,10 @@ const statusConfig: Record<string, { icon: typeof Clock; color: string; spin?: b
interface PipelineTaskCardProps {
task: SerializedTask;
blockedByCount?: number;
}
export function PipelineTaskCard({ task }: PipelineTaskCardProps) {
export function PipelineTaskCard({ task, blockedByCount = 0 }: PipelineTaskCardProps) {
const { setSelectedTaskId } = useExecutionContext();
const queueTask = trpc.queueTask.useMutation();
@@ -32,6 +33,11 @@ export function PipelineTaskCard({ task }: PipelineTaskCardProps) {
className={cn("h-3.5 w-3.5 shrink-0", config.color, config.spin && "animate-spin")}
/>
<span className="min-w-0 flex-1 truncate text-xs">{task.name}</span>
{blockedByCount > 0 && (
<span className="shrink-0 text-[10px] text-muted-foreground">
blocked by {blockedByCount}
</span>
)}
{task.status === "pending" && (
<button
className="shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"