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 phaseId = (phaseInput?.data?.id as string) ?? null;
|
||||
const entries: CreateChangeSetEntryData[] = [];
|
||||
const fileIdToDbId = new Map<string, string>();
|
||||
|
||||
// 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({
|
||||
|
||||
@@ -13,7 +13,7 @@ ${CODEBASE_EXPLORATION}
|
||||
|
||||
<output_format>
|
||||
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
|
||||
</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)
|
||||
\`\`\`
|
||||
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>
|
||||
|
||||
<task_sizing>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
Reference in New Issue
Block a user