Skip to content

Commit facb36c

Browse files
fix(cli): distinguish degraded judge/synthesis runs from a clean pass (DSE-901) (#60)
Adversarial (and synthesize/debate) mode exited 0 even when the judge/ synthesizer call failed while members still answered -- a verification gate reading only the exit code (e.g. /conclave-verify) could not tell a partial run from a clean pass. This reproduced live on 2026-07-25: an Anthropic credit failure took out the claude judge while 4/5 members answered, adversarial.verdict came back null, and the CLI exited 0. CouncilResult gains a computed `degraded` field (True when synthesis_error or adversarial.verdict_error is set), serialized as a top-level JSON key. The CLI now exits a distinct code 3 for a degraded run, keeping the existing 0 (clean)/1 (hard failure: zero usable answers or Elite not ready)/2 (usage error) meanings unchanged. Judge/synthesizer failover to another provider is explicitly out of scope for this fix (follow-up).
1 parent eb1d845 commit facb36c

6 files changed

Lines changed: 314 additions & 9 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1313
persists the complete `CouncilResult` JSON with user-private temporary-file semantics
1414
while preserving stdout and exit-code behavior. Persistence remains opt-in and is
1515
rejected with streaming; write failures retain normal stdout before exiting 1.
16+
- **Degraded-run exit code (DSE-901).** `CouncilResult.degraded` is a new computed
17+
field, `True` whenever `synthesis_error` or `adversarial.verdict_error` is set --
18+
i.e. at least one member answered but the judge/synthesizer step failed. The CLI
19+
now exits a distinct code `3` in that case (`--json` still emits the full payload,
20+
now including `"degraded"`), so a verification gate reading only the exit code can
21+
no longer mistake a partial run for a clean pass. This closes the gap found
22+
2026-07-25: an Anthropic credit failure took out the `claude` judge while 4/5
23+
members still answered, `adversarial.verdict` came back `null`, and the CLI still
24+
exited `0`. Judge/synthesizer failover to another provider remains explicitly out
25+
of scope for this fix (tracked separately).
26+
27+
### Fixed
28+
29+
- **Adversarial mode's `--json` gate contract (DSE-901).** Previously a judge/
30+
synthesizer failure in `adversarial` (or `synthesize`/`debate`) mode was only
31+
observable by inspecting `synthesis_error`/`adversarial.verdict_error` in the
32+
payload; the exit code stayed `0`. See "Degraded-run exit code" above.
1633

1734
## [1.2.0] - 2026-07-18
1835

‎DOCUMENTATION_INDEX.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ Package root: `src/conclave/` (installed as the `conclave` package; console scri
8282
| Adapter tests | [`tests/test_adapters.py`](tests/test_adapters.py) | Per-adapter `build_request` + `parse_response` for openai-compat/anthropic/gemini: system-hoist, max_tokens, role mapping, usage parsing, empty/malformed/error-status raises. |
8383
| Provider highway tests | [`tests/test_providers.py`](tests/test_providers.py) | `resolve_adapter` (built-in prefixes, per-provider URLs, custom endpoints, unknown-prefix raise), end-to-end `call_model`, and `redact()` (bearer/`sk-`/env-var-value/`x-api-key` scrubbing; pre-redacted provider errors). |
8484
| Registry/config tests | [`tests/test_registry_config.py`](tests/test_registry_config.py) | Name resolution, key-presence logic, config merge. |
85-
| CLI tests | [`tests/test_cli.py`](tests/test_cli.py) | Typer `CliRunner`: exit-code contract (0 usable result and ready Elite / 1 zero-usable or non-ready Elite / 2 usage error), `--json` payload + exit code, human renderers per mode, `providers` table never prints secrets, aclose lifecycle. |
85+
| CLI tests | [`tests/test_cli.py`](tests/test_cli.py) | Typer `CliRunner`: exit-code contract (0 usable result and ready Elite / 1 zero-usable or non-ready Elite / 2 usage error / 3 degraded — usable answers but judge/synthesizer failed, DSE-901), `--json` payload (including `degraded`) + exit code, human renderers per mode, `providers` table never prints secrets, aclose lifecycle. |
8686
| Eval tests | [`tests/evals/`](tests/evals/) | Frozen matrix, offline replay, live reservation/checkpoint/resume gates, blinding, scoring, reporting, and CLI assertions. |
8787
| Transport tests | [`tests/test_transport.py`](tests/test_transport.py) | `post_json` via httpx `MockTransport`: success/error-status/non-JSON fallback, timeout & connect/HTTP errors → `TransportError` (key never leaks), client reuse/pooling, aclose idempotency. |
8888
| Streaming tests | [`tests/test_streaming.py`](tests/test_streaming.py) | Per-adapter SSE via `MockTransport` (openai-compat/anthropic/gemini): incremental chunks + assembled answer == concatenation == buffered result; mid-stream malformed-frame/connection-drop/non-2xx → error set with partial text preserved (never raises); key redaction in stream errors; buffered `ask()` never opens a stream; `Council.ask_stream` interleaving + terminal `done` shape; CLI `--stream` smoke + exit-code contract + debate rejection; `--stream` + cache one-shot replay. |

‎src/conclave/cli.py‎

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -436,6 +436,15 @@ def on_event(event: StreamEvent) -> None:
436436
# round-over-round, so the default is conservative and rarely fires spuriously.
437437
_DEFAULT_CONVERGE_THRESHOLD = 0.95
438438

439+
# Distinct exit code for a "degraded" run (DSE-901): at least one member
440+
# answered, but the judge/synthesizer step failed (``CouncilResult.degraded``).
441+
# Deliberately NOT 2 -- that code already means "usage/config error" (unknown
442+
# mode, unresolved council) below, and colliding the two would reintroduce the
443+
# same ambiguity this change fixes. Kept below the existing 0/1/2 meanings so a
444+
# caller doing a bare ``echo $?`` can add one new branch without reinterpreting
445+
# any exit code it already handles.
446+
_DEGRADED_EXIT_CODE = 3
447+
439448

440449
def _resolve_converge_threshold(
441450
converge: bool | None,
@@ -555,14 +564,27 @@ def ask(
555564
556565
Exit codes:
557566
558-
* 0 -- the run produced at least one usable member answer and, for Elite,
567+
* 0 -- the run produced at least one usable member answer, the
568+
judge/synthesizer step (when attempted) succeeded, and for Elite,
559569
``decision_readiness`` is ``ready``.
560570
* 1 -- the run produced zero usable member answers (e.g. no council member
561571
had an API key, or every member failed), or an Elite result is missing or
562572
has ``decision_readiness`` ``not_ready``/``indeterminate``. Under ``--json``
563573
the full JSON result is still emitted to stdout first, so a script can both
564574
parse the payload and detect the failure via the non-zero exit code.
565575
* 2 -- a usage/config error (unknown mode, or no members resolved).
576+
* 3 -- degraded (DSE-901): at least one member answered, but the
577+
judge/synthesizer step failed -- ``CouncilResult.degraded`` is ``True``
578+
(``synthesis_error`` or, in ``adversarial`` mode, ``adversarial.verdict_error``
579+
is set). Under ``--json`` the full JSON result -- including the top-level
580+
``"degraded": true`` field -- is still emitted to stdout first. Kept
581+
distinct from both 0 and 1 so a caller that checks only the exit code (e.g.
582+
a verification gate) cannot mistake a partial run for a clean pass: this was
583+
exactly the 2026-07-25 incident that produced this code (an Anthropic
584+
credit failure took out the judge while 4/5 members still answered, and the
585+
run exited 0 with ``adversarial.verdict`` silently ``null``). Not raised for
586+
Elite (its own readiness gate above already covers a failed
587+
synthesis/verdict step with exit code 1).
566588
"""
567589
mode_lower = mode.lower()
568590
if mode_lower not in _VALID_MODES:
@@ -609,6 +631,10 @@ def ask(
609631
"[red]No usable council answers. Run 'conclave providers' to check keys.[/red]"
610632
)
611633
raise typer.Exit(code=1)
634+
if result.degraded:
635+
# _stream_to_terminal already printed the "No synthesis: ..." warning
636+
# (from synthesis_error) to stderr; only the exit code is new here.
637+
raise typer.Exit(code=_DEGRADED_EXIT_CODE)
612638
return
613639

614640
if mode_lower == "debate":
@@ -628,7 +654,11 @@ def ask(
628654

629655
# A run that produced no usable member answers is a failure for scripting
630656
# purposes regardless of output format. We compute this once and apply the
631-
# same exit-code contract to both the JSON and human paths.
657+
# same exit-code contract to both the JSON and human paths. A run that DID
658+
# get usable member answers but whose judge/synthesizer step failed is a
659+
# distinct, less severe failure (DSE-901): ``result.degraded`` (checked
660+
# below, after the hard-failure/usage-error exits) drives exit code
661+
# ``_DEGRADED_EXIT_CODE`` instead of silently returning 0.
632662
no_usable_answers = not result.successful_answers
633663
elite_not_ready = mode_lower == "elite" and (
634664
result.elite is None or result.elite.decision_readiness != "ready"
@@ -651,6 +681,8 @@ def ask(
651681
console.print_json(json.dumps(payload))
652682
if json_output_failed or no_usable_answers or elite_not_ready:
653683
raise typer.Exit(code=1)
684+
if result.degraded:
685+
raise typer.Exit(code=_DEGRADED_EXIT_CODE)
654686
return
655687

656688
if mode_lower == "elite":
@@ -682,6 +714,11 @@ def ask(
682714
_RENDERERS[result.mode](result)
683715
if json_output_failed:
684716
raise typer.Exit(code=1)
717+
if result.degraded:
718+
# The mode-specific renderer above already printed the "No synthesis: ..."
719+
# / "No verdict: ..." warning (from synthesis_error / verdict_error) to
720+
# stderr; only the exit code is new here (DSE-901).
721+
raise typer.Exit(code=_DEGRADED_EXIT_CODE)
685722

686723

687724
@app.command()

‎src/conclave/models.py‎

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from collections.abc import Iterable
1212
from typing import Literal
1313

14-
from pydantic import BaseModel, Field
14+
from pydantic import BaseModel, Field, computed_field
1515

1616
from .verdict import (
1717
CouncilConflict,
@@ -355,6 +355,11 @@ class CouncilResult(BaseModel):
355355
member_answers: Read-only alias for ``answers`` (the per-member raw
356356
responses), exposed under the contract-v2 name. Returns the same list
357357
object as ``answers``; there is one underlying field.
358+
degraded: Computed field (DSE-901) -- ``True`` when the run produced at
359+
least one usable member answer but the judge/synthesizer step failed,
360+
so a caller cannot mistake a partial run for a clean pass. See the
361+
property docstring below for the exact rule and the CLI's exit-code
362+
contract (:func:`conclave.cli.ask`) that keys off it.
358363
"""
359364

360365
prompt: str
@@ -402,6 +407,46 @@ def member_answers(self) -> list[ModelAnswer]:
402407
"""Contract-v2 alias for :attr:`answers` (the per-member raw responses)."""
403408
return self.answers
404409

410+
@computed_field # type: ignore[prop-decorator]
411+
@property
412+
def degraded(self) -> bool:
413+
"""True when members answered but the judge/synthesizer step failed (DSE-901).
414+
415+
Distinguishes "partial run: judge/synthesis failed" from a clean pass, so
416+
a verification gate that only checks ``CouncilResult`` fields (or the
417+
CLI's exit code -- see :func:`conclave.cli.ask`) cannot read a degraded
418+
run as a full success. This closed a real gap: on 2026-07-25 an
419+
Anthropic credit failure meant 4/5 members answered but the ``claude``
420+
judge/synthesizer failed, ``adversarial.verdict`` was ``null``, and the
421+
CLI still exited 0.
422+
423+
``True`` whenever either of the two fields the judge/synthesizer path
424+
writes on failure is set:
425+
426+
* ``synthesis_error`` is non-``None`` (``synthesize``/``debate``/``elite``
427+
modes, and mirrored into ``adversarial`` mode's top-level fields too); or
428+
* ``adversarial.verdict_error`` is non-``None`` (checked directly as a
429+
defense-in-depth fallback in case a future code path sets it without
430+
mirroring to ``synthesis_error``).
431+
432+
Always ``False`` for ``raw``/``vote`` runs, where the judge/synthesizer is
433+
never invoked, and for any run where it ran and succeeded. Because
434+
``synthesis_error`` is also set when a run has zero usable member answers
435+
(nothing to synthesize), that harder failure is *also* ``degraded=True``
436+
here; the CLI's exit-code contract gives that case its own, more severe
437+
exit code, so this flag alone does not distinguish "nothing answered"
438+
from "some members answered, judge/synthesis failed" -- callers wanting
439+
that distinction should also check ``successful_answers``. Included as a
440+
top-level key in ``model_dump(mode="json")`` output (a Pydantic
441+
``computed_field``), so a scripted consumer can check it directly instead
442+
of reaching into ``synthesis_error``/``adversarial``.
443+
"""
444+
if self.synthesis_error:
445+
return True
446+
if self.adversarial is not None and self.adversarial.verdict_error:
447+
return True
448+
return False
449+
405450

406451
# Late import (see the note near the top): ``manifest`` imports ``TokenUsage``
407452
# from this module, so it can only be imported once the leaf types above exist.

‎tests/test_cli.py‎

Lines changed: 138 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,16 @@
33
These exercise ``conclave.cli.ask`` through Typer's ``CliRunner`` (no real keys,
44
no network). Three concerns are pinned here:
55
6-
* **Exit-code contract (#17).** A run that produces zero *usable* member answers,
7-
or an Elite run whose ``decision_readiness`` is not ``ready``, exits non-zero
8-
(code 1) on both the human and ``--json`` paths. Under ``--json`` the full JSON
9-
payload is still emitted to stdout so a script can parse the result *and*
10-
detect the failure via the exit code. Other runs with a usable answer exit 0.
6+
* **Exit-code contract (#17, extended by DSE-901).** A run that produces zero
7+
*usable* member answers, or an Elite run whose ``decision_readiness`` is not
8+
``ready``, exits non-zero (code 1) on both the human and ``--json`` paths.
9+
Under ``--json`` the full JSON payload is still emitted to stdout so a script
10+
can parse the result *and* detect the failure via the exit code. A run with a
11+
usable answer whose judge/synthesizer step nonetheless failed
12+
(``CouncilResult.degraded``) exits a distinct code 3 rather than 0 -- see
13+
``tests/test_modes.py`` / ``tests/test_synthesizer.py`` for the mode-level
14+
``degraded`` behavior this exit code surfaces. Every other run with a usable
15+
answer exits 0.
1116
* **Durable result output (#58).** Buffered runs can atomically persist the same
1217
complete JSON payload with user-private permissions, including before a failure exit.
1318
Invalid destinations and streaming combinations fail before council construction.
@@ -28,6 +33,7 @@
2833
from conclave.config import ConclaveConfig
2934
from conclave.models import CouncilResult, EliteResult, ModelAnswer
3035
from conclave.verdict import CouncilVerdict
36+
from tests.conftest import make_response
3137

3238
runner = CliRunner()
3339

@@ -52,6 +58,14 @@ def patch_cli_config(monkeypatch):
5258
monkeypatch.setattr(cli, "load_config", _config)
5359

5460

61+
def _system_text(messages) -> str:
62+
"""Return the system-role content of a message list, or '' if none."""
63+
for m in messages:
64+
if m.get("role") == "system":
65+
return m.get("content", "")
66+
return ""
67+
68+
5569
def test_no_members_human_exits_one(clear_keys, patch_cli_config):
5670
"""Plain (human) path: zero usable answers -> exit code 1."""
5771
result = runner.invoke(cli.app, ["ask", "hello"])
@@ -285,6 +299,125 @@ def test_unknown_mode_exits_two(patch_cli_config):
285299
assert result.exit_code == 2
286300

287301

302+
# --------------------------------------------------------------------------- #
303+
# Degraded-run exit code (DSE-901): members answered, judge/synthesizer failed.
304+
# --------------------------------------------------------------------------- #
305+
306+
307+
def test_adversarial_judge_failure_json_is_degraded_not_clean(
308+
monkeypatch, patch_cli_config, patch_call_model
309+
):
310+
"""DSE-901: members answer but the judge/synthesizer call fails -> degraded.
311+
312+
Reproduces the 2026-07-25 incident that filed this ticket: the Anthropic key
313+
had no credit, so the ``claude`` judge's call returned an HTTP 400 while
314+
``grok``/``gemini`` still answered normally, and the CLI exited 0 with
315+
``adversarial.verdict`` silently ``null``. The fix must surface
316+
``degraded: true`` in the JSON payload and exit the new degraded code
317+
(``cli._DEGRADED_EXIT_CODE`` == 3), not the clean-pass code 0.
318+
"""
319+
for var in ("XAI_API_KEY", "GEMINI_API_KEY", "ANTHROPIC_API_KEY"):
320+
monkeypatch.setenv(var, "dummy-key")
321+
322+
def handler(model, messages, **kwargs):
323+
if "judge of an adversarial review" in _system_text(messages):
324+
raise RuntimeError("HTTP 400: your credit balance is too low")
325+
return make_response(f"answer from {model}")
326+
327+
patch_call_model(handler)
328+
result = runner.invoke(
329+
cli.app,
330+
["ask", "hello", "--council", "grok,gemini", "--mode", "adversarial", "--json"],
331+
)
332+
333+
assert result.exit_code == cli._DEGRADED_EXIT_CODE
334+
assert result.exit_code not in (0, 1, 2)
335+
payload = json.loads(result.stdout)
336+
assert payload["degraded"] is True
337+
assert payload["adversarial"]["verdict"] is None
338+
assert "credit balance" in payload["adversarial"]["verdict_error"]
339+
assert payload["synthesis_error"] == payload["adversarial"]["verdict_error"]
340+
# This is a PARTIAL run, not the hard "zero usable answers" failure (code 1):
341+
# the proposer/critic that didn't hit the judge path still answered.
342+
assert len(payload["answers"]) >= 1
343+
assert any(a["error"] is None and a["answer"] is not None for a in payload["answers"])
344+
345+
346+
def test_adversarial_judge_failure_human_exits_degraded_code(
347+
monkeypatch, patch_cli_config, patch_call_model
348+
):
349+
"""Same DSE-901 scenario on the human (non-``--json``) render path."""
350+
for var in ("XAI_API_KEY", "GEMINI_API_KEY", "ANTHROPIC_API_KEY"):
351+
monkeypatch.setenv(var, "dummy-key")
352+
353+
def handler(model, messages, **kwargs):
354+
if "judge of an adversarial review" in _system_text(messages):
355+
raise RuntimeError("HTTP 400: your credit balance is too low")
356+
return make_response(f"answer from {model}")
357+
358+
patch_call_model(handler)
359+
result = runner.invoke(
360+
cli.app, ["ask", "hello", "--council", "grok,gemini", "--mode", "adversarial"]
361+
)
362+
363+
assert result.exit_code == cli._DEGRADED_EXIT_CODE
364+
assert "No verdict" in result.output
365+
assert "credit balance" in result.output
366+
367+
368+
def test_adversarial_clean_pass_still_exits_zero(monkeypatch, patch_cli_config, patch_call_model):
369+
"""Regression: a fully successful adversarial run (proposer/critic/judge all
370+
succeed) is NOT degraded and keeps exiting 0 -- the new check must not fire
371+
on the existing clean-pass path."""
372+
for var in ("XAI_API_KEY", "GEMINI_API_KEY", "ANTHROPIC_API_KEY"):
373+
monkeypatch.setenv(var, "dummy-key")
374+
375+
def handler(model, messages, **kwargs):
376+
system = _system_text(messages)
377+
if "judge of an adversarial review" in system:
378+
return make_response("verdict text")
379+
if "critic on an adversarial review" in system:
380+
return make_response(f"critique from {model}")
381+
return make_response(f"proposal from {model}")
382+
383+
patch_call_model(handler)
384+
result = runner.invoke(
385+
cli.app,
386+
["ask", "hello", "--council", "grok,gemini", "--mode", "adversarial", "--json"],
387+
)
388+
389+
assert result.exit_code == 0
390+
payload = json.loads(result.stdout)
391+
assert payload["degraded"] is False
392+
assert payload["adversarial"]["verdict"] == "verdict text"
393+
394+
395+
def test_all_members_failed_synthesize_mode_exits_one_not_degraded(
396+
monkeypatch, patch_cli_config, patch_call_model
397+
):
398+
"""Regression: zero usable answers keeps exit 1, never the new degraded code.
399+
400+
In default (synthesize) mode, every member erroring also sets
401+
``synthesis_error`` ("no successful member answers to synthesize"), so
402+
``result.degraded`` reads ``True`` here too -- the CLI's priority order must
403+
still pick the more severe hard-failure code (1) over the new degraded code.
404+
"""
405+
for var in ("XAI_API_KEY", "GEMINI_API_KEY"):
406+
monkeypatch.setenv(var, "dummy-key")
407+
408+
def handler(model, messages, **kwargs):
409+
raise RuntimeError("provider down")
410+
411+
patch_call_model(handler)
412+
result = runner.invoke(cli.app, ["ask", "hello", "--council", "grok,gemini", "--json"])
413+
414+
assert result.exit_code == 1
415+
payload = json.loads(result.stdout)
416+
assert payload["degraded"] is True
417+
assert len(payload["answers"]) == 2
418+
assert all(a["error"] is not None for a in payload["answers"])
419+
420+
288421
def _elite_cli_result(
289422
*,
290423
completed: bool = True,

0 commit comments

Comments
 (0)