diff --git a/apps/server/agent/output-handler.ts b/apps/server/agent/output-handler.ts index d99b81e..057e20b 100644 --- a/apps/server/agent/output-handler.ts +++ b/apps/server/agent/output-handler.ts @@ -498,6 +498,7 @@ export class OutputHandler { const phaseInput = readFrontmatterFile(join(agentWorkdir, '.cw', 'input', 'phase.md')); const phaseId = (phaseInput?.data?.id as string) ?? null; const entries: CreateChangeSetEntryData[] = []; + const fileIdToDbId = new Map(); // Load existing tasks for dedup — prevents duplicates when multiple agents finish concurrently const existingTasks = phaseId ? await this.taskRepository.findByPhaseId(phaseId) : []; @@ -506,6 +507,9 @@ export class OutputHandler { for (const [i, t] of tasks.entries()) { if (existingNames.has(t.title)) { 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; } try { @@ -518,6 +522,7 @@ export class OutputHandler { category: (t.category as any) ?? 'execute', type: (t.type as any) ?? 'auto', }); + fileIdToDbId.set(t.id, created.id); existingNames.add(t.title); // prevent dupes within same agent output entries.push({ 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) { try { const cs = await this.changeSetRepository!.createWithEntries({ diff --git a/apps/server/agent/prompts/detail.ts b/apps/server/agent/prompts/detail.ts index 0181b14..2601aeb 100644 --- a/apps/server/agent/prompts/detail.ts +++ b/apps/server/agent/prompts/detail.ts @@ -13,7 +13,7 @@ ${CODEBASE_EXPLORATION} 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 @@ -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) \`\`\` 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. diff --git a/apps/server/db/repositories/change-set-repository.ts b/apps/server/db/repositories/change-set-repository.ts index 18e72d3..309aea7 100644 --- a/apps/server/db/repositories/change-set-repository.ts +++ b/apps/server/db/repositories/change-set-repository.ts @@ -16,7 +16,7 @@ export type CreateChangeSetData = { }; export type CreateChangeSetEntryData = { - entityType: 'page' | 'phase' | 'task' | 'phase_dependency'; + entityType: 'page' | 'phase' | 'task' | 'phase_dependency' | 'task_dependency'; entityId: string; action: 'create' | 'update' | 'delete'; previousState?: string | null; diff --git a/apps/server/db/schema.ts b/apps/server/db/schema.ts index 1d6ebde..f4cbfa4 100644 --- a/apps/server/db/schema.ts +++ b/apps/server/db/schema.ts @@ -339,7 +339,7 @@ export const changeSetEntries = sqliteTable('change_set_entries', { changeSetId: text('change_set_id') .notNull() .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(), action: text('action', { enum: ['create', 'update', 'delete'] }).notNull(), previousState: text('previous_state'), // JSON snapshot, null for creates diff --git a/apps/server/trpc/routers/task.ts b/apps/server/trpc/routers/task.ts index 5e29ec2..c0b5413 100644 --- a/apps/server/trpc/routers/task.ts +++ b/apps/server/trpc/routers/task.ts @@ -151,6 +151,19 @@ export function taskProcedures(publicProcedure: ProcedureBuilder) { 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 .input(z.object({ taskId: z.string().min(1) })) .mutation(async ({ ctx, input }) => { diff --git a/apps/web/src/components/execution/PhaseDetailPanel.tsx b/apps/web/src/components/execution/PhaseDetailPanel.tsx index 81bb657..5b05f0d 100644 --- a/apps/web/src/components/execution/PhaseDetailPanel.tsx +++ b/apps/web/src/components/execution/PhaseDetailPanel.tsx @@ -119,6 +119,23 @@ export function PhaseDetailPanel({ const depsQuery = trpc.getPhaseDependencies.useQuery({ phaseId: phase.id }); const dependencyIds = depsQuery.data?.dependencies ?? []; + // Task-level dependencies + const taskDepsQuery = trpc.listPhaseTaskDependencies.useQuery({ phaseId: phase.id }); + const taskDepsMap = useMemo(() => { + const map = new Map>(); + 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 const resolvedDeps = dependencyIds .map((depId) => phases.find((p) => p.id === depId)) @@ -139,11 +156,11 @@ export function PhaseDetailPanel({ task, phaseName: `Phase ${displayIndex}: ${phase.name}`, agentName: null, - blockedBy: [], + blockedBy: taskDepsMap.get(task.id) ?? [], dependents: [], })); 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 --- const changeSetsQuery = trpc.listChangeSets.useQuery( @@ -379,7 +396,7 @@ export function PhaseDetailPanel({ key={task.id} task={task} agentName={null} - blockedBy={[]} + blockedBy={taskDepsMap.get(task.id) ?? []} isLast={idx === sortedTasks.length - 1} onClick={() => setSelectedTaskId(task.id)} onDelete={() => deleteTask.mutate({ id: task.id })} diff --git a/apps/web/src/components/execution/PhaseWithTasks.tsx b/apps/web/src/components/execution/PhaseWithTasks.tsx index 4adaea3..6186694 100644 --- a/apps/web/src/components/execution/PhaseWithTasks.tsx +++ b/apps/web/src/components/execution/PhaseWithTasks.tsx @@ -1,4 +1,4 @@ -import { useEffect } from "react"; +import { useEffect, useMemo } from "react"; import { trpc } from "@/lib/trpc"; import { PhaseAccordion } from "@/components/PhaseAccordion"; import type { SerializedTask } from "@/components/TaskRow"; @@ -30,6 +30,7 @@ export function PhaseWithTasks({ }: PhaseWithTasksProps) { const tasksQuery = trpc.listPhaseTasks.useQuery({ phaseId: phase.id }); const depsQuery = trpc.getPhaseDependencies.useQuery({ phaseId: phase.id }); + const taskDepsQuery = trpc.listPhaseTaskDependencies.useQuery({ phaseId: phase.id }); const tasks = tasksQuery.data ?? []; @@ -39,6 +40,7 @@ export function PhaseWithTasks({ tasks={tasks} tasksLoaded={tasksQuery.isSuccess} phaseDependencyIds={depsQuery.data?.dependencies ?? []} + taskDependencyEdges={taskDepsQuery.data ?? []} defaultExpanded={defaultExpanded} onTaskClick={onTaskClick} onTaskCounts={onTaskCounts} @@ -52,6 +54,7 @@ interface PhaseWithTasksInnerProps { tasks: SerializedTask[]; tasksLoaded: boolean; phaseDependencyIds: string[]; + taskDependencyEdges: Array<{ taskId: string; dependsOn: string[] }>; defaultExpanded: boolean; onTaskClick: (taskId: string) => void; onTaskCounts: (phaseId: string, counts: TaskCounts) => void; @@ -63,11 +66,27 @@ function PhaseWithTasksInner({ tasks, tasksLoaded, phaseDependencyIds: _phaseDependencyIds, + taskDependencyEdges, defaultExpanded, onTaskClick, onTaskCounts, registerTasks, }: PhaseWithTasksInnerProps) { + // Build task dependency map + const taskDepsMap = useMemo(() => { + const map = new Map>(); + 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 useEffect(() => { const complete = tasks.filter( @@ -79,17 +98,17 @@ function PhaseWithTasksInner({ task, phaseName: phase.name, agentName: null, - blockedBy: [], + blockedBy: taskDepsMap.get(task.id) ?? [], dependents: [], })); registerTasks(phase.id, entries); - }, [tasks, phase.id, phase.name, onTaskCounts, registerTasks]); + }, [tasks, phase.id, phase.name, onTaskCounts, registerTasks, taskDepsMap]); const sortedTasks = sortByPriorityAndQueueTime(tasks); const taskEntries = sortedTasks.map((task) => ({ task, 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 }> = []; diff --git a/docs/agent.md b/docs/agent.md index 6c78cb4..eb2bff6 100644 --- a/docs/agent.md +++ b/docs/agent.md @@ -9,7 +9,7 @@ | `types.ts` | Core types: `AgentInfo`, `AgentManager` interface, `SpawnOptions`, `StreamEvent` | | `manager.ts` | `MultiProviderAgentManager` — main orchestrator class | | `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-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 | diff --git a/docs/server-api.md b/docs/server-api.md index 22d9a9d..faa5848 100644 --- a/docs/server-api.md +++ b/docs/server-api.md @@ -79,6 +79,7 @@ Each procedure uses `require*Repository(ctx)` helpers that throw `TRPCError(INTE | listPhaseTasks | query | All tasks for phase | | 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 | | approveTask | mutation | Approve and complete task | ### Initiatives