profileShare

rasmusjy / roundtable

Read-only snapshot

No repository description.

main default branch 181 files Expires Sep 13, 2026, 9:06 AM

Commit

add gavel: conclude a running debate straight to synthesis

commit 08befef

10 changed files with +177 and −16

Jump to a changed file
  1. src/app/api/debates/[id]/gavel/route.ts +25 −0
  2. src/components/debate/debate-console.tsx +11 −1
  3. src/components/debate/new-debate.tsx +20 −1
  4. src/core/convergence.ts +1 −1
  5. src/core/events.ts +8 −0
  6. src/core/orchestrator.test.ts +42 −2
  7. src/core/orchestrator.ts +30 −0
  8. src/lib/debate-registry.ts +28 −8
  9. src/lib/debate-runner.ts +5 −3
  10. src/lib/debate-view.ts +7 −0
added src/app/api/debates/[id]/gavel/route.ts +25 −0
@@ -0,0 +1,25 @@
1 +import { NextResponse } from 'next/server';
2 +import { currentUserId } from '@/auth';
3 +import { getDebateOwnership } from '@/db/repositories';
4 +import { gavelDebate } from '@/lib/debate-registry';
5 +
6 +export const runtime = 'nodejs';
7 +
8 +type Params = { params: Promise<{ id: string }> };
9 +
10 +/**
11 + * POST: conclude a running debate gracefully - skip remaining rounds and go
12 + * straight to synthesis (unlike cancel, which aborts and wastes the spend).
13 + */
14 +export async function POST(_request: Request, { params }: Params) {
15 + const { id } = await params;
16 + const ownership = await getDebateOwnership(id);
17 + if (!ownership) return NextResponse.json({ error: 'Not found' }, { status: 404 });
18 +
19 + const userId = await currentUserId();
20 + const canControl = ownership.userId === null || ownership.userId === userId;
21 + if (!canControl) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
22 +
23 + const struck = gavelDebate(id);
24 + return NextResponse.json({ ok: struck });
25 +}
modified src/components/debate/debate-console.tsx +11 −1
@@ -1,6 +1,6 @@
1 1 'use client';
2 2
3 -import { AlertTriangle, CircleCheckBig, PiggyBank, Scale } from 'lucide-react';
3 +import { AlertTriangle, CircleCheckBig, Gavel, PiggyBank, Scale } from 'lucide-react';
4 4 import { CostBreakdown } from '@/components/debate/cost-breakdown';
5 5 import { CostMeter } from '@/components/debate/cost-meter';
6 6 import { CritiqueMatrix } from '@/components/debate/critique-matrix';
@@ -51,6 +51,16 @@export function DebateConsole({ view, showActions = true }: { view: DebateView;
51 51 </div>
52 52 )}
53 53
54 + {view.gavelStruck && (
55 + <div className="flex items-center gap-2 rounded-lg border border-primary/30 bg-primary/5 p-3 text-sm">
56 + <Gavel className="h-4 w-4 text-primary" />
57 + <span>
58 + You concluded the debate after round {view.gavelStruck.round}; the chairman synthesized the answers as
59 + they stood.
60 + </span>
61 + </div>
62 + )}
63 +
54 64 {view.budgetReached && (
55 65 <div className="flex items-center gap-2 rounded-lg border border-amber-500/30 bg-amber-500/5 p-3 text-sm">
56 66 <PiggyBank className="h-4 w-4 text-amber-500" />
modified src/components/debate/new-debate.tsx +20 −1
@@ -1,6 +1,6 @@
1 1 'use client';
2 2
3 -import { AlertTriangle, KeyRound, Loader2, Play, RotateCcw, Save, Sparkles, Square } from 'lucide-react';
3 +import { AlertTriangle, Gavel, KeyRound, Loader2, Play, RotateCcw, Save, Sparkles, Square } from 'lucide-react';
4 4 import { useEffect, useMemo, useState } from 'react';
5 5 import { ApiKeyDialog } from '@/components/api-key-dialog';
6 6 import { DebateConsole } from '@/components/debate/debate-console';
@@ -191,6 +191,16 @@export function NewDebate({ fromDebateId }: { fromDebateId?: string }) {
191 191 }
192 192 };
193 193
194 + const concludeNow = async () => {
195 + if (!view.debateId) return;
196 + try {
197 + await fetch(`/api/debates/${view.debateId}/gavel`, { method: 'POST' });
198 + toast.message('Concluding: the chairman will synthesize the answers as they stand.');
199 + } catch {
200 + toast.error('Could not conclude the debate');
201 + }
202 + };
203 +
194 204 const launch = () =>
195 205 start({
196 206 question: question.trim(),
@@ -421,6 +431,15 @@export function NewDebate({ fromDebateId }: { fromDebateId?: string }) {
421 431 <Button variant="outline" size="sm" onClick={cancel}>
422 432 <Square className="h-3.5 w-3.5" /> Stop watching
423 433 </Button>
434 + <Button
435 + variant="outline"
436 + size="sm"
437 + onClick={concludeNow}
438 + disabled={!view.debateId || Boolean(view.gavelStruck)}
439 + title="Skip remaining rounds and have the chairman synthesize now"
440 + >
441 + <Gavel className="h-3.5 w-3.5" /> Conclude now
442 + </Button>
424 443 <Button variant="destructive" size="sm" onClick={cancelDebateRun}>
425 444 <Square className="h-3.5 w-3.5" /> Cancel debate
426 445 </Button>
modified src/core/convergence.ts +1 −1
@@ -6,7 +6,7 @@
6 6 * by the assessor model and the current health of the council.
7 7 */
8 8
9 -export type StopReason = 'converged' | 'max_rounds' | 'insufficient_models' | 'budget' | 'continue';
9 +export type StopReason = 'converged' | 'max_rounds' | 'insufficient_models' | 'budget' | 'gavel' | 'continue';
10 10
11 11 export interface StopDecision {
12 12 stop: boolean;
modified src/core/events.ts +8 −0
@@ -68,6 +68,14 @@export type DebateEvent =
68 68 totalCostUsd: number;
69 69 maxCostUsd: number;
70 70 }
71 + | {
72 + /**
73 + * The user concluded the debate early: remaining rounds are skipped and
74 + * synthesis runs on the answers as they stand. Live-only (not persisted).
75 + */
76 + type: 'gavel_struck';
77 + round: number;
78 + }
71 79 | {
72 80 type: 'cost_update';
73 81 totalCostUsd: number;
modified src/core/orchestrator.test.ts +42 −2
@@ -27,7 +27,12 @@interface RunHarness {
27 27 async function run(
28 28 config: DebateConfig,
29 29 scenario: MockScenario = {},
30 - opts: { signal?: AbortSignal; debateId?: string } = {},
30 + opts: {
31 + signal?: AbortSignal;
32 + gavelSignal?: AbortSignal;
33 + debateId?: string;
34 + onEvent?: (e: DebateEvent) => void;
35 + } = {},
31 36 ): Promise<RunHarness> {
32 37 const events: DebateEvent[] = [];
33 38 let clock = 1_000;
@@ -37,10 +42,11 @@async function run(
37 42 llm: new MockLlmClient({ latencyMs: 50, ...scenario }),
38 43 emit: (e) => {
39 44 events.push(e);
45 + opts.onEvent?.(e);
40 46 },
41 47 now: () => (clock += 10),
42 48 },
43 - { debateId: opts.debateId ?? 'debate-test', signal: opts.signal },
49 + { debateId: opts.debateId ?? 'debate-test', signal: opts.signal, gavelSignal: opts.gavelSignal },
44 50 );
45 51 return {
46 52 result,
@@ -173,6 +179,40 @@describe('runDebate - JSON repair', () => {
173 179 });
174 180 });
175 181
182 +describe('runDebate - gavel', () => {
183 + it('skips all rounds and synthesizes when the gavel is struck before round 1', async () => {
184 + const { result, of } = await run(makeConfig(), {}, { gavelSignal: AbortSignal.abort() });
185 + expect(result.status).toBe('completed');
186 + expect(result.rounds).toHaveLength(0);
187 + expect(result.synthesis).not.toBeNull();
188 + expect(of('gavel_struck')).toHaveLength(1);
189 + expect(of('gavel_struck')[0]!.round).toBe(0);
190 + });
191 +
192 + it('finishes the phase in flight, records the partial round, and synthesizes', async () => {
193 + const gavel = new AbortController();
194 + const { result, of } = await run(
195 + makeConfig(),
196 + {},
197 + {
198 + gavelSignal: gavel.signal,
199 + // Strike as soon as the first round-1 critique lands: the critique
200 + // phase finishes, revision and convergence are skipped.
201 + onEvent: (e) => {
202 + if (e.type === 'critique_completed' && e.round === 1) gavel.abort();
203 + },
204 + },
205 + );
206 + expect(result.status).toBe('completed');
207 + expect(result.rounds).toHaveLength(1);
208 + expect(result.rounds[0]!.critiques.length).toBeGreaterThan(0);
209 + expect(result.rounds[0]!.revisions).toHaveLength(0);
210 + expect(result.rounds[0]!.convergence).toBeNull();
211 + expect(result.synthesis).not.toBeNull();
212 + expect(of('gavel_struck')[0]!.round).toBe(1);
213 + });
214 +});
215 +
176 216 describe('runDebate - budget cap', () => {
177 217 it('skips remaining rounds and synthesizes when spend crosses the cap', async () => {
178 218 // Round-0 answers alone cost more than this, so no cycle should run.
modified src/core/orchestrator.ts +30 −0
@@ -83,6 +83,12 @@export interface RunOptions {
83 83 debateId: string;
84 84 /** Debate-level cancellation (server shutdown / explicit user cancel). */
85 85 signal?: AbortSignal;
86 + /**
87 + * Graceful early conclusion ("the gavel"): when fired, the run finishes the
88 + * phase in flight, skips everything else, and goes straight to synthesis -
89 + * unlike `signal`, which aborts the debate outright.
90 + */
91 + gavelSignal?: AbortSignal;
86 92 }
87 93
88 94 /** Mutable run state, threaded through the phase helpers. */
@@ -252,6 +258,13 @@export async function runDebate(
252 258
253 259 // === Rounds 1..maxRounds: critique -> revision -> convergence =========
254 260 let stopReason: StopReason = 'max_rounds';
261 + const gavelled = () => opts.gavelSignal?.aborted === true;
262 + const strikeGavel = async (round: number) => {
263 + stopReason = 'gavel';
264 + logger.info('gavel_struck', { round });
265 + await emit({ type: 'gavel_struck', round });
266 + };
267 +
255 268 for (let round = 1; round <= config.maxRounds; round++) {
256 269 // Spend cap: skip remaining rounds and let the chairman synthesize what
257 270 // exists - a cheaper answer instead of a wasted debate.
@@ -266,12 +279,29 @@export async function runDebate(
266 279 });
267 280 break;
268 281 }
282 + if (gavelled()) {
283 + await strikeGavel(round - 1);
284 + break;
285 + }
269 286 await emit({ type: 'round_started', round, kind: 'cycle' });
270 287
271 288 const critiques = await critiquePhase(round);
272 289 throwIfAborted(opts.signal);
290 + // The gavel is checked at phase boundaries so it takes effect within
291 + // seconds, not at the next round. A partial round is still recorded.
292 + if (gavelled()) {
293 + state.rounds.push({ round, critiques, revisions: [], convergence: null });
294 + await strikeGavel(round);
295 + break;
296 + }
273 297 await revisionPhase(round, critiques);
274 298 throwIfAborted(opts.signal);
299 + if (gavelled()) {
300 + state.rounds.push({ round, critiques, revisions: state.pendingRevisions ?? [], convergence: null });
301 + state.pendingRevisions = undefined;
302 + await strikeGavel(round);
303 + break;
304 + }
275 305 const convergence = await convergencePhase(round);
276 306 await emitCost();
277 307
modified src/lib/debate-registry.ts +28 −8
@@ -1,15 +1,23 @@
1 1 /**
2 - * Registry of in-flight debates so an explicit "cancel" request can abort a run
3 - * server-side.
2 + * Registry of in-flight debates so explicit user controls can reach a run
3 + * server-side:
4 + * - cancel: abort everything (the run ends as 'aborted')
5 + * - gavel: conclude gracefully - finish the current phase, skip remaining
6 + * rounds, and go straight to synthesis
4 7 *
5 8 * Note this is intentionally separate from client disconnect: a browser going
6 9 * away does NOT abort the debate (graceful resume), but hitting the cancel
7 10 * endpoint does. Process-local, which is fine for the single-VPS deployment.
8 11 */
9 -const running = new Map<string, AbortController>();
12 +interface RunningDebate {
13 + abort: AbortController;
14 + gavel: AbortController;
15 +}
16 +
17 +const running = new Map<string, RunningDebate>();
10 18
11 -export function registerDebate(id: string, controller: AbortController): void {
12 - running.set(id, controller);
19 +export function registerDebate(id: string, handles: RunningDebate): void {
20 + running.set(id, handles);
13 21 }
14 22
15 23 export function unregisterDebate(id: string): void {
@@ -22,9 +30,21 @@export function isDebateRunning(id: string): boolean {
22 30
23 31 /** Abort a running debate. Returns false if it wasn't running here. */
24 32 export function cancelDebate(id: string): boolean {
25 - const controller = running.get(id);
26 - if (!controller) return false;
27 - controller.abort(new Error('Debate cancelled by user'));
33 + const handles = running.get(id);
34 + if (!handles) return false;
35 + handles.abort.abort(new Error('Debate cancelled by user'));
28 36 running.delete(id);
29 37 return true;
30 38 }
39 +
40 +/**
41 + * Conclude a running debate early: remaining rounds are skipped and the
42 + * chairman synthesizes what exists. The debate stays registered - it is still
43 + * running (synthesis) until the run loop unregisters it.
44 + */
45 +export function gavelDebate(id: string): boolean {
46 + const handles = running.get(id);
47 + if (!handles) return false;
48 + handles.gavel.abort(new Error('Gavel: conclude now'));
49 + return true;
50 +}
modified src/lib/debate-runner.ts +5 −3
@@ -53,9 +53,11 @@export async function startDebateStream(
53 53 ? new MockLlmClient()
54 54 : createOpenRouterClient({ apiKey: key.apiKey, pricing: await getPricing() });
55 55
56 - // Abort controller for explicit server-side cancellation (not client disconnect).
56 + // Abort controller for explicit server-side cancellation (not client
57 + // disconnect) plus a gavel signal for graceful early conclusion.
57 58 const abort = new AbortController();
58 - registerDebate(debateId, abort);
59 + const gavel = new AbortController();
60 + registerDebate(debateId, { abort, gavel });
59 61
60 62 let closed = false;
61 63 let heartbeat: ReturnType<typeof setInterval> | undefined;
@@ -88,7 +90,7 @@export async function startDebateStream(
88 90 const result = await runDebate(
89 91 args.config,
90 92 { llm, emit, logger: serverLogger },
91 - { debateId, signal: abort.signal },
93 + { debateId, signal: abort.signal, gavelSignal: gavel.signal },
92 94 );
93 95 await finalizeDebate(debateId, result);
94 96 } catch (err) {
modified src/lib/debate-view.ts +7 −0
@@ -53,6 +53,8 @@export interface DebateView {
53 53 droppedParticipants: string[];
54 54 /** Set when the spend cap cut the debate short of its configured rounds. */
55 55 budgetReached: { round: number; totalCostUsd: number; maxCostUsd: number } | null;
56 + /** Set when the user concluded the debate early (live streams only). */
57 + gavelStruck: { round: number } | null;
56 58 error?: string;
57 59 }
58 60
@@ -77,6 +79,7 @@export function initialDebateView(question = ''): DebateView {
77 79 working: {},
78 80 droppedParticipants: [],
79 81 budgetReached: null,
82 + gavelStruck: null,
80 83 };
81 84 }
82 85
@@ -215,6 +218,9 @@export function applyEvent(prev: DebateView, event: DebateEvent): DebateView {
215 218 budgetReached: { round: event.round, totalCostUsd: event.totalCostUsd, maxCostUsd: event.maxCostUsd },
216 219 };
217 220
221 + case 'gavel_struck':
222 + return { ...prev, gavelStruck: { round: event.round } };
223 +
218 224 case 'debate_completed':
219 225 return {
220 226 ...prev,
@@ -272,6 +278,7 @@export function fromResult(result: DebateResult): DebateView {
272 278 maxCostUsd: result.config.maxCostUsd,
273 279 }
274 280 : null,
281 + gavelStruck: null,
275 282 ...(result.error ? { error: result.error } : {}),
276 283 };
277 284 }