Pre-merge mergeability check via `git merge-tree --write-tree` (dry-run, no side effects). When conflicts exist the "Merge & Push" button is disabled and a ConflictResolutionPanel shows conflict files with options to resolve manually or spawn a conflict-resolution agent. Agent questions appear inline via QuestionForm; on completion the mergeability re-checks automatically. New server-side: MergeabilityResult type, BranchManager.checkMergeability, conflict-resolution prompt, checkInitiativeMergeability query, spawnConflictResolutionAgent mutation, getActiveConflictAgent query. New frontend: useConflictAgent hook, ConflictResolutionPanel component, mergeability badge + panel integration in InitiativeReview.
272 lines
8.9 KiB
TypeScript
272 lines
8.9 KiB
TypeScript
/**
|
|
* Agent Router — spawn, stop, delete, list, get, resume, result, questions, output
|
|
*/
|
|
|
|
import { TRPCError } from '@trpc/server';
|
|
import { z } from 'zod';
|
|
import { tracked, type TrackedEnvelope } from '@trpc/server';
|
|
import type { ProcedureBuilder } from '../trpc.js';
|
|
import type { TRPCContext } from '../context.js';
|
|
import type { AgentInfo, AgentResult, PendingQuestions } from '../../agent/types.js';
|
|
import type { AgentOutputEvent } from '../../events/types.js';
|
|
import { requireAgentManager, requireLogChunkRepository } from './_helpers.js';
|
|
|
|
export const spawnAgentInputSchema = z.object({
|
|
name: z.string().min(1).optional(),
|
|
taskId: z.string().min(1),
|
|
prompt: z.string().min(1),
|
|
cwd: z.string().optional(),
|
|
mode: z.enum(['execute', 'discuss', 'plan', 'detail', 'refine']).optional(),
|
|
provider: z.string().optional(),
|
|
initiativeId: z.string().min(1).optional(),
|
|
});
|
|
|
|
export type SpawnAgentInput = z.infer<typeof spawnAgentInputSchema>;
|
|
|
|
export const agentIdentifierSchema = z.object({
|
|
name: z.string().optional(),
|
|
id: z.string().optional(),
|
|
}).refine(data => data.name || data.id, {
|
|
message: 'Either name or id must be provided',
|
|
});
|
|
|
|
export type AgentIdentifier = z.infer<typeof agentIdentifierSchema>;
|
|
|
|
export const resumeAgentInputSchema = z.object({
|
|
name: z.string().optional(),
|
|
id: z.string().optional(),
|
|
answers: z.record(z.string(), z.string()),
|
|
}).refine(data => data.name || data.id, {
|
|
message: 'Either name or id must be provided',
|
|
});
|
|
|
|
export type ResumeAgentInput = z.infer<typeof resumeAgentInputSchema>;
|
|
|
|
async function resolveAgent(
|
|
ctx: TRPCContext,
|
|
input: { name?: string; id?: string }
|
|
): Promise<AgentInfo> {
|
|
if (!ctx.agentManager) {
|
|
throw new TRPCError({
|
|
code: 'INTERNAL_SERVER_ERROR',
|
|
message: 'Agent manager not available',
|
|
});
|
|
}
|
|
|
|
const agent = input.name
|
|
? await ctx.agentManager.getByName(input.name)
|
|
: await ctx.agentManager.get(input.id!);
|
|
|
|
if (!agent) {
|
|
throw new TRPCError({
|
|
code: 'NOT_FOUND',
|
|
message: `Agent '${input.name ?? input.id}' not found`,
|
|
});
|
|
}
|
|
|
|
return agent;
|
|
}
|
|
|
|
export function agentProcedures(publicProcedure: ProcedureBuilder) {
|
|
return {
|
|
spawnAgent: publicProcedure
|
|
.input(spawnAgentInputSchema)
|
|
.mutation(async ({ ctx, input }) => {
|
|
const agentManager = requireAgentManager(ctx);
|
|
return agentManager.spawn({
|
|
name: input.name,
|
|
taskId: input.taskId,
|
|
prompt: input.prompt,
|
|
cwd: input.cwd,
|
|
mode: input.mode,
|
|
provider: input.provider,
|
|
initiativeId: input.initiativeId,
|
|
});
|
|
}),
|
|
|
|
stopAgent: publicProcedure
|
|
.input(agentIdentifierSchema)
|
|
.mutation(async ({ ctx, input }) => {
|
|
const agentManager = requireAgentManager(ctx);
|
|
const agent = await resolveAgent(ctx, input);
|
|
await agentManager.stop(agent.id);
|
|
return { success: true, name: agent.name };
|
|
}),
|
|
|
|
deleteAgent: publicProcedure
|
|
.input(agentIdentifierSchema)
|
|
.mutation(async ({ ctx, input }) => {
|
|
const agentManager = requireAgentManager(ctx);
|
|
const agent = await resolveAgent(ctx, input);
|
|
await agentManager.delete(agent.id);
|
|
return { success: true, name: agent.name };
|
|
}),
|
|
|
|
dismissAgent: publicProcedure
|
|
.input(agentIdentifierSchema)
|
|
.mutation(async ({ ctx, input }) => {
|
|
const agentManager = requireAgentManager(ctx);
|
|
const agent = await resolveAgent(ctx, input);
|
|
await agentManager.dismiss(agent.id);
|
|
return { success: true, name: agent.name };
|
|
}),
|
|
|
|
listAgents: publicProcedure
|
|
.query(async ({ ctx }) => {
|
|
const agentManager = requireAgentManager(ctx);
|
|
return agentManager.list();
|
|
}),
|
|
|
|
getAgent: publicProcedure
|
|
.input(agentIdentifierSchema)
|
|
.query(async ({ ctx, input }) => {
|
|
return resolveAgent(ctx, input);
|
|
}),
|
|
|
|
getAgentByName: publicProcedure
|
|
.input(z.object({ name: z.string().min(1) }))
|
|
.query(async ({ ctx, input }) => {
|
|
const agentManager = requireAgentManager(ctx);
|
|
return agentManager.getByName(input.name);
|
|
}),
|
|
|
|
resumeAgent: publicProcedure
|
|
.input(resumeAgentInputSchema)
|
|
.mutation(async ({ ctx, input }) => {
|
|
const agentManager = requireAgentManager(ctx);
|
|
const agent = await resolveAgent(ctx, input);
|
|
await agentManager.resume(agent.id, input.answers);
|
|
return { success: true, name: agent.name };
|
|
}),
|
|
|
|
getAgentResult: publicProcedure
|
|
.input(agentIdentifierSchema)
|
|
.query(async ({ ctx, input }): Promise<AgentResult | null> => {
|
|
const agentManager = requireAgentManager(ctx);
|
|
const agent = await resolveAgent(ctx, input);
|
|
return agentManager.getResult(agent.id);
|
|
}),
|
|
|
|
getAgentQuestions: publicProcedure
|
|
.input(agentIdentifierSchema)
|
|
.query(async ({ ctx, input }): Promise<PendingQuestions | null> => {
|
|
const agentManager = requireAgentManager(ctx);
|
|
const agent = await resolveAgent(ctx, input);
|
|
return agentManager.getPendingQuestions(agent.id);
|
|
}),
|
|
|
|
listWaitingAgents: publicProcedure
|
|
.query(async ({ ctx }) => {
|
|
const agentManager = requireAgentManager(ctx);
|
|
const allAgents = await agentManager.list();
|
|
return allAgents.filter(agent => agent.status === 'waiting_for_input');
|
|
}),
|
|
|
|
getActiveRefineAgent: publicProcedure
|
|
.input(z.object({ initiativeId: z.string().min(1) }))
|
|
.query(async ({ ctx, input }): Promise<AgentInfo | null> => {
|
|
const agentManager = requireAgentManager(ctx);
|
|
const allAgents = await agentManager.list();
|
|
// Surface discuss and refine agents — both work on initiative content
|
|
const CONTENT_MODES = ['discuss', 'refine'];
|
|
const candidates = allAgents
|
|
.filter(
|
|
(a) =>
|
|
CONTENT_MODES.includes(a.mode) &&
|
|
a.initiativeId === input.initiativeId &&
|
|
['running', 'waiting_for_input', 'idle', 'crashed'].includes(a.status) &&
|
|
!a.userDismissedAt,
|
|
)
|
|
.sort(
|
|
(a, b) =>
|
|
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
|
);
|
|
return candidates[0] ?? null;
|
|
}),
|
|
|
|
getActiveConflictAgent: publicProcedure
|
|
.input(z.object({ initiativeId: z.string().min(1) }))
|
|
.query(async ({ ctx, input }): Promise<AgentInfo | null> => {
|
|
const agentManager = requireAgentManager(ctx);
|
|
const allAgents = await agentManager.list();
|
|
const candidates = allAgents
|
|
.filter(
|
|
(a) =>
|
|
a.mode === 'execute' &&
|
|
a.initiativeId === input.initiativeId &&
|
|
a.name?.startsWith('conflict-') &&
|
|
['running', 'waiting_for_input', 'idle', 'crashed'].includes(a.status) &&
|
|
!a.userDismissedAt,
|
|
)
|
|
.sort(
|
|
(a, b) =>
|
|
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
|
);
|
|
return candidates[0] ?? null;
|
|
}),
|
|
|
|
getAgentOutput: publicProcedure
|
|
.input(agentIdentifierSchema)
|
|
.query(async ({ ctx, input }): Promise<string> => {
|
|
const agent = await resolveAgent(ctx, input);
|
|
const logChunkRepo = requireLogChunkRepository(ctx);
|
|
|
|
const chunks = await logChunkRepo.findByAgentId(agent.id);
|
|
return chunks.map(c => c.content).join('');
|
|
}),
|
|
|
|
onAgentOutput: publicProcedure
|
|
.input(z.object({ agentId: z.string().min(1) }))
|
|
.subscription(async function* (opts): AsyncGenerator<TrackedEnvelope<{ agentId: string; data: string }>> {
|
|
const { agentId } = opts.input;
|
|
const signal = opts.signal ?? new AbortController().signal;
|
|
const eventBus = opts.ctx.eventBus;
|
|
|
|
let eventCounter = 0;
|
|
const queue: string[] = [];
|
|
let resolve: (() => void) | null = null;
|
|
|
|
const handler = (event: AgentOutputEvent) => {
|
|
if (event.payload.agentId !== agentId) return;
|
|
queue.push(event.payload.data);
|
|
if (resolve) {
|
|
const r = resolve;
|
|
resolve = null;
|
|
r();
|
|
}
|
|
};
|
|
|
|
eventBus.on('agent:output', handler);
|
|
|
|
const cleanup = () => {
|
|
eventBus.off('agent:output', handler);
|
|
if (resolve) {
|
|
const r = resolve;
|
|
resolve = null;
|
|
r();
|
|
}
|
|
};
|
|
|
|
signal.addEventListener('abort', cleanup, { once: true });
|
|
|
|
try {
|
|
while (!signal.aborted) {
|
|
while (queue.length > 0) {
|
|
const data = queue.shift()!;
|
|
const id = `${agentId}-live-${eventCounter++}`;
|
|
yield tracked(id, { agentId, data });
|
|
}
|
|
|
|
if (!signal.aborted) {
|
|
await new Promise<void>((r) => {
|
|
resolve = r;
|
|
});
|
|
}
|
|
}
|
|
} finally {
|
|
cleanup();
|
|
}
|
|
}),
|
|
};
|
|
}
|