2 Commits

Author SHA1 Message Date
Li Liu 6e31ec33ac test: drop obsolete summaries assertion after summary-step removal
The per-reviewer summary step was removed in 0f03726, dropping the
summaries field from DebateResult, but this test still asserted on it
and failed. Remove the stale assertion.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 14:28:30 -07:00
Li Liu f94808969f feat: add --fail-fast option to abort review/discuss on any reviewer failure
By default the orchestrator is resilient: a single reviewer (or context
gatherer) failure is logged and the round continues with the survivors,
aborting only when all reviewers fail.

The new --fail-fast flag flips to strict mode — any reviewer or
context-gathering failure re-throws immediately and terminates the
whole flow. Wired through the review and discuss commands via
OrchestratorOptions.failFast, with a regression test and README docs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 14:24:03 -07:00
7 changed files with 51 additions and 1 deletions
+16
View File
@@ -177,6 +177,7 @@ Options:
--git-remote <remote> Git remote for PR URL detection (default: origin)
--skip-context Skip context gathering phase
--no-post Skip post-processing (GitHub comment flow)
--fail-fast Abort the entire review immediately if any reviewer fails
--plan-only Generate review plan without executing
--reanalyze Force re-analyze features (ignore cache)
@@ -206,6 +207,7 @@ Options:
--reviewers <ids> Comma-separated reviewer IDs
-a, --all Use all configured reviewers
-d, --devil-advocate Add a Devil's Advocate to challenge consensus
--fail-fast Abort the entire discussion immediately if any reviewer fails
--list List all discuss sessions
--resume <id> Resume a discuss session with follow-up question
```
@@ -436,6 +438,20 @@ magpie review 12345 --no-converge
Set `defaults.check_convergence: false` in config to disable by default.
### Failure Handling
By default, Magpie is **resilient**: if a single reviewer fails (network error, rate limit, model unavailable), the round continues with the surviving reviewers and only aborts if *all* reviewers fail. The failed reviewer's slot shows `[Review failed: ...]` and is excluded from subsequent rounds.
Use `--fail-fast` to flip to strict mode — any single reviewer failure (or context-gathering failure) immediately terminates the entire flow with an error:
```bash
# Strict mode: abort the moment anything fails
magpie review 12345 --fail-fast
magpie discuss "Should we use microservices?" --fail-fast
```
Useful when you want to guarantee every configured reviewer participated, or when you're debugging provider/auth issues and don't want failures swallowed.
### Markdown Rendering
All outputs (analysis, reviewer comments, final conclusion) are rendered with proper markdown formatting in terminal - headers, bold, tables, code blocks all display correctly.
+3
View File
@@ -199,6 +199,7 @@ interface DiscussOptions {
list?: boolean
resume?: string
devilAdvocate?: boolean
failFast?: boolean
}
async function runDiscussion(
@@ -302,6 +303,7 @@ async function runDiscussion(
checkConvergence,
language: lang,
interruptState,
failFast: !!options.failFast,
onWaiting: (reviewerId) => {
flushBuffer()
if (spinnerRef.spinner) spinnerRef.spinner.stop()
@@ -443,6 +445,7 @@ export const discussCommand = new Command('discuss')
.option('--reviewers <ids>', 'Comma-separated reviewer IDs')
.option('-a, --all', 'Use all reviewers')
.option('-d, --devil-advocate', "Add a Devil's Advocate to challenge consensus")
.option('--fail-fast', 'Abort the entire discussion immediately if any reviewer fails')
.option('--list', 'List all discuss sessions')
.option('--resume <id>', 'Resume a discuss session')
.action(async (topic: string | undefined, options: DiscussOptions) => {
+2
View File
@@ -55,6 +55,7 @@ export const reviewCommand = new Command('review')
.option('--export <file>', 'Export completed review to markdown')
.option('--skip-context', 'Skip context gathering phase')
.option('--no-post', 'Skip post-processing (GitHub comment flow)')
.option('--fail-fast', 'Abort the entire review immediately if any reviewer (or context gatherer) fails')
.action(async (pr: string | undefined, options) => {
const spinner = ora('Loading configuration...').start()
@@ -433,6 +434,7 @@ export const reviewCommand = new Command('review')
checkConvergence,
language: config.defaults.language,
interruptState,
failFast: !!options.failFast,
onWaiting: (reviewerId) => {
// Flush previous reviewer's buffer before showing spinner
flushBuffer()
+7
View File
@@ -352,6 +352,9 @@ Then on the LAST line, respond with EXACTLY one word: CONVERGED or NOT_CONVERGED
const diff = this.extractDiffFromPrompt(prompt)
this.gatheredContext = await this.contextGatherer!.gather(diff, label, 'main')
} catch (error) {
if (this.options.failFast) {
throw new Error(`Context gathering failed (fail-fast): ${error instanceof Error ? error.message : String(error)}`)
}
logger.warn('Context gathering failed:', error)
}
})()
@@ -489,6 +492,10 @@ Then on the LAST line, respond with EXACTLY one word: CONVERGED or NOT_CONVERGED
duration: (endTime - startTime) / 1000
}
this.options.onParallelStatus?.(round, statuses)
if (this.options.failFast) {
// Re-throw so Promise.all rejects immediately and aborts the whole flow
throw new Error(`Reviewer ${reviewer.id} failed in round ${round} (fail-fast): ${err instanceof Error ? err.message : String(err)}`)
}
logger.warn(`Reviewer ${reviewer.id} failed in round ${round}:`, err)
return { reviewer, fullResponse: '', inputText: '', failed: true as const, error: err }
}
+1
View File
@@ -56,6 +56,7 @@ export interface OrchestratorOptions {
onPostAnalysisQA?: () => Promise<{ target: string; question: string } | null>
onContextGathered?: (context: GatheredContext) => void // Context gathering complete callback
interruptState?: { interrupted: boolean } // External interrupt signal (e.g., Ctrl+C)
failFast?: boolean // Abort the entire flow as soon as any reviewer (or context gatherer) fails
}
/** Structured issue from a reviewer */
@@ -63,4 +63,26 @@ describe('DebateOrchestrator resilience', () => {
await expect(orchestrator.runStreaming('test', 'Review this code'))
.rejects.toThrow('All reviewers failed')
})
it('should abort immediately when failFast is enabled and any reviewer fails', async () => {
const goodProvider = makeProvider('good', 'LGTM, no issues found.')
const badProvider = makeFailingProvider('bad')
const reviewers = [
makeReviewer('good-reviewer', goodProvider),
makeReviewer('bad-reviewer', badProvider),
]
const summarizer = makeReviewer('summarizer', makeProvider('sum', 'Final conclusion.'))
const analyzer = makeReviewer('analyzer', makeProvider('analyzer', 'Analysis done.'))
const orchestrator = new DebateOrchestrator(reviewers, summarizer, analyzer, {
maxRounds: 1,
interactive: false,
checkConvergence: false,
failFast: true,
})
await expect(orchestrator.runStreaming('test', 'Review this code'))
.rejects.toThrow(/bad-reviewer.*fail-fast/)
})
})
-1
View File
@@ -50,7 +50,6 @@ describe('DebateOrchestrator', () => {
expect(result.prNumber).toBe('123')
expect(result.analysis).toBe('PR analysis result')
expect(result.messages.length).toBe(4) // 2 reviewers * 2 rounds
expect(result.summaries.length).toBe(2)
expect(result.finalConclusion).toBe('Final conclusion')
})