Skip to content

Commit aa3cc2e

Browse files
St0rmz1St0rmz1
andauthored
feat(code-review): manual council reviews (multi-specialist, per-model, single session) (#4569)
* feat(code-review): council manual-run backend, council_result persistence, selection glue * fix Problem: const reviewType = input.council ? 'council' : 'standard' keys only off the presence of the council object. CodeReviewCouncilConfigSchema allows specialists up to .max(8) with no minimum and defaults enabled to true, so an input as small as council: {} parses successfully to { enabled: true, specialists: [] }. That request is stamped review_type: 'council', passes through the enterprise entitlement gate (assertCouncilCreationAllowed), and is persisted as a council run — even though the canonical isCouncilActive predicate (packages/worker-utils/src/code-review-council.ts:526) requires enabled && enabledSpecialists >= COUNCIL_MIN_SPECIALISTS (2). This creates drift between how the row is labeled/gated and whether it can actually behave as a council. * feat(code-review): manual council New Job UI (specialist picker, per-model/effort, voting), gated * refactor(code-review): single source of truth for council finding schema * feat(code-review): single-session council execution (runtimeAgents, coordinator prompt, payload fork) * feat(code-review): capture council manifest, compute decision, persist council_result * refactor(code-review): parse manual config once in council finalize; document intentional entitlement fail-fast * feat(code-review): council results panel + council unit tests (prompt, finalize mapping) * refactor(code-review): drop unused councilSelectionsFromConfig until a seed-from-config flow exists * fix(code-review-council): address PR findings * fix(code-review-council): address second-round review findings - Persist council_result atomically in the analytics completion tx - Council manifest no longer collides with the analytics final-line marker - Specialists on their own model no longer inherit the base model's variant - Bound the inherited council base model to the runtime-agent limit - Keep council_result off hot full-row reads; load it only for the detail view - Remove the colliding 0185 council migration (regenerate as 0186 post-merge) * chore(db): regenerate council_result migration as 0186 after main merge Resolves the 0185 collision with origin/main's 0185_lowly_mandroid; the council_result column now lands on top of the merged baseline. * fix(council reviews) correct the sub agent guidance prompts for council reviews * perf(code-review-council): skip council_result query on in-flight polls * perf(code-review-council): gate entitlement query on rollout flag + non-local * chore: re-sign HEAD with verified identity --------- Co-authored-by: St0rmz1 <astorms@replicated.com>
1 parent cb39835 commit aa3cc2e

30 files changed

Lines changed: 37042 additions & 59 deletions

apps/web/src/app/(app)/code-reviews/[reviewId]/CodeReviewDetailClient.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button';
55
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
66
import { PageContainer } from '@/components/layouts/PageContainer';
77
import { CodeReviewStreamView } from '@/components/code-reviews/CodeReviewStreamView';
8+
import { CouncilGovernancePanel } from '@/components/code-reviews/CouncilGovernancePanel';
89
import { formatTokenCount } from '@/lib/code-reviews/summary/usage-footer';
910
import { getCodeReviewJobsHref } from '@/lib/code-reviews/code-review-links';
1011
import { ExternalLink, GitPullRequest, Loader2, ArrowLeft, RotateCcw, Ban } from 'lucide-react';
@@ -287,6 +288,14 @@ export function CodeReviewDetailClient({ reviewId }: CodeReviewDetailClientProps
287288
</CardContent>
288289
</Card>
289290

291+
{/* Council results (council runs only) */}
292+
{review.review_type === 'council' && (
293+
<CouncilGovernancePanel
294+
councilResult={review.council_result}
295+
awaitingResults={isInFlightReviewStatus(status)}
296+
/>
297+
)}
298+
290299
{/* Session log / live stream */}
291300
{showStreamView && (
292301
<CodeReviewStreamView

apps/web/src/app/(app)/organizations/[id]/code-reviews/ReviewAgentPageClient.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ import {
2525
} from 'lucide-react';
2626
import { useTRPC } from '@/lib/trpc/utils';
2727
import { useQuery } from '@tanstack/react-query';
28+
import { useFeatureFlagEnabled } from 'posthog-js/react';
29+
import { CODE_REVIEW_COUNCIL_FLAG } from '@/lib/code-reviews/core/council-selection';
2830
import Link from 'next/link';
2931
import { useRouter } from 'next/navigation';
3032
import { GitLabLogo } from '@/components/auth/GitLabLogo';
@@ -59,6 +61,20 @@ export function ReviewAgentPageClient({
5961
const router = useRouter();
6062
const selectedPlatform = initialPlatform;
6163

64+
// The council UI shows for `localMode || (entitled && rolloutFlag)`. Entitlement only
65+
// matters in the second branch, so skip the (DB-backed) entitlement query unless the
66+
// rollout flag is on AND we're not in local mode (local mode bypasses entitlement). This
67+
// avoids a per-page-load entitlement lookup for the users who can't see council anyway;
68+
// server-side creation still enforces entitlement regardless.
69+
const councilFlagEnabled = useFeatureFlagEnabled(CODE_REVIEW_COUNCIL_FLAG);
70+
const { data: councilEntitlement } = useQuery(
71+
trpc.organizations.reviewAgent.getCouncilEntitlement.queryOptions(
72+
{ organizationId },
73+
{ enabled: !localCodeReviewDevelopmentEnabled && !!councilFlagEnabled }
74+
)
75+
);
76+
const councilEntitled = councilEntitlement?.entitled ?? false;
77+
6278
const handlePlatformChange = (platform: Platform) => {
6379
const params = new URLSearchParams();
6480
if (platform !== 'github') {
@@ -292,6 +308,7 @@ export function ReviewAgentPageClient({
292308
localCodeReviewDevelopmentEnabled={localCodeReviewDevelopmentEnabled}
293309
defaultModelSlug={selectedConfigData?.modelSlug}
294310
defaultThinkingEffort={selectedConfigData?.thinkingEffort}
311+
councilEntitled={councilEntitled}
295312
/>
296313
) : (
297314
<Alert>
@@ -405,6 +422,7 @@ export function ReviewAgentPageClient({
405422
localCodeReviewDevelopmentEnabled={localCodeReviewDevelopmentEnabled}
406423
defaultModelSlug={selectedConfigData?.modelSlug}
407424
defaultThinkingEffort={selectedConfigData?.thinkingEffort}
425+
councilEntitled={councilEntitled}
408426
/>
409427
) : (
410428
<Alert>

apps/web/src/app/api/internal/code-review-status/[reviewId]/route.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,7 @@ function makeReview(overrides: Partial<CloudAgentCodeReview> = {}): CloudAgentCo
252252
manual_config: null,
253253
review_type: 'standard',
254254
trigger_source: null,
255+
council_result: null,
255256
model: null,
256257
total_tokens_in: null,
257258
total_tokens_out: null,

apps/web/src/app/api/internal/code-review-status/[reviewId]/route.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,10 @@ import type { Owner } from '@/lib/code-reviews/core';
9393
import { CodeReviewPlatformSchema, type CodeReviewPlatform } from '@/lib/code-reviews/core/schemas';
9494
import { parseCodeReviewAnalyticsManifest } from '@/lib/code-reviews/analytics/contracts';
9595
import { finalizeCompletedCodeReviewWithAnalytics } from '@/lib/code-reviews/analytics/db';
96+
import {
97+
computeCouncilResultForReview,
98+
finalizeCouncilResultForReview,
99+
} from '@/lib/code-reviews/council/finalize-council-result';
96100
import {
97101
getManualCodeReviewConfig,
98102
shouldPublishCodeReviewToProvider,
@@ -1052,6 +1056,13 @@ export async function POST(
10521056
executionId,
10531057
completedAt: callbackCompletedAt,
10541058
capture,
1059+
// Persist the council outcome atomically with the completion claim, so a council
1060+
// write failure can't leave a completed council review without a result (redelivery
1061+
// would short-circuit on the already-terminal parent). No-op for standard runs.
1062+
councilResult: computeCouncilResultForReview({
1063+
review,
1064+
lastAssistantMessageText: rawPayload.lastAssistantMessageText,
1065+
}),
10551066
});
10561067

10571068
if (completionResult.outcome !== 'applied') {
@@ -1321,6 +1332,21 @@ export async function POST(
13211332
status === 'cancelled' &&
13221333
isModelNotFoundCodeReviewTerminalReason(terminalReason, errorMessage);
13231334

1335+
// Non-analytics completion path only: persist the council outcome BEFORE marking the
1336+
// review completed. Writing it first means a `completed` council review always has a
1337+
// `council_result`; if this write fails it throws, the callback returns an error, and
1338+
// cloud-agent-next redelivers — retrying finalization — rather than leaving a completed
1339+
// run permanently without a result. The ANALYTICS path is handled above, where
1340+
// council_result is written atomically inside the completion transaction (its parent
1341+
// is already completed by the time control reaches here, so the redelivery-retry design
1342+
// would not hold on that path).
1343+
if (status === 'completed' && review.review_type === 'council' && !analyticsCompletionApplied) {
1344+
await finalizeCouncilResultForReview({
1345+
review,
1346+
lastAssistantMessageText: rawPayload.lastAssistantMessageText,
1347+
});
1348+
}
1349+
13241350
if (analyticsCompletionApplied) {
13251351
// Parent and accepted attempt completion were claimed with analytics in one transaction.
13261352
} else if (isModelNotFoundCancellation) {

apps/web/src/components/code-reviews/CodeReviewJobsCard.tsx

Lines changed: 131 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,24 @@ import { CodeReviewStreamView } from './CodeReviewStreamView';
4747
import { useOrganizationModels } from '@/components/cloud-agent/hooks/useOrganizationModels';
4848
import { ModelCombobox, type ModelOption } from '@/components/shared/ModelCombobox';
4949
import { PRIMARY_DEFAULT_MODEL } from '@/lib/ai-gateway/models';
50+
import { useFeatureFlagEnabled } from 'posthog-js/react';
51+
import { Switch } from '@/components/ui/switch';
52+
import {
53+
COUNCIL_AGGREGATION_STRATEGIES,
54+
type CouncilAggregationStrategy,
55+
} from '@kilocode/db/schema-types';
56+
import {
57+
COUNCIL_MIN_SPECIALISTS,
58+
formatAggregationStrategy,
59+
} from '@kilocode/worker-utils/code-review-council';
60+
import { CouncilSpecialistPicker } from './CouncilSpecialistPicker';
61+
import {
62+
CODE_REVIEW_COUNCIL_FLAG,
63+
buildCouncilSpecialists,
64+
countEnabledSelections,
65+
defaultCouncilSelections,
66+
type CouncilSpecialistSelection,
67+
} from '@/lib/code-reviews/core/council-selection';
5068
import {
5169
getAvailableThinkingEfforts,
5270
thinkingEffortLabel,
@@ -76,6 +94,8 @@ type CodeReviewJobsCardProps = {
7694
localCodeReviewDevelopmentEnabled?: boolean;
7795
defaultModelSlug?: string | null;
7896
defaultThinkingEffort?: string | null;
97+
/** Whether this owner is entitled to council (enterprise + active). */
98+
councilEntitled?: boolean;
7999
};
80100

81101
const PAGE_SIZE = 10;
@@ -167,6 +187,7 @@ export function CodeReviewJobsCard({
167187
localCodeReviewDevelopmentEnabled = false,
168188
defaultModelSlug,
169189
defaultThinkingEffort,
190+
councilEntitled = false,
170191
}: CodeReviewJobsCardProps) {
171192
const [expandedReviewId, setExpandedReviewId] = useState<string | null>(null);
172193
const [currentPage, setCurrentPage] = useState(1);
@@ -179,9 +200,23 @@ export function CodeReviewJobsCard({
179200
);
180201
const [manualJobThinkingEffort, setManualJobThinkingEffort] = useState<string | null>(null);
181202
const [manualJobInstructions, setManualJobInstructions] = useState('');
203+
const [manualJobCouncilEnabled, setManualJobCouncilEnabled] = useState(false);
204+
const [manualJobCouncilAggregation, setManualJobCouncilAggregation] =
205+
useState<CouncilAggregationStrategy>('any_blocking_member');
206+
const [manualJobCouncilSelections, setManualJobCouncilSelections] = useState<
207+
Record<string, CouncilSpecialistSelection>
208+
>(() => defaultCouncilSelections());
182209
const [manualJobSubmitted, setManualJobSubmitted] = useState(false);
183210
const [manualJobSubmitError, setManualJobSubmitError] = useState<string | null>(null);
184211

212+
// Council UI shows in local dev, or for entitled enterprise orgs with the rollout flag.
213+
const councilFlagEnabled = useFeatureFlagEnabled(CODE_REVIEW_COUNCIL_FLAG);
214+
const councilUiEnabled =
215+
localCodeReviewDevelopmentEnabled || (councilEntitled && !!councilFlagEnabled);
216+
const manualJobCouncilEnabledCount = countEnabledSelections(manualJobCouncilSelections);
217+
const manualJobCouncilBelowMin =
218+
manualJobCouncilEnabled && manualJobCouncilEnabledCount < COUNCIL_MIN_SPECIALISTS;
219+
185220
const trpc = useTRPC();
186221
const queryClient = useQueryClient();
187222
const router = useRouter();
@@ -264,7 +299,11 @@ export function CodeReviewJobsCard({
264299
? orgCreateManualReviewJobMutation.isPending
265300
: personalCreateManualReviewJobMutation.isPending;
266301
const manualJobSubmitDisabled =
267-
isManualJobSubmitting || isLoadingModels || modelOptions.length === 0 || !manualJobModelAllowed;
302+
isManualJobSubmitting ||
303+
isLoadingModels ||
304+
modelOptions.length === 0 ||
305+
!manualJobModelAllowed ||
306+
manualJobCouncilBelowMin;
268307

269308
useEffect(() => {
270309
if (
@@ -311,6 +350,9 @@ export function CodeReviewJobsCard({
311350
selectInitialManualJobThinkingEffort(defaultThinkingEffort, nextModelSlug)
312351
);
313352
setManualJobInstructions('');
353+
setManualJobCouncilEnabled(false);
354+
setManualJobCouncilAggregation('any_blocking_member');
355+
setManualJobCouncilSelections(defaultCouncilSelections());
314356
setManualJobSubmitted(false);
315357
setManualJobSubmitError(null);
316358
}
@@ -370,12 +412,27 @@ export function CodeReviewJobsCard({
370412
return;
371413
}
372414

415+
if (manualJobCouncilEnabled && manualJobCouncilEnabledCount < COUNCIL_MIN_SPECIALISTS) {
416+
setManualJobSubmitError(`Select at least ${COUNCIL_MIN_SPECIALISTS} council specialists.`);
417+
return;
418+
}
419+
420+
const council =
421+
councilUiEnabled && manualJobCouncilEnabled
422+
? {
423+
enabled: true,
424+
aggregation_strategy: manualJobCouncilAggregation,
425+
specialists: buildCouncilSpecialists(manualJobCouncilSelections),
426+
}
427+
: undefined;
428+
373429
const input = {
374430
platform,
375431
url: manualJobUrl.trim(),
376432
modelSlug: manualJobModelSlug,
377433
thinkingEffort: manualJobThinkingEffort,
378434
instructions: manualJobInstructions.trim() || undefined,
435+
council,
379436
};
380437

381438
if (organizationId) {
@@ -528,6 +585,79 @@ export function CodeReviewJobsCard({
528585
{MANUAL_INSTRUCTIONS_MAX_LENGTH} characters.
529586
</p>
530587
</div>
588+
589+
{councilUiEnabled && (
590+
<div className="space-y-3 rounded-md border p-3">
591+
<div className="flex items-start justify-between gap-3">
592+
<div className="grid gap-1 leading-none">
593+
<Label htmlFor="manual-code-review-council" className="font-medium">
594+
Council review
595+
</Label>
596+
<p className="text-muted-foreground text-sm">
597+
Run multiple specialists, each on its own model, and combine their votes.
598+
</p>
599+
</div>
600+
<Switch
601+
id="manual-code-review-council"
602+
checked={manualJobCouncilEnabled}
603+
onCheckedChange={setManualJobCouncilEnabled}
604+
disabled={isManualJobSubmitting}
605+
aria-label="Enable council review"
606+
/>
607+
</div>
608+
609+
{manualJobCouncilEnabled && (
610+
<div className="space-y-4">
611+
<CouncilSpecialistPicker
612+
selections={manualJobCouncilSelections}
613+
onChange={setManualJobCouncilSelections}
614+
modelOptions={modelOptions}
615+
isLoadingModels={isLoadingModels}
616+
disabled={isManualJobSubmitting}
617+
defaultModelSlug={manualJobModelSlug}
618+
modal
619+
/>
620+
621+
<div className="space-y-2">
622+
<Label htmlFor="manual-code-review-council-aggregation">
623+
Governance decision
624+
</Label>
625+
<Select
626+
value={manualJobCouncilAggregation}
627+
onValueChange={value =>
628+
setManualJobCouncilAggregation(value as CouncilAggregationStrategy)
629+
}
630+
disabled={isManualJobSubmitting}
631+
>
632+
<SelectTrigger
633+
id="manual-code-review-council-aggregation"
634+
className="w-full"
635+
>
636+
<SelectValue />
637+
</SelectTrigger>
638+
<SelectContent>
639+
{COUNCIL_AGGREGATION_STRATEGIES.map(strategy => (
640+
<SelectItem key={strategy} value={strategy}>
641+
{formatAggregationStrategy(strategy)}
642+
</SelectItem>
643+
))}
644+
</SelectContent>
645+
</Select>
646+
<p
647+
className={
648+
manualJobCouncilBelowMin
649+
? 'text-destructive text-sm'
650+
: 'text-muted-foreground text-sm'
651+
}
652+
>
653+
Select {COUNCIL_MIN_SPECIALISTS}–4 specialists.{' '}
654+
{manualJobCouncilEnabledCount} selected.
655+
</p>
656+
</div>
657+
</div>
658+
)}
659+
</div>
660+
)}
531661
</div>
532662
</form>
533663
<DialogFooter>

0 commit comments

Comments
 (0)