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

@@ -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 }) => {

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"

View File

@@ -103,9 +103,11 @@ The initiative detail page has three tabs managed via local state (not URL param
### Pipeline Components (`src/components/pipeline/`)
| Component | Purpose |
|-----------|---------|
| `PipelineVisualization` | DAG visualization of phase pipeline |
| `PipelineNode` | Individual phase node in pipeline |
| `PipelineEdge` | Dependency edge between nodes |
| `PipelineTab` | Execution tab entry — fetches tasks, phase deps, task deps |
| `PipelineGraph` | Horizontal DAG of phase columns with connectors |
| `PipelineStageColumn` | Single depth column containing phase groups |
| `PipelinePhaseGroup` | Phase card with topologically-sorted task list |
| `PipelineTaskCard` | Individual task row with status icon, blocked-by count |
### Review Components (`src/components/review/`)
| Component | Purpose |

View File

@@ -81,6 +81,7 @@ Each procedure uses `require*Repository(ctx)` helpers that throw `TRPCError(INTE
| listPendingApprovals | query | Tasks with status=pending_approval |
| deleteTask | mutation | Delete a task by ID |
| listPhaseTaskDependencies | query | All task dependency edges for tasks in a phase |
| listInitiativeTaskDependencies | query | All task dependency edges for tasks in an initiative |
| approveTask | mutation | Approve and complete task |
### Initiatives