Commit
add gavel: conclude a running debate straight to synthesis
commit
08befef
10 changed files with +177 and −16
Jump to a changed file
- src/app/api/debates/[id]/gavel/route.ts +25 −0
- src/components/debate/debate-console.tsx +11 −1
- src/components/debate/new-debate.tsx +20 −1
- src/core/convergence.ts +1 −1
- src/core/events.ts +8 −0
- src/core/orchestrator.test.ts +42 −2
- src/core/orchestrator.ts +30 −0
- src/lib/debate-registry.ts +28 −8
- src/lib/debate-runner.ts +5 −3
- 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 | } |