feat: Wire full request-changes flow for phase review

- Add PhaseChangesRequestedEvent to event bus
- Add requestChangesOnPhase() to ExecutionOrchestrator: reads unresolved
  comments, creates revision task (category='review'), resets phase to
  in_progress, queues task for dispatch
- Expand merge-skip and branch routing to include 'review' category so
  revision tasks work directly on the phase branch
- Add requestPhaseChanges tRPC procedure (reads comments from DB)
- Wire frontend: mutation replaces stub handler, window.prompt for
  optional summary, loading state on button
This commit is contained in:
Lukas May
2026-03-05 11:35:34 +01:00
parent 8ae3916c90
commit 7e0749ef17
7 changed files with 153 additions and 11 deletions

View File

@@ -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 {

View File

@@ -38,6 +38,7 @@ export type {
PhaseCompletedEvent,
PhaseBlockedEvent,
PhasePendingReviewEvent,
PhaseChangesRequestedEvent,
PhaseMergedEvent,
TaskMergedEvent,
MergeQueuedEvent,

View File

@@ -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

View File

@@ -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<string, typeof unresolvedComments>();
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 };
}
}

View File

@@ -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 };
}),
};
}

View File

@@ -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"
>
<X className="h-3 w-3" />
{isRequestingChanges ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<X className="h-3 w-3" />
)}
Request Changes
</Button>
<div className="relative" ref={confirmRef}>

View File

@@ -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}