Files
Codewalkers/apps/server/trpc/routers/architect.ts
Lukas May 346d62ef8d fix: prevent stale duplicate planning tasks from blocking phase completion
Three fixes for phases getting stuck when a detail task crashes and is retried:

1. detailPhase mutation (architect.ts): clean up orphaned pending/in_progress
   detail tasks before creating new ones, preventing duplicates at the source
2. orchestrator recovery: detect and complete stale duplicate planning tasks
   (same category+phase, one completed, one pending)
3. ensureBranch: catch "already exists" TOCTOU race instead of blocking phase
2026-03-06 21:44:26 +01:00

381 lines
13 KiB
TypeScript

/**
* Architect Router — discuss, plan, refine, detail spawn procedures
*/
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import type { ProcedureBuilder } from '../trpc.js';
import {
requireAgentManager,
requireInitiativeRepository,
requirePhaseRepository,
requirePageRepository,
requireTaskRepository,
} from './_helpers.js';
import {
buildDiscussPrompt,
buildPlanPrompt,
buildRefinePrompt,
buildDetailPrompt,
} from '../../agent/prompts/index.js';
import { isPlanningCategory } from '../../git/branch-naming.js';
import type { PhaseRepository } from '../../db/repositories/phase-repository.js';
import type { TaskRepository } from '../../db/repositories/task-repository.js';
import type { PageRepository } from '../../db/repositories/page-repository.js';
import type { Phase, Task } from '../../db/schema.js';
import type { PageForSerialization } from '../../agent/content-serializer.js';
export async function gatherInitiativeContext(
phaseRepo: PhaseRepository | undefined,
taskRepo: TaskRepository | undefined,
pageRepo: PageRepository | undefined,
initiativeId: string,
): Promise<{
phases: Array<Phase & { dependsOn?: string[] }>;
tasks: Task[];
pages: PageForSerialization[];
}> {
const [rawPhases, deps, initiativeTasks, pages] = await Promise.all([
phaseRepo?.findByInitiativeId(initiativeId) ?? [],
phaseRepo?.findDependenciesByInitiativeId(initiativeId) ?? [],
taskRepo?.findByInitiativeId(initiativeId) ?? [],
pageRepo?.findByInitiativeId(initiativeId) ?? [],
]);
// Merge dependencies into each phase as a dependsOn array
const depsByPhase = new Map<string, string[]>();
for (const dep of deps) {
const arr = depsByPhase.get(dep.phaseId) ?? [];
arr.push(dep.dependsOnPhaseId);
depsByPhase.set(dep.phaseId, arr);
}
const phases = rawPhases.map((ph) => ({
...ph,
dependsOn: depsByPhase.get(ph.id) ?? [],
}));
// Collect tasks from all phases (some tasks only have phaseId, not initiativeId)
const taskIds = new Set(initiativeTasks.map((t) => t.id));
const allTasks = [...initiativeTasks];
if (taskRepo) {
for (const ph of rawPhases) {
const phaseTasks = await taskRepo.findByPhaseId(ph.id);
for (const t of phaseTasks) {
if (!taskIds.has(t.id)) {
taskIds.add(t.id);
allTasks.push(t);
}
}
}
}
// Only include implementation tasks in agent context — planning tasks are irrelevant noise
const implementationTasks = allTasks.filter(t => !isPlanningCategory(t.category));
return { phases, tasks: implementationTasks, pages };
}
export function architectProcedures(publicProcedure: ProcedureBuilder) {
return {
spawnArchitectDiscuss: publicProcedure
.input(z.object({
name: z.string().min(1).optional(),
initiativeId: z.string().min(1),
context: z.string().optional(),
provider: z.string().optional(),
}))
.mutation(async ({ ctx, input }) => {
const agentManager = requireAgentManager(ctx);
const initiativeRepo = requireInitiativeRepository(ctx);
const taskRepo = requireTaskRepository(ctx);
const initiative = await initiativeRepo.findById(input.initiativeId);
if (!initiative) {
throw new TRPCError({
code: 'NOT_FOUND',
message: `Initiative '${input.initiativeId}' not found`,
});
}
const task = await taskRepo.create({
initiativeId: input.initiativeId,
name: `Discuss: ${initiative.name}`,
description: input.context ?? 'Gather context and requirements for initiative',
category: 'discuss',
status: 'in_progress',
});
const context = await gatherInitiativeContext(ctx.phaseRepository, ctx.taskRepository, ctx.pageRepository, input.initiativeId);
const prompt = buildDiscussPrompt();
return agentManager.spawn({
name: input.name,
taskId: task.id,
prompt,
mode: 'discuss',
provider: input.provider,
initiativeId: input.initiativeId,
inputContext: {
initiative,
pages: context.pages.length > 0 ? context.pages : undefined,
phases: context.phases.length > 0 ? context.phases : undefined,
tasks: context.tasks.length > 0 ? context.tasks : undefined,
},
});
}),
spawnArchitectPlan: publicProcedure
.input(z.object({
name: z.string().min(1).optional(),
initiativeId: z.string().min(1),
contextSummary: z.string().optional(),
provider: z.string().optional(),
}))
.mutation(async ({ ctx, input }) => {
const agentManager = requireAgentManager(ctx);
const initiativeRepo = requireInitiativeRepository(ctx);
const taskRepo = requireTaskRepository(ctx);
const initiative = await initiativeRepo.findById(input.initiativeId);
if (!initiative) {
throw new TRPCError({
code: 'NOT_FOUND',
message: `Initiative '${input.initiativeId}' not found`,
});
}
// Auto-dismiss stale plan agents
const allAgents = await agentManager.list();
const staleAgents = allAgents.filter(
(a) =>
a.mode === 'plan' &&
a.initiativeId === input.initiativeId &&
['crashed', 'idle'].includes(a.status) &&
!a.userDismissedAt,
);
for (const stale of staleAgents) {
await agentManager.dismiss(stale.id);
}
// Reject if a plan agent is already active for this initiative
const activePlanAgents = allAgents.filter(
(a) =>
a.mode === 'plan' &&
a.initiativeId === input.initiativeId &&
['running', 'waiting_for_input'].includes(a.status),
);
if (activePlanAgents.length > 0) {
throw new TRPCError({
code: 'CONFLICT',
message: 'A plan agent is already running for this initiative',
});
}
const task = await taskRepo.create({
initiativeId: input.initiativeId,
name: `Plan: ${initiative.name}`,
description: 'Plan initiative into phases',
category: 'plan',
status: 'in_progress',
});
const context = await gatherInitiativeContext(ctx.phaseRepository, ctx.taskRepository, ctx.pageRepository, input.initiativeId);
const prompt = buildPlanPrompt();
return agentManager.spawn({
name: input.name,
taskId: task.id,
prompt,
mode: 'plan',
provider: input.provider,
initiativeId: input.initiativeId,
inputContext: {
initiative,
pages: context.pages.length > 0 ? context.pages : undefined,
phases: context.phases.length > 0 ? context.phases : undefined,
tasks: context.tasks.length > 0 ? context.tasks : undefined,
},
});
}),
spawnArchitectRefine: publicProcedure
.input(z.object({
name: z.string().min(1).optional(),
initiativeId: z.string().min(1),
instruction: z.string().optional(),
provider: z.string().optional(),
}))
.mutation(async ({ ctx, input }) => {
const agentManager = requireAgentManager(ctx);
const initiativeRepo = requireInitiativeRepository(ctx);
const pageRepo = requirePageRepository(ctx);
const taskRepo = requireTaskRepository(ctx);
const initiative = await initiativeRepo.findById(input.initiativeId);
if (!initiative) {
throw new TRPCError({
code: 'NOT_FOUND',
message: `Initiative '${input.initiativeId}' not found`,
});
}
// Bug #10: Auto-dismiss stale (crashed/idle) refine agents before checking for active ones
const allAgents = await agentManager.list();
const staleAgents = allAgents.filter(
(a) =>
a.mode === 'refine' &&
a.initiativeId === input.initiativeId &&
['crashed', 'idle'].includes(a.status) &&
!a.userDismissedAt,
);
for (const stale of staleAgents) {
await agentManager.dismiss(stale.id);
}
// Bug #9: Prevent concurrent refine agents on the same initiative
const activeRefineAgents = allAgents.filter(
(a) =>
a.mode === 'refine' &&
a.initiativeId === input.initiativeId &&
['running', 'waiting_for_input'].includes(a.status),
);
if (activeRefineAgents.length > 0) {
throw new TRPCError({
code: 'CONFLICT',
message: `A refine agent is already running for this initiative`,
});
}
const pages = await pageRepo.findByInitiativeId(input.initiativeId);
if (pages.length === 0) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Initiative has no page content to refine',
});
}
const task = await taskRepo.create({
initiativeId: input.initiativeId,
name: `Refine: ${initiative.name}`,
description: input.instruction ?? 'Review and propose edits to initiative content',
category: 'refine',
status: 'in_progress',
});
const prompt = buildRefinePrompt(input.instruction);
return agentManager.spawn({
name: input.name,
taskId: task.id,
prompt,
mode: 'refine',
provider: input.provider,
initiativeId: input.initiativeId,
inputContext: { initiative, pages },
});
}),
spawnArchitectDetail: publicProcedure
.input(z.object({
name: z.string().min(1).optional(),
phaseId: z.string().min(1),
taskName: z.string().min(1).optional(),
context: z.string().optional(),
provider: z.string().optional(),
}))
.mutation(async ({ ctx, input }) => {
const agentManager = requireAgentManager(ctx);
const phaseRepo = requirePhaseRepository(ctx);
const taskRepo = requireTaskRepository(ctx);
const initiativeRepo = requireInitiativeRepository(ctx);
const phase = await phaseRepo.findById(input.phaseId);
if (!phase) {
throw new TRPCError({
code: 'NOT_FOUND',
message: `Phase '${input.phaseId}' not found`,
});
}
const initiative = await initiativeRepo.findById(phase.initiativeId);
if (!initiative) {
throw new TRPCError({
code: 'NOT_FOUND',
message: `Initiative '${phase.initiativeId}' not found`,
});
}
// Auto-dismiss stale detail agents for this phase
const allAgents = await agentManager.list();
const detailAgents = allAgents.filter(
(a) => a.mode === 'detail' && !a.userDismissedAt,
);
// Look up tasks to find which phase each detail agent targets
const activeForPhase: typeof detailAgents = [];
const staleForPhase: typeof detailAgents = [];
for (const agent of detailAgents) {
if (!agent.taskId) continue;
const agentTask = await taskRepo.findById(agent.taskId);
if (agentTask?.phaseId !== input.phaseId) continue;
if (['crashed', 'idle'].includes(agent.status)) {
staleForPhase.push(agent);
} else if (['running', 'waiting_for_input'].includes(agent.status)) {
activeForPhase.push(agent);
}
}
for (const stale of staleForPhase) {
await agentManager.dismiss(stale.id);
}
if (activeForPhase.length > 0) {
throw new TRPCError({
code: 'CONFLICT',
message: `A detail agent is already running for phase "${phase.name}"`,
});
}
// Clean up orphaned pending/in_progress detail tasks from previous failed attempts
const phaseTasks = await taskRepo.findByPhaseId(input.phaseId);
for (const t of phaseTasks) {
if (t.category === 'detail' && (t.status === 'pending' || t.status === 'in_progress')) {
await taskRepo.update(t.id, { status: 'completed', summary: 'Superseded by retry' });
}
}
const detailTaskName = input.taskName ?? `Detail: ${phase.name}`;
const task = await taskRepo.create({
phaseId: phase.id,
initiativeId: phase.initiativeId,
name: detailTaskName,
description: input.context ?? `Detail phase "${phase.name}" into executable tasks`,
category: 'detail',
status: 'in_progress',
});
const context = await gatherInitiativeContext(ctx.phaseRepository, ctx.taskRepository, ctx.pageRepository, phase.initiativeId);
const prompt = buildDetailPrompt();
return agentManager.spawn({
name: input.name,
taskId: task.id,
prompt,
mode: 'detail',
provider: input.provider,
initiativeId: phase.initiativeId,
inputContext: {
initiative,
phase,
task,
pages: context.pages.length > 0 ? context.pages : undefined,
phases: context.phases.length > 0 ? context.phases : undefined,
tasks: context.tasks.length > 0 ? context.tasks : undefined,
},
});
}),
};
}