Skip to content

Commit cba737b

Browse files
sansariclaude
andauthored
feat(quality-gate): hung-reviewer retry policy with configurable constants (#413)
* feat(quality-gate): hung-reviewer retry policy with configurable constants Reviewers that return with 0 tool uses (API overload / dropped connection) are now surfaced with explicit retry instructions rather than being silently passed or requiring manual intervention. - Add REVIEWER_MAX_RETRIES (default: 1) and REVIEWER_FAST_FAIL_SECONDS (default: 30) module-level constants to quality_gate.py - Extend _build_review_guidance() with a "Handling Hung Reviewers" section that instructs Claude to detect 0-tool-use returns, retry once, and — after exhausting retries — call mark_review_as_passed with a skip note so the workflow proceeds without blocking indefinitely - Fast-fail detection: if elapsed time < REVIEWER_FAST_FAIL_SECONDS the reviewer never got an API response; retry immediately - _build_review_guidance() now accepts max_retries and fast_fail_seconds kwargs for test overriding and future caller configuration - Add JOBS-REQ-004.9 to spec and 9 new tests covering all acceptance criteria Closes #408 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(quality-gate): address reviewer feedback on hung-reviewer policy - Fix hardcoded "once"/"If the retry also" — now parameterized via retry_instruction/exhaust_condition locals so the prose stays correct for any value of max_retries - Fix mark_review_as_passed guidance: the tool only accepts review_id; skip note is now communicated to the user separately, not as a phantom tool argument - Add slow-hang path: guidance now explicitly describes both fast-fail (elapsed < REVIEWER_FAST_FAIL_SECONDS, retry immediately) and slow-hang (elapsed >= threshold, retry after brief pause) - Fix JOBS-REQ-004.9 spec: moved to end of file (after 004.8), corrected requirement 4 and 5 to match implementation - Strengthen test_guidance_specifies_max_retries: checks "max N retry/ies" not just presence of "retry" - Fix test_build_review_guidance_accepts_override_params: checks specific formatted strings "max 3 retries" / "4 failed attempts" / "60" - Add test_guidance_describes_slow_hang_path (REQ-004.9.5) - Add test_guidance_instructs_separate_user_message_not_tool_comment (REQ-004.9.4) - Add JOBS-REQ-004.8 and JOBS-REQ-004.9 to module docstring Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * style: apply ruff auto-formatting to quality_gate and test file Wrap two long ternary expressions in quality_gate.py and one long assert expression in test_quality_gate.py into parenthesized multi-line form per ruff line-length rules. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 3ca7453 commit cba737b

4 files changed

Lines changed: 171 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Added
1111

1212
- `review_depth: lightweight` annotation for review blocks in `job.yml` step outputs and `.deepreview` rules — when set, the workflow's `common_job_info` preamble is omitted from review instruction files, reducing token overhead for trivial or reversible intermediate steps (closes #86)
13+
- Hung-reviewer retry policy in quality gate guidance: reviewers completing with 0 tool uses are retried up to `REVIEWER_MAX_RETRIES` (default: 1) times before being skipped with a "manual review recommended" note; fast-fails (elapsed < `REVIEWER_FAST_FAIL_SECONDS`, default: 30s) are retried immediately (closes #408)
1314

1415
### Changed
1516

doc/specs/deepwork/jobs/JOBS-REQ-004-quality-review-system.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,3 +73,13 @@ The quality review system evaluates step outputs against defined quality criteri
7373
4. The synthetic task's instructions MUST be prefixed with the same preamble used for file-based reviews (workflow `common_job_info` and input context).
7474
5. String outputs with `None` values MUST be skipped.
7575
6. The synthetic task's `review_id` MUST incorporate the string value into its content hash so that distinct string values produce distinct cache keys (per REVIEW-REQ-009.1.7).
76+
77+
### JOBS-REQ-004.9: Hung Reviewer Retry Policy
78+
79+
1. The review guidance MUST include a clearly labelled hung-reviewer retry policy section.
80+
2. The guidance MUST define a reviewer as "hung" when it completes with 0 tool uses and produces no substantive output.
81+
3. The guidance MUST instruct the agent to retry a hung reviewer up to `REVIEWER_MAX_RETRIES` times (default: 1) before skipping.
82+
4. After exhausting retries the guidance MUST instruct the agent to: (a) call `mark_review_as_passed` with the review ID only (the tool accepts no comment argument), and (b) separately inform the user with a skip message — not silently pass the review without any attempt.
83+
5. The guidance MUST distinguish a fast-fail (elapsed time under `REVIEWER_FAST_FAIL_SECONDS`, default: 30) from a slow-hang (elapsed time at or above that threshold), advising an immediate retry for fast-fails and a brief-pause retry for slow-hangs.
84+
6. `REVIEWER_MAX_RETRIES` and `REVIEWER_FAST_FAIL_SECONDS` MUST be named module-level constants (not inline magic numbers) so they can be adjusted without editing the guidance prose.
85+
7. `_build_review_guidance()` MUST accept `max_retries` and `fast_fail_seconds` keyword arguments so callers can override the defaults in tests or alternative configurations.

src/deepwork/jobs/mcp/quality_gate.py

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,19 @@
3131

3232
logger = logging.getLogger("deepwork.jobs.mcp.quality_gate")
3333

34+
# Hung-reviewer retry policy (configurable constants).
35+
#
36+
# A reviewer is considered "hung" when it completes with 0 tool uses —
37+
# a strong signal of API overload or a dropped connection. The dispatch
38+
# guidance instructs Claude to retry hung reviewers up to
39+
# REVIEWER_MAX_RETRIES times before skipping and logging a warning.
40+
#
41+
# REVIEWER_FAST_FAIL_SECONDS: if a reviewer returns 0 tool uses *and*
42+
# elapsed time is below this threshold it almost certainly never received
43+
# an API response at all — retry immediately without any backoff.
44+
REVIEWER_MAX_RETRIES: int = 1
45+
REVIEWER_FAST_FAIL_SECONDS: int = 30
46+
3447

3548
def validate_json_schemas(
3649
outputs: dict[str, ArgumentValue],
@@ -480,8 +493,27 @@ def run_quality_gate(
480493
return guidance
481494

482495

483-
def _build_review_guidance(review_output: str) -> str:
484-
"""Build the complete review guidance including /review skill instructions."""
496+
def _build_review_guidance(
497+
review_output: str,
498+
max_retries: int = REVIEWER_MAX_RETRIES,
499+
fast_fail_seconds: int = REVIEWER_FAST_FAIL_SECONDS,
500+
) -> str:
501+
"""Build the complete review guidance including hung-reviewer retry policy.
502+
503+
Args:
504+
review_output: Formatted list of review tasks from format_for_claude.
505+
max_retries: How many times to retry a hung reviewer before skipping.
506+
fast_fail_seconds: Elapsed-time threshold below which a 0-tool-use
507+
result is treated as a fast-fail and retried immediately.
508+
"""
509+
retry_word = "retry" if max_retries == 1 else "retries"
510+
retry_instruction = (
511+
"Retry it once" if max_retries == 1 else f"Retry it up to {max_retries} times"
512+
)
513+
exhaust_condition = (
514+
"If the retry also returns" if max_retries == 1 else f"If all {max_retries} retries return"
515+
)
516+
total_attempts = max_retries + 1
485517
return f"""Quality reviews are required before this step can advance.
486518
487519
{review_output}
@@ -490,6 +522,18 @@ def _build_review_guidance(review_output: str) -> str:
490522
491523
For each review task listed above, launch it as a parallel Agent. The task's prompt field points to an instruction file — read it and follow the review instructions.
492524
525+
## Handling Hung Reviewers
526+
527+
A reviewer has **hung** when it completes with **0 tool uses** — a signal of API overload or a dropped connection. Hung reviewers must be retried; do **not** silently pass them.
528+
529+
**Retry policy (max {max_retries} {retry_word} per reviewer)**:
530+
531+
1. After each reviewer completes, check whether it made 0 tool uses (shown as `0 tool uses` in the agent result) **and** produced no substantive output.
532+
2. If both are true the reviewer hung. {retry_instruction} by re-launching the same agent with the same prompt.
533+
- If elapsed time was under {fast_fail_seconds}s the reviewer fast-failed (never got an API response) — retry immediately.
534+
- If elapsed time was {fast_fail_seconds}s or more (slow-hang), the reviewer started but stalled — retry after a brief pause.
535+
3. {exhaust_condition} 0 tool uses: call `mark_review_as_passed` with the review ID. Then tell the user: "Review skipped after {total_attempts} failed attempts — manual review recommended for this step." Do **not** proceed without informing the user.
536+
493537
## After Reviews
494538
495-
For any failing reviews, if you believe the issue is invalid, then you can call `mark_review_as_passed` on it. Otherwise, you should act on any feedback from the review to fix the issues. Once done, call `finished_step` again to see if you will pass now."""
539+
For any failing reviews where the reviewer produced actual findings: if you believe the issue is invalid, call `mark_review_as_passed` on it. Otherwise, act on the feedback, fix the issues, and call `finished_step` again."""

tests/unit/jobs/mcp/test_quality_gate.py

Lines changed: 113 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
"""Tests for MCP quality gate (reviews-based implementation).
22
33
Validates requirements: JOBS-REQ-004, JOBS-REQ-004.1, JOBS-REQ-004.2, JOBS-REQ-004.3,
4-
JOBS-REQ-004.4, JOBS-REQ-004.5, JOBS-REQ-004.6, JOBS-REQ-004.7.
4+
JOBS-REQ-004.4, JOBS-REQ-004.5, JOBS-REQ-004.6, JOBS-REQ-004.7, JOBS-REQ-004.8,
5+
JOBS-REQ-004.9.
56
67
Note: JOBS-REQ-009 is DEPRECATED (superseded by JOBS-REQ-004). No tests required.
78
"""
@@ -13,6 +14,9 @@
1314
from unittest.mock import patch
1415

1516
from deepwork.jobs.mcp.quality_gate import (
17+
REVIEWER_FAST_FAIL_SECONDS,
18+
REVIEWER_MAX_RETRIES,
19+
_build_review_guidance,
1620
build_dynamic_review_rules,
1721
build_string_output_review_tasks,
1822
run_quality_gate,
@@ -1897,3 +1901,111 @@ def test_reruns_review_when_file_content_changes_after_pass(self, tmp_path: Path
18971901
# Review MUST run again because content changed
18981902
assert result is not None
18991903
assert "Quality reviews are required" in result
1904+
1905+
1906+
class TestHungReviewerRetryPolicy:
1907+
"""Tests for the hung-reviewer retry policy — validates JOBS-REQ-004.9."""
1908+
1909+
# THIS TEST VALIDATES A HARD REQUIREMENT (JOBS-REQ-004.9.1).
1910+
# YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES
1911+
def test_guidance_includes_hung_reviewer_section(self) -> None:
1912+
"""Review guidance MUST include a hung-reviewer retry policy section."""
1913+
result = _build_review_guidance("## Review Tasks\n\n- some task")
1914+
assert "Handling Hung Reviewers" in result
1915+
1916+
# THIS TEST VALIDATES A HARD REQUIREMENT (JOBS-REQ-004.9.2).
1917+
# YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES
1918+
def test_guidance_defines_hung_as_zero_tool_uses(self) -> None:
1919+
"""Guidance MUST define a hung reviewer as one that returns 0 tool uses."""
1920+
result = _build_review_guidance("## Review Tasks\n\n- some task")
1921+
assert "0 tool uses" in result
1922+
1923+
# THIS TEST VALIDATES A HARD REQUIREMENT (JOBS-REQ-004.9.3).
1924+
# YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES
1925+
def test_guidance_specifies_max_retries(self) -> None:
1926+
"""Guidance MUST specify the max retry count from REVIEWER_MAX_RETRIES."""
1927+
result = _build_review_guidance("## Review Tasks\n\n- some task")
1928+
# Default is 1 retry — heading must say "max 1 retry"
1929+
assert (
1930+
f"max {REVIEWER_MAX_RETRIES} retry" in result
1931+
or f"max {REVIEWER_MAX_RETRIES} retries" in result
1932+
)
1933+
1934+
# THIS TEST VALIDATES A HARD REQUIREMENT (JOBS-REQ-004.9.4).
1935+
# YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES
1936+
def test_guidance_instructs_skip_with_note_after_retries_exhausted(self) -> None:
1937+
"""After retries exhausted, guidance MUST instruct skip + log, not silent pass."""
1938+
result = _build_review_guidance("## Review Tasks\n\n- some task")
1939+
assert "mark_review_as_passed" in result
1940+
assert "manual review recommended" in result
1941+
1942+
# THIS TEST VALIDATES A HARD REQUIREMENT (JOBS-REQ-004.9.5).
1943+
# YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES
1944+
def test_guidance_mentions_fast_fail_threshold(self) -> None:
1945+
"""Guidance MUST mention the fast-fail elapsed-time threshold."""
1946+
result = _build_review_guidance("## Review Tasks\n\n- some task")
1947+
# The threshold in seconds should appear in the guidance
1948+
assert str(REVIEWER_FAST_FAIL_SECONDS) in result
1949+
1950+
# THIS TEST VALIDATES A HARD REQUIREMENT (JOBS-REQ-004.9.6).
1951+
# YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES
1952+
def test_reviewer_max_retries_is_named_constant(self) -> None:
1953+
"""REVIEWER_MAX_RETRIES MUST be a module-level integer constant."""
1954+
assert isinstance(REVIEWER_MAX_RETRIES, int)
1955+
assert REVIEWER_MAX_RETRIES >= 1
1956+
1957+
# THIS TEST VALIDATES A HARD REQUIREMENT (JOBS-REQ-004.9.6).
1958+
# YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES
1959+
def test_reviewer_fast_fail_seconds_is_named_constant(self) -> None:
1960+
"""REVIEWER_FAST_FAIL_SECONDS MUST be a module-level integer constant."""
1961+
assert isinstance(REVIEWER_FAST_FAIL_SECONDS, int)
1962+
assert REVIEWER_FAST_FAIL_SECONDS > 0
1963+
1964+
# THIS TEST VALIDATES A HARD REQUIREMENT (JOBS-REQ-004.9.7).
1965+
# YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES
1966+
def test_build_review_guidance_accepts_override_params(self) -> None:
1967+
"""_build_review_guidance MUST accept max_retries and fast_fail_seconds kwargs."""
1968+
result = _build_review_guidance(
1969+
"## Review Tasks\n\n- some task",
1970+
max_retries=3,
1971+
fast_fail_seconds=60,
1972+
)
1973+
# max_retries=3 → heading says "max 3 retries", exhaust text says "4 failed attempts"
1974+
assert "max 3 retries" in result
1975+
assert "4 failed attempts" in result
1976+
# fast_fail_seconds=60 → threshold appears in fast-fail bullet
1977+
assert "60" in result
1978+
1979+
# THIS TEST VALIDATES A HARD REQUIREMENT (JOBS-REQ-004.9.7).
1980+
# YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES
1981+
def test_build_review_guidance_retry_text_reflects_max_retries(self) -> None:
1982+
"""Guidance retry count text MUST reflect the max_retries parameter."""
1983+
result_1 = _build_review_guidance("tasks", max_retries=1)
1984+
result_2 = _build_review_guidance("tasks", max_retries=2)
1985+
# After exhausting retries: message mentions total attempts (max_retries + 1)
1986+
assert "2 failed attempts" in result_1
1987+
assert "3 failed attempts" in result_2
1988+
1989+
# THIS TEST VALIDATES A HARD REQUIREMENT (JOBS-REQ-004.9.5).
1990+
# YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES
1991+
def test_guidance_describes_slow_hang_path(self) -> None:
1992+
"""Guidance MUST describe the slow-hang retry path (elapsed >= threshold)."""
1993+
result = _build_review_guidance("## Review Tasks\n\n- some task")
1994+
# Must mention that slow-hangs are also retried (not only fast-fails)
1995+
assert "slow-hang" in result or "slow" in result.lower()
1996+
1997+
# THIS TEST VALIDATES A HARD REQUIREMENT (JOBS-REQ-004.9.4).
1998+
# YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES
1999+
def test_guidance_instructs_separate_user_message_not_tool_comment(self) -> None:
2000+
"""Skip note MUST be communicated to the user separately, not as a tool arg.
2001+
2002+
mark_review_as_passed only accepts review_id — the guidance must not
2003+
imply passing a comment/note to the tool itself.
2004+
"""
2005+
result = _build_review_guidance("## Review Tasks\n\n- some task")
2006+
# Guidance should tell Claude to call mark_review_as_passed AND then
2007+
# separately tell the user — not pass a note argument to the tool.
2008+
assert "tell the user" in result
2009+
# Must NOT suggest an impossible "append comment" or "note=" argument
2010+
assert "append the comment" not in result
2011+
assert "note=" not in result

0 commit comments

Comments
 (0)