fix: Persist and expose task dependencies from detail output
Detail agents define task dependencies in YAML frontmatter but they were silently dropped — never written to the task_dependencies table. This caused all tasks to dispatch in parallel regardless of intended ordering, and the frontend showed no dependency information. - Add fileIdToDbId mapping and second-pass dependency creation in output-handler.ts (mirrors existing phase dependency pattern) - Add task_dependency to changeset entry entityType enum - Add listPhaseTaskDependencies tRPC procedure for batch querying - Wire blockedBy in PhaseDetailPanel and PhaseWithTasks from real data - Clarify dependency semantics in detail prompt
This commit is contained in:
@@ -498,6 +498,7 @@ export class OutputHandler {
|
|||||||
const phaseInput = readFrontmatterFile(join(agentWorkdir, '.cw', 'input', 'phase.md'));
|
const phaseInput = readFrontmatterFile(join(agentWorkdir, '.cw', 'input', 'phase.md'));
|
||||||
const phaseId = (phaseInput?.data?.id as string) ?? null;
|
const phaseId = (phaseInput?.data?.id as string) ?? null;
|
||||||
const entries: CreateChangeSetEntryData[] = [];
|
const entries: CreateChangeSetEntryData[] = [];
|
||||||
|
const fileIdToDbId = new Map<string, string>();
|
||||||
|
|
||||||
// Load existing tasks for dedup — prevents duplicates when multiple agents finish concurrently
|
// Load existing tasks for dedup — prevents duplicates when multiple agents finish concurrently
|
||||||
const existingTasks = phaseId ? await this.taskRepository.findByPhaseId(phaseId) : [];
|
const existingTasks = phaseId ? await this.taskRepository.findByPhaseId(phaseId) : [];
|
||||||
@@ -506,6 +507,9 @@ export class OutputHandler {
|
|||||||
for (const [i, t] of tasks.entries()) {
|
for (const [i, t] of tasks.entries()) {
|
||||||
if (existingNames.has(t.title)) {
|
if (existingNames.has(t.title)) {
|
||||||
log.info({ agentId, task: t.title, phaseId }, 'skipped duplicate task');
|
log.info({ agentId, task: t.title, phaseId }, 'skipped duplicate task');
|
||||||
|
// Map deduped file ID to existing DB ID for dependency resolution
|
||||||
|
const existing = existingTasks.find(et => et.name === t.title);
|
||||||
|
if (existing) fileIdToDbId.set(t.id, existing.id);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -518,6 +522,7 @@ export class OutputHandler {
|
|||||||
category: (t.category as any) ?? 'execute',
|
category: (t.category as any) ?? 'execute',
|
||||||
type: (t.type as any) ?? 'auto',
|
type: (t.type as any) ?? 'auto',
|
||||||
});
|
});
|
||||||
|
fileIdToDbId.set(t.id, created.id);
|
||||||
existingNames.add(t.title); // prevent dupes within same agent output
|
existingNames.add(t.title); // prevent dupes within same agent output
|
||||||
entries.push({
|
entries.push({
|
||||||
entityType: 'task',
|
entityType: 'task',
|
||||||
@@ -536,6 +541,29 @@ export class OutputHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Second pass: create task dependencies
|
||||||
|
let depSortOrder = entries.length;
|
||||||
|
for (const t of tasks) {
|
||||||
|
const taskDbId = fileIdToDbId.get(t.id);
|
||||||
|
if (!taskDbId || t.dependencies.length === 0) continue;
|
||||||
|
for (const depFileId of t.dependencies) {
|
||||||
|
const depDbId = fileIdToDbId.get(depFileId);
|
||||||
|
if (!depDbId) continue;
|
||||||
|
try {
|
||||||
|
await this.taskRepository.createDependency(taskDbId, depDbId);
|
||||||
|
entries.push({
|
||||||
|
entityType: 'task_dependency',
|
||||||
|
entityId: `${taskDbId}:${depDbId}`,
|
||||||
|
action: 'create',
|
||||||
|
newState: JSON.stringify({ taskId: taskDbId, dependsOnTaskId: depDbId }),
|
||||||
|
sortOrder: depSortOrder++,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
log.warn({ agentId, taskDbId, depFileId, err: err instanceof Error ? err.message : String(err) }, 'failed to create task dependency');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (entries.length > 0) {
|
if (entries.length > 0) {
|
||||||
try {
|
try {
|
||||||
const cs = await this.changeSetRepository!.createWithEntries({
|
const cs = await this.changeSetRepository!.createWithEntries({
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ ${CODEBASE_EXPLORATION}
|
|||||||
|
|
||||||
<output_format>
|
<output_format>
|
||||||
Write one file per task to \`.cw/output/tasks/{id}.md\`:
|
Write one file per task to \`.cw/output/tasks/{id}.md\`:
|
||||||
- Frontmatter: \`title\`, \`category\` (execute|research|discuss|plan|detail|refine|verify|merge|review), \`type\` (auto|checkpoint:human-verify|checkpoint:decision|checkpoint:human-action), \`dependencies\` (list of task IDs)
|
- Frontmatter: \`title\`, \`category\` (execute|research|discuss|plan|detail|refine|verify|merge|review), \`type\` (auto|checkpoint:human-verify|checkpoint:decision|checkpoint:human-action), \`dependencies\` (list of task IDs that must complete before this task can start)
|
||||||
- Body: Detailed task description
|
- Body: Detailed task description
|
||||||
</output_format>
|
</output_format>
|
||||||
|
|
||||||
@@ -59,6 +59,7 @@ Parallel tasks must not modify the same files. Include a file list per task:
|
|||||||
Files: src/db/schema/users.ts (create), src/db/migrations/001_users.sql (create)
|
Files: src/db/schema/users.ts (create), src/db/migrations/001_users.sql (create)
|
||||||
\`\`\`
|
\`\`\`
|
||||||
If two tasks touch the same file or one needs the other's output, add a dependency.
|
If two tasks touch the same file or one needs the other's output, add a dependency.
|
||||||
|
Tasks with no dependencies run in parallel. Add a dependency when one task needs another's output or modifies the same files.
|
||||||
</file_ownership>
|
</file_ownership>
|
||||||
|
|
||||||
<task_sizing>
|
<task_sizing>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export type CreateChangeSetData = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type CreateChangeSetEntryData = {
|
export type CreateChangeSetEntryData = {
|
||||||
entityType: 'page' | 'phase' | 'task' | 'phase_dependency';
|
entityType: 'page' | 'phase' | 'task' | 'phase_dependency' | 'task_dependency';
|
||||||
entityId: string;
|
entityId: string;
|
||||||
action: 'create' | 'update' | 'delete';
|
action: 'create' | 'update' | 'delete';
|
||||||
previousState?: string | null;
|
previousState?: string | null;
|
||||||
|
|||||||
@@ -339,7 +339,7 @@ export const changeSetEntries = sqliteTable('change_set_entries', {
|
|||||||
changeSetId: text('change_set_id')
|
changeSetId: text('change_set_id')
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => changeSets.id, { onDelete: 'cascade' }),
|
.references(() => changeSets.id, { onDelete: 'cascade' }),
|
||||||
entityType: text('entity_type', { enum: ['page', 'phase', 'task', 'phase_dependency'] }).notNull(),
|
entityType: text('entity_type', { enum: ['page', 'phase', 'task', 'phase_dependency', 'task_dependency'] }).notNull(),
|
||||||
entityId: text('entity_id').notNull(),
|
entityId: text('entity_id').notNull(),
|
||||||
action: text('action', { enum: ['create', 'update', 'delete'] }).notNull(),
|
action: text('action', { enum: ['create', 'update', 'delete'] }).notNull(),
|
||||||
previousState: text('previous_state'), // JSON snapshot, null for creates
|
previousState: text('previous_state'), // JSON snapshot, null for creates
|
||||||
|
|||||||
@@ -151,6 +151,19 @@ export function taskProcedures(publicProcedure: ProcedureBuilder) {
|
|||||||
return { success: true };
|
return { success: true };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
listPhaseTaskDependencies: publicProcedure
|
||||||
|
.input(z.object({ phaseId: z.string().min(1) }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const taskRepo = requireTaskRepository(ctx);
|
||||||
|
const tasks = await taskRepo.findByPhaseId(input.phaseId);
|
||||||
|
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
|
approveTask: publicProcedure
|
||||||
.input(z.object({ taskId: z.string().min(1) }))
|
.input(z.object({ taskId: z.string().min(1) }))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
|||||||
@@ -119,6 +119,23 @@ export function PhaseDetailPanel({
|
|||||||
const depsQuery = trpc.getPhaseDependencies.useQuery({ phaseId: phase.id });
|
const depsQuery = trpc.getPhaseDependencies.useQuery({ phaseId: phase.id });
|
||||||
const dependencyIds = depsQuery.data?.dependencies ?? [];
|
const dependencyIds = depsQuery.data?.dependencies ?? [];
|
||||||
|
|
||||||
|
// Task-level dependencies
|
||||||
|
const taskDepsQuery = trpc.listPhaseTaskDependencies.useQuery({ phaseId: phase.id });
|
||||||
|
const taskDepsMap = useMemo(() => {
|
||||||
|
const map = new Map<string, Array<{ name: string; status: string }>>();
|
||||||
|
const edges = taskDepsQuery.data ?? [];
|
||||||
|
for (const edge of edges) {
|
||||||
|
const blockers = edge.dependsOn
|
||||||
|
.map((depId) => {
|
||||||
|
const t = tasks.find((tk) => tk.id === depId);
|
||||||
|
return t ? { name: t.name, status: t.status } : null;
|
||||||
|
})
|
||||||
|
.filter(Boolean) as Array<{ name: string; status: string }>;
|
||||||
|
if (blockers.length > 0) map.set(edge.taskId, blockers);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [taskDepsQuery.data, tasks]);
|
||||||
|
|
||||||
// Resolve dependency IDs to phase objects
|
// Resolve dependency IDs to phase objects
|
||||||
const resolvedDeps = dependencyIds
|
const resolvedDeps = dependencyIds
|
||||||
.map((depId) => phases.find((p) => p.id === depId))
|
.map((depId) => phases.find((p) => p.id === depId))
|
||||||
@@ -139,11 +156,11 @@ export function PhaseDetailPanel({
|
|||||||
task,
|
task,
|
||||||
phaseName: `Phase ${displayIndex}: ${phase.name}`,
|
phaseName: `Phase ${displayIndex}: ${phase.name}`,
|
||||||
agentName: null,
|
agentName: null,
|
||||||
blockedBy: [],
|
blockedBy: taskDepsMap.get(task.id) ?? [],
|
||||||
dependents: [],
|
dependents: [],
|
||||||
}));
|
}));
|
||||||
handleRegisterTasks(phase.id, entries);
|
handleRegisterTasks(phase.id, entries);
|
||||||
}, [tasks, phase.id, displayIndex, phase.name, handleTaskCounts, handleRegisterTasks]);
|
}, [tasks, phase.id, displayIndex, phase.name, handleTaskCounts, handleRegisterTasks, taskDepsMap]);
|
||||||
|
|
||||||
// --- Change sets for detail agent ---
|
// --- Change sets for detail agent ---
|
||||||
const changeSetsQuery = trpc.listChangeSets.useQuery(
|
const changeSetsQuery = trpc.listChangeSets.useQuery(
|
||||||
@@ -379,7 +396,7 @@ export function PhaseDetailPanel({
|
|||||||
key={task.id}
|
key={task.id}
|
||||||
task={task}
|
task={task}
|
||||||
agentName={null}
|
agentName={null}
|
||||||
blockedBy={[]}
|
blockedBy={taskDepsMap.get(task.id) ?? []}
|
||||||
isLast={idx === sortedTasks.length - 1}
|
isLast={idx === sortedTasks.length - 1}
|
||||||
onClick={() => setSelectedTaskId(task.id)}
|
onClick={() => setSelectedTaskId(task.id)}
|
||||||
onDelete={() => deleteTask.mutate({ id: task.id })}
|
onDelete={() => deleteTask.mutate({ id: task.id })}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
import { trpc } from "@/lib/trpc";
|
import { trpc } from "@/lib/trpc";
|
||||||
import { PhaseAccordion } from "@/components/PhaseAccordion";
|
import { PhaseAccordion } from "@/components/PhaseAccordion";
|
||||||
import type { SerializedTask } from "@/components/TaskRow";
|
import type { SerializedTask } from "@/components/TaskRow";
|
||||||
@@ -30,6 +30,7 @@ export function PhaseWithTasks({
|
|||||||
}: PhaseWithTasksProps) {
|
}: PhaseWithTasksProps) {
|
||||||
const tasksQuery = trpc.listPhaseTasks.useQuery({ phaseId: phase.id });
|
const tasksQuery = trpc.listPhaseTasks.useQuery({ phaseId: phase.id });
|
||||||
const depsQuery = trpc.getPhaseDependencies.useQuery({ phaseId: phase.id });
|
const depsQuery = trpc.getPhaseDependencies.useQuery({ phaseId: phase.id });
|
||||||
|
const taskDepsQuery = trpc.listPhaseTaskDependencies.useQuery({ phaseId: phase.id });
|
||||||
|
|
||||||
const tasks = tasksQuery.data ?? [];
|
const tasks = tasksQuery.data ?? [];
|
||||||
|
|
||||||
@@ -39,6 +40,7 @@ export function PhaseWithTasks({
|
|||||||
tasks={tasks}
|
tasks={tasks}
|
||||||
tasksLoaded={tasksQuery.isSuccess}
|
tasksLoaded={tasksQuery.isSuccess}
|
||||||
phaseDependencyIds={depsQuery.data?.dependencies ?? []}
|
phaseDependencyIds={depsQuery.data?.dependencies ?? []}
|
||||||
|
taskDependencyEdges={taskDepsQuery.data ?? []}
|
||||||
defaultExpanded={defaultExpanded}
|
defaultExpanded={defaultExpanded}
|
||||||
onTaskClick={onTaskClick}
|
onTaskClick={onTaskClick}
|
||||||
onTaskCounts={onTaskCounts}
|
onTaskCounts={onTaskCounts}
|
||||||
@@ -52,6 +54,7 @@ interface PhaseWithTasksInnerProps {
|
|||||||
tasks: SerializedTask[];
|
tasks: SerializedTask[];
|
||||||
tasksLoaded: boolean;
|
tasksLoaded: boolean;
|
||||||
phaseDependencyIds: string[];
|
phaseDependencyIds: string[];
|
||||||
|
taskDependencyEdges: Array<{ taskId: string; dependsOn: string[] }>;
|
||||||
defaultExpanded: boolean;
|
defaultExpanded: boolean;
|
||||||
onTaskClick: (taskId: string) => void;
|
onTaskClick: (taskId: string) => void;
|
||||||
onTaskCounts: (phaseId: string, counts: TaskCounts) => void;
|
onTaskCounts: (phaseId: string, counts: TaskCounts) => void;
|
||||||
@@ -63,11 +66,27 @@ function PhaseWithTasksInner({
|
|||||||
tasks,
|
tasks,
|
||||||
tasksLoaded,
|
tasksLoaded,
|
||||||
phaseDependencyIds: _phaseDependencyIds,
|
phaseDependencyIds: _phaseDependencyIds,
|
||||||
|
taskDependencyEdges,
|
||||||
defaultExpanded,
|
defaultExpanded,
|
||||||
onTaskClick,
|
onTaskClick,
|
||||||
onTaskCounts,
|
onTaskCounts,
|
||||||
registerTasks,
|
registerTasks,
|
||||||
}: PhaseWithTasksInnerProps) {
|
}: PhaseWithTasksInnerProps) {
|
||||||
|
// Build task dependency map
|
||||||
|
const taskDepsMap = useMemo(() => {
|
||||||
|
const map = new Map<string, Array<{ name: string; status: string }>>();
|
||||||
|
for (const edge of taskDependencyEdges) {
|
||||||
|
const blockers = edge.dependsOn
|
||||||
|
.map((depId) => {
|
||||||
|
const t = tasks.find((tk) => tk.id === depId);
|
||||||
|
return t ? { name: t.name, status: t.status } : null;
|
||||||
|
})
|
||||||
|
.filter(Boolean) as Array<{ name: string; status: string }>;
|
||||||
|
if (blockers.length > 0) map.set(edge.taskId, blockers);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [taskDependencyEdges, tasks]);
|
||||||
|
|
||||||
// Propagate task counts and entries
|
// Propagate task counts and entries
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const complete = tasks.filter(
|
const complete = tasks.filter(
|
||||||
@@ -79,17 +98,17 @@ function PhaseWithTasksInner({
|
|||||||
task,
|
task,
|
||||||
phaseName: phase.name,
|
phaseName: phase.name,
|
||||||
agentName: null,
|
agentName: null,
|
||||||
blockedBy: [],
|
blockedBy: taskDepsMap.get(task.id) ?? [],
|
||||||
dependents: [],
|
dependents: [],
|
||||||
}));
|
}));
|
||||||
registerTasks(phase.id, entries);
|
registerTasks(phase.id, entries);
|
||||||
}, [tasks, phase.id, phase.name, onTaskCounts, registerTasks]);
|
}, [tasks, phase.id, phase.name, onTaskCounts, registerTasks, taskDepsMap]);
|
||||||
|
|
||||||
const sortedTasks = sortByPriorityAndQueueTime(tasks);
|
const sortedTasks = sortByPriorityAndQueueTime(tasks);
|
||||||
const taskEntries = sortedTasks.map((task) => ({
|
const taskEntries = sortedTasks.map((task) => ({
|
||||||
task,
|
task,
|
||||||
agentName: null as string | null,
|
agentName: null as string | null,
|
||||||
blockedBy: [] as Array<{ name: string; status: string }>,
|
blockedBy: taskDepsMap.get(task.id) ?? [] as Array<{ name: string; status: string }>,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const phaseDeps: Array<{ name: string; status: string }> = [];
|
const phaseDeps: Array<{ name: string; status: string }> = [];
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
| `types.ts` | Core types: `AgentInfo`, `AgentManager` interface, `SpawnOptions`, `StreamEvent` |
|
| `types.ts` | Core types: `AgentInfo`, `AgentManager` interface, `SpawnOptions`, `StreamEvent` |
|
||||||
| `manager.ts` | `MultiProviderAgentManager` — main orchestrator class |
|
| `manager.ts` | `MultiProviderAgentManager` — main orchestrator class |
|
||||||
| `process-manager.ts` | `AgentProcessManager` — worktree creation, command building, detached spawn |
|
| `process-manager.ts` | `AgentProcessManager` — worktree creation, command building, detached spawn |
|
||||||
| `output-handler.ts` | `OutputHandler` — JSONL stream parsing, completion detection, proposal creation, task dedup |
|
| `output-handler.ts` | `OutputHandler` — JSONL stream parsing, completion detection, proposal creation, task dedup, task dependency persistence |
|
||||||
| `file-tailer.ts` | `FileTailer` — watches output files, fires parser + raw content callbacks |
|
| `file-tailer.ts` | `FileTailer` — watches output files, fires parser + raw content callbacks |
|
||||||
| `file-io.ts` | Input/output file I/O: frontmatter writing, signal.json reading, tiptap conversion |
|
| `file-io.ts` | Input/output file I/O: frontmatter writing, signal.json reading, tiptap conversion |
|
||||||
| `markdown-to-tiptap.ts` | Markdown to Tiptap JSON conversion using MarkdownManager |
|
| `markdown-to-tiptap.ts` | Markdown to Tiptap JSON conversion using MarkdownManager |
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ Each procedure uses `require*Repository(ctx)` helpers that throw `TRPCError(INTE
|
|||||||
| listPhaseTasks | query | All tasks for phase |
|
| listPhaseTasks | query | All tasks for phase |
|
||||||
| listPendingApprovals | query | Tasks with status=pending_approval |
|
| listPendingApprovals | query | Tasks with status=pending_approval |
|
||||||
| deleteTask | mutation | Delete a task by ID |
|
| deleteTask | mutation | Delete a task by ID |
|
||||||
|
| listPhaseTaskDependencies | query | All task dependency edges for tasks in a phase |
|
||||||
| approveTask | mutation | Approve and complete task |
|
| approveTask | mutation | Approve and complete task |
|
||||||
|
|
||||||
### Initiatives
|
### Initiatives
|
||||||
|
|||||||
Reference in New Issue
Block a user