diff --git a/apps/server/dispatch/manager.ts b/apps/server/dispatch/manager.ts index 5637a15..0d87e9d 100644 --- a/apps/server/dispatch/manager.ts +++ b/apps/server/dispatch/manager.ts @@ -359,8 +359,8 @@ export class DefaultDispatchManager implements DispatchManager { const phase = await this.phaseRepository.findById(task.phaseId); if (phase) { - if (task.category === 'merge') { - // Merge tasks work directly on the phase branch + if (task.category === 'merge' || task.category === 'review') { + // Merge/review tasks work directly on the phase branch baseBranch = initBranch; branchName = phaseBranchName(initBranch, phase.name); } else { diff --git a/apps/server/events/index.ts b/apps/server/events/index.ts index 7cf572d..a8a691a 100644 --- a/apps/server/events/index.ts +++ b/apps/server/events/index.ts @@ -38,6 +38,7 @@ export type { PhaseCompletedEvent, PhaseBlockedEvent, PhasePendingReviewEvent, + PhaseChangesRequestedEvent, PhaseMergedEvent, TaskMergedEvent, MergeQueuedEvent, diff --git a/apps/server/events/types.ts b/apps/server/events/types.ts index 6ff9060..d73ad94 100644 --- a/apps/server/events/types.ts +++ b/apps/server/events/types.ts @@ -329,6 +329,16 @@ export interface PhasePendingReviewEvent extends DomainEvent { }; } +export interface PhaseChangesRequestedEvent extends DomainEvent { + type: 'phase:changes_requested'; + payload: { + phaseId: string; + initiativeId: string; + taskId: string; + commentCount: number; + }; +} + export interface PhaseMergedEvent extends DomainEvent { type: 'phase:merged'; payload: { @@ -595,6 +605,7 @@ export type DomainEventMap = | PhaseCompletedEvent | PhaseBlockedEvent | PhasePendingReviewEvent + | PhaseChangesRequestedEvent | PhaseMergedEvent | TaskMergedEvent | MergeQueuedEvent diff --git a/apps/server/execution/orchestrator.ts b/apps/server/execution/orchestrator.ts index de62157..9079f7e 100644 --- a/apps/server/execution/orchestrator.ts +++ b/apps/server/execution/orchestrator.ts @@ -11,7 +11,7 @@ * - Review per-phase: pause after each phase for diff review */ -import type { EventBus, TaskCompletedEvent, PhasePendingReviewEvent, PhaseMergedEvent, TaskMergedEvent, PhaseQueuedEvent, AgentStoppedEvent } from '../events/index.js'; +import type { EventBus, TaskCompletedEvent, PhasePendingReviewEvent, PhaseChangesRequestedEvent, PhaseMergedEvent, TaskMergedEvent, PhaseQueuedEvent, AgentStoppedEvent } from '../events/index.js'; import type { BranchManager } from '../git/branch-manager.js'; import type { PhaseRepository } from '../db/repositories/phase-repository.js'; import type { TaskRepository } from '../db/repositories/task-repository.js'; @@ -145,8 +145,8 @@ export class ExecutionOrchestrator { const phase = await this.phaseRepository.findById(task.phaseId); if (!phase) return; - // Skip merge tasks — they already work on the phase branch directly - if (task.category === 'merge') return; + // Skip merge/review tasks — they already work on the phase branch directly + if (task.category === 'merge' || task.category === 'review') return; const initBranch = initiative.branch; const phBranch = phaseBranchName(initBranch, phase.name); @@ -304,4 +304,84 @@ export class ExecutionOrchestrator { log.info({ phaseId }, 'phase review approved and merged'); } + + /** + * Request changes on a phase that's pending review. + * Creates a revision task, resets the phase to in_progress, and dispatches. + */ + async requestChangesOnPhase( + phaseId: string, + unresolvedComments: Array<{ filePath: string; lineNumber: number; body: string }>, + summary?: string, + ): Promise<{ taskId: string }> { + const phase = await this.phaseRepository.findById(phaseId); + if (!phase) throw new Error(`Phase not found: ${phaseId}`); + if (phase.status !== 'pending_review') { + throw new Error(`Phase ${phaseId} is not pending review (status: ${phase.status})`); + } + + const initiative = await this.initiativeRepository.findById(phase.initiativeId); + if (!initiative) throw new Error(`Initiative not found: ${phase.initiativeId}`); + + // Build revision task description from comments + summary + const lines: string[] = []; + if (summary) { + lines.push(`## Summary\n\n${summary}\n`); + } + if (unresolvedComments.length > 0) { + lines.push('## Review Comments\n'); + // Group comments by file + const byFile = new Map(); + for (const c of unresolvedComments) { + const arr = byFile.get(c.filePath) ?? []; + arr.push(c); + byFile.set(c.filePath, arr); + } + for (const [filePath, fileComments] of byFile) { + lines.push(`### ${filePath}\n`); + for (const c of fileComments) { + lines.push(`- **Line ${c.lineNumber}**: ${c.body}`); + } + lines.push(''); + } + } + + const description = lines.join('\n') || 'Address review feedback.'; + + // Create revision task + const task = await this.taskRepository.create({ + phaseId, + initiativeId: phase.initiativeId, + name: `Address review feedback: ${phase.name}`, + description, + category: 'review', + priority: 'high', + }); + + // Reset phase status back to in_progress + await this.phaseRepository.update(phaseId, { status: 'in_progress' as any }); + + // Queue task for dispatch + await this.dispatchManager.queue(task.id); + + // Emit event + const event: PhaseChangesRequestedEvent = { + type: 'phase:changes_requested', + timestamp: new Date(), + payload: { + phaseId, + initiativeId: phase.initiativeId, + taskId: task.id, + commentCount: unresolvedComments.length, + }, + }; + this.eventBus.emit(event); + + log.info({ phaseId, taskId: task.id, commentCount: unresolvedComments.length }, 'changes requested on phase'); + + // Kick off dispatch + this.scheduleDispatch(); + + return { taskId: task.id }; + } } diff --git a/apps/server/trpc/routers/phase.ts b/apps/server/trpc/routers/phase.ts index 6ce5fbf..ba1e138 100644 --- a/apps/server/trpc/routers/phase.ts +++ b/apps/server/trpc/routers/phase.ts @@ -341,5 +341,38 @@ export function phaseProcedures(publicProcedure: ProcedureBuilder) { } return comment; }), + + requestPhaseChanges: publicProcedure + .input(z.object({ + phaseId: z.string().min(1), + summary: z.string().optional(), + })) + .mutation(async ({ ctx, input }) => { + const orchestrator = requireExecutionOrchestrator(ctx); + const reviewCommentRepo = requireReviewCommentRepository(ctx); + + const allComments = await reviewCommentRepo.findByPhaseId(input.phaseId); + const unresolved = allComments + .filter((c: { resolved: boolean }) => !c.resolved) + .map((c: { filePath: string; lineNumber: number; body: string }) => ({ + filePath: c.filePath, + lineNumber: c.lineNumber, + body: c.body, + })); + + if (unresolved.length === 0 && !input.summary) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Add comments or a summary before requesting changes', + }); + } + + const result = await orchestrator.requestChangesOnPhase( + input.phaseId, + unresolved, + input.summary, + ); + return { success: true, taskId: result.taskId }; + }), }; } diff --git a/apps/web/src/components/review/ReviewHeader.tsx b/apps/web/src/components/review/ReviewHeader.tsx index f1a98a5..5f70a13 100644 --- a/apps/web/src/components/review/ReviewHeader.tsx +++ b/apps/web/src/components/review/ReviewHeader.tsx @@ -46,6 +46,7 @@ interface ReviewHeaderProps { unresolvedCount: number; onApprove: () => void; onRequestChanges: () => void; + isRequestingChanges?: boolean; preview: PreviewState | null; viewedCount?: number; totalCount?: number; @@ -63,6 +64,7 @@ export function ReviewHeader({ unresolvedCount, onApprove, onRequestChanges, + isRequestingChanges, preview, viewedCount, totalCount, @@ -186,9 +188,14 @@ export function ReviewHeader({ variant="outline" size="sm" onClick={onRequestChanges} + disabled={isRequestingChanges} className="h-8 text-xs px-3 border-status-error-border/50 text-status-error-fg hover:bg-status-error-bg/50 hover:border-status-error-border" > - + {isRequestingChanges ? ( + + ) : ( + + )} Request Changes
diff --git a/apps/web/src/components/review/ReviewTab.tsx b/apps/web/src/components/review/ReviewTab.tsx index 049e1d6..8fac959 100644 --- a/apps/web/src/components/review/ReviewTab.tsx +++ b/apps/web/src/components/review/ReviewTab.tsx @@ -225,12 +225,21 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) { approveMutation.mutate({ phaseId: activePhaseId }); }, [activePhaseId, approveMutation]); + const requestChangesMutation = trpc.requestPhaseChanges.useMutation({ + onSuccess: () => { + setStatus("changes_requested"); + toast.success("Changes requested — revision task dispatched"); + phasesQuery.refetch(); + }, + onError: (err) => toast.error(err.message), + }); + const handleRequestChanges = useCallback(() => { - setStatus("changes_requested"); - toast("Changes requested", { - description: "The agent will be notified about the requested changes.", - }); - }, []); + if (!activePhaseId) return; + const summary = window.prompt("Optional: describe what needs to change (leave blank for comments only)"); + if (summary === null) return; // cancelled + requestChangesMutation.mutate({ phaseId: activePhaseId, summary: summary || undefined }); + }, [activePhaseId, requestChangesMutation]); const handleFileClick = useCallback((filePath: string) => { const el = fileRefs.current.get(filePath); @@ -282,6 +291,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) { unresolvedCount={unresolvedCount} onApprove={handleApprove} onRequestChanges={handleRequestChanges} + isRequestingChanges={requestChangesMutation.isPending} preview={previewState} viewedCount={viewedFiles.size} totalCount={allFiles.length}