- Add getActiveRefineAgent to spawn mutation optimistic updates and live event invalidation rules so the refine panel reflects agent state immediately without manual refresh - Accept optional instruction param in buildRefinePrompt() and inject it as <user_instruction> block so the agent knows what to focus on - Pass input.instruction through in architect router spawn call
373 lines
13 KiB
TypeScript
373 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}"`,
|
|
});
|
|
}
|
|
|
|
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,
|
|
},
|
|
});
|
|
}),
|
|
};
|
|
}
|