From d10a333c579997383f281184d9fecd0cc0e85ca3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 06:38:21 -0700 Subject: [PATCH 01/65] test(noema): require free-first auto fallback route --- tests/test_noema_orchestrator_workflow_contract.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/test_noema_orchestrator_workflow_contract.py b/tests/test_noema_orchestrator_workflow_contract.py index dfa9aa2c8f..c40a30b5c4 100644 --- a/tests/test_noema_orchestrator_workflow_contract.py +++ b/tests/test_noema_orchestrator_workflow_contract.py @@ -1,4 +1,4 @@ -"""Noema review now uses the vendored orchestrator sidecar, not NVIDIA NIM.""" +"""Noema review uses the vendored orchestrator auto pool with free-first fallback.""" from __future__ import annotations @@ -11,8 +11,8 @@ from tests.test_required_workflow_queue_contract import workflow_step, workflow_text -def test_noema_review_credentials_and_llm_use_orchestrator_free() -> None: - """Require reviewer credentials and the sidecar; the public NIM hardcode is gone.""" +def test_noema_review_credentials_and_llm_use_orchestrator_auto() -> None: + """Require reviewer credentials and the auto sidecar; direct NIM stays absent.""" workflow = workflow_text("noema-review.yml") assert "fail_unavailable()" in workflow @@ -32,6 +32,7 @@ def test_noema_review_credentials_and_llm_use_orchestrator_free() -> None: assert "Resolve Noema target repository visibility" in workflow assert "target_visibility.outputs.require_zdr" in workflow assert "CONTEXTUAL_ORCHESTRATOR_REQUIRE_ZDR" in workflow + assert "CONTEXTUAL_ORCHESTRATOR_POOL: auto" in workflow assert ( "NOEMA_LLM_API_KEY: ${{ secrets.NOEMA_LLM_API_KEY || secrets.OPENAI_API_KEY || '' }}" not in workflow @@ -42,7 +43,8 @@ def test_noema_review_credentials_and_llm_use_orchestrator_free() -> None: assert "NVIDIA_NIM_API_KEY_SUB: ${{ secrets.NVIDIA_NIM_API_KEY_SUB }}" in workflow assert "OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}" in workflow assert "OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}" in workflow - assert 'export NOEMA_LLM_MODEL="orchestrator/free"' in workflow + assert 'export NOEMA_LLM_MODEL="orchestrator/auto"' in workflow + assert 'export NOEMA_LLM_MODEL="orchestrator/free"' not in workflow assert ( "contextual-orchestrator review sidecar must be provisioned before Noema LLM review." in workflow From 0c4f0992f6f4130ce277d8017061246177ff6f0c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 06:43:29 -0700 Subject: [PATCH 02/65] ci(noema): run gateway contracts on changed paths --- .../noema-orchestrator-quality-ci.yml | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 .github/workflows/noema-orchestrator-quality-ci.yml diff --git a/.github/workflows/noema-orchestrator-quality-ci.yml b/.github/workflows/noema-orchestrator-quality-ci.yml new file mode 100644 index 0000000000..09b806cbb9 --- /dev/null +++ b/.github/workflows/noema-orchestrator-quality-ci.yml @@ -0,0 +1,74 @@ +name: Noema Orchestrator Quality CI + +on: + pull_request: + branches: [main] + paths: + - ".github/workflows/noema-orchestrator-quality-ci.yml" + - ".github/workflows/noema-review.yml" + - "CHANGELOG.md" + - "docs/doctoring/noema-orchestrator-auto-fallback.md" + - "scripts/ci/contextual_orchestrator_review_launcher.py" + - "scripts/ci/contextual_orchestrator_review_policy.py" + - "scripts/ci/contextual_orchestrator_review_sidecar.sh" + - "scripts/ci/load_contextual_orchestrator_token.sh" + - "scripts/ci/zdr_policy.py" + - "tests/test_contextual_orchestrator_review_sidecar_contract.py" + - "tests/test_noema_orchestrator_workflow_contract.py" + +permissions: + contents: read + +concurrency: + group: noema-orchestrator-quality-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + exact-head-contract: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Checkout exact source revision + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14" + + - name: Install exact hash-verified test runner dependencies + env: + PIP_DISABLE_PIP_VERSION_CHECK: "1" + PIP_NO_INPUT: "1" + shell: bash --noprofile --norc -e -o pipefail {0} + run: | + cat >"${RUNNER_TEMP}/noema-quality-requirements.txt" <<'EOF' + coverage==7.15.2 --hash=sha256:b9a6367e4aff723e8ee8190836836124284e8fcd4265e307c844010cfa074f3f + iniconfig==2.1.0 --hash=sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760 + packaging==26.2 --hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e + pluggy==1.6.0 --hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746 + pygments==2.20.0 --hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176 + pytest==9.1.1 --hash=sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c + EOF + python -m pip install \ + --only-binary=:all: \ + --require-hashes \ + -r "${RUNNER_TEMP}/noema-quality-requirements.txt" + + - name: Verify exact-head Noema gateway contracts + shell: bash --noprofile --norc -e -o pipefail {0} + run: | + test "$(git rev-parse HEAD)" = "${{ github.event.pull_request.head.sha || github.sha }}" + python -m coverage run -m pytest \ + tests/test_noema_orchestrator_workflow_contract.py \ + tests/test_contextual_orchestrator_review_sidecar_contract.py \ + -q + python -m compileall -q \ + tests/test_noema_orchestrator_workflow_contract.py \ + tests/test_contextual_orchestrator_review_sidecar_contract.py \ + scripts/ci/contextual_orchestrator_review_launcher.py + bash -n scripts/ci/contextual_orchestrator_review_sidecar.sh + git diff --exit-code From 9effbb40c5a95cf220f6328a70023dba03d7a0c6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 06:46:43 -0700 Subject: [PATCH 03/65] test(strix): cover auto orchestrator loopback route --- tests/test_strix_openai_fallback_api_base.py | 82 ++++++++++++++++++-- 1 file changed, 75 insertions(+), 7 deletions(-) diff --git a/tests/test_strix_openai_fallback_api_base.py b/tests/test_strix_openai_fallback_api_base.py index 57988048e9..4b20d35806 100644 --- a/tests/test_strix_openai_fallback_api_base.py +++ b/tests/test_strix_openai_fallback_api_base.py @@ -145,6 +145,43 @@ def _resolve_api_base(env: dict[str, str], model: str) -> tuple[int, str]: return completed.returncode, completed.stdout.strip() +def _resolve_child_model(model: str, api_base: str) -> tuple[int, str]: + """Execute the production child-model qualifier for one gateway route.""" + + gate_source = STRIX_GATE.read_text(encoding="utf-8") + helper_sources = [ + _function_block(gate_source, "is_contextual_orchestrator_model"), + _function_block(gate_source, "is_contextual_orchestrator_api_base"), + _function_block(gate_source, "is_github_models_api_base"), + _function_block(gate_source, "child_model_for_api_base"), + ] + with tempfile.TemporaryDirectory(prefix="strix-gateway-child-model-") as temp_dir: + completed = subprocess.run( + [ + "bash", + "-c", + "\n".join( + [ + "set -euo pipefail", + *helper_sources, + 'child_model_for_api_base "$1" "$2"', + ] + ), + "strix-child-model", + model, + api_base, + ], + check=False, + capture_output=True, + text=True, + env={ + "PATH": "/usr/bin:/bin:/usr/local/bin", + "HOME": temp_dir, + }, + ) + return completed.returncode, completed.stdout.strip() + + class ExplicitOpenAIFallbackRouting(unittest.TestCase): """Direct-OpenAI fallbacks must not inherit the primary provider base.""" @@ -269,21 +306,52 @@ def test_workflow_does_not_configure_an_external_fallback(self) -> None: self.assertIn("Provision contextual-orchestrator Strix sidecar", workflow) def test_workflow_gateway_base_is_the_only_http_exception(self) -> None: - """The local sidecar is accepted without allowing arbitrary HTTP bases.""" + """Both gateway pools accept only the pinned process-local HTTP base.""" - rc, api_base = _resolve_api_base( - {"LLM_API_BASE_FILE": "http://127.0.0.1:18080/v1"}, + for model in ( "orchestrator/free", - ) - self.assertEqual(rc, 0) - self.assertEqual(api_base, "http://127.0.0.1:18080/v1") + "contextual-orchestrator/orchestrator/free", + "orchestrator/auto", + "contextual-orchestrator/orchestrator/auto", + ): + with self.subTest(model=model): + rc, api_base = _resolve_api_base( + {"LLM_API_BASE_FILE": "http://127.0.0.1:18080/v1"}, + model, + ) + self.assertEqual(rc, 0) + self.assertEqual(api_base, "http://127.0.0.1:18080/v1") rc, _ = _resolve_api_base( {"LLM_API_BASE_FILE": "http://127.0.0.1:18081/v1"}, - "orchestrator/free", + "orchestrator/auto", ) self.assertEqual(rc, 2) + rc, _ = _resolve_api_base( + {"LLM_API_BASE_FILE": "http://127.0.0.1:18080/v1"}, + "orchestrator/unknown", + ) + self.assertEqual(rc, 2) + + def test_gateway_child_model_preserves_selected_virtual_pool(self) -> None: + """LiteLLM qualification must not rewrite auto back to free.""" + + expected_child_models = { + "orchestrator/free": "openai/orchestrator/free", + "contextual-orchestrator/orchestrator/free": "openai/orchestrator/free", + "orchestrator/auto": "openai/orchestrator/auto", + "contextual-orchestrator/orchestrator/auto": "openai/orchestrator/auto", + } + for model, expected_child_model in expected_child_models.items(): + with self.subTest(model=model): + rc, child_model = _resolve_child_model( + model, + "http://127.0.0.1:18080/v1", + ) + self.assertEqual(rc, 0) + self.assertEqual(child_model, expected_child_model) + def test_manual_status_job_has_status_write_permission(self) -> None: """OIDC target-app exchange may request the target commit status scope.""" From 687087b55b2f359501f29971ae834d986778d766 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 06:54:46 -0700 Subject: [PATCH 04/65] fix(strix): preserve auto orchestrator gateway route --- scripts/ci/strix_quick_gate.sh | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/scripts/ci/strix_quick_gate.sh b/scripts/ci/strix_quick_gate.sh index 1e0630b301..c4cd33cfa7 100755 --- a/scripts/ci/strix_quick_gate.sh +++ b/scripts/ci/strix_quick_gate.sh @@ -322,7 +322,8 @@ is_vertex_model() { is_contextual_orchestrator_model() { case "$1" in - orchestrator/free | contextual-orchestrator/orchestrator/free) + orchestrator/free | contextual-orchestrator/orchestrator/free | \ + orchestrator/auto | contextual-orchestrator/orchestrator/auto) return 0 ;; *) @@ -2561,12 +2562,14 @@ child_model_for_api_base() { local llm_api_base_value="$2" # LiteLLM requires an explicit provider prefix even when the gateway is an - # OpenAI-compatible local endpoint. Keep the public gateway model name, but - # qualify only the child process model so the request still carries - # orchestrator/free to contextual-orchestrator. + # OpenAI-compatible local endpoint. Strip only the connector-facing alias so + # the selected orchestrator/free or orchestrator/auto virtual pool reaches + # contextual-orchestrator unchanged. if is_contextual_orchestrator_model "$model" && is_contextual_orchestrator_api_base "$llm_api_base_value"; then - printf '%s\n' 'openai/orchestrator/free' + local contextual_orchestrator_model + contextual_orchestrator_model="${model#contextual-orchestrator/}" + printf 'openai/%s\n' "$contextual_orchestrator_model" return 0 fi From 4741d8e0450b8537beeb8ec4cfe2ea9213a829e5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 06:58:36 -0700 Subject: [PATCH 05/65] fix(noema): use free-first auto provider fallback --- .github/workflows/noema-review.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/noema-review.yml b/.github/workflows/noema-review.yml index 5c60782adb..9affc48bef 100644 --- a/.github/workflows/noema-review.yml +++ b/.github/workflows/noema-review.yml @@ -297,6 +297,7 @@ jobs: OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} CONTEXTUAL_ORCHESTRATOR_REQUIRE_ZDR: ${{ steps.target_visibility.outputs.require_zdr }} + CONTEXTUAL_ORCHESTRATOR_POOL: auto run: | set -euo pipefail bash "$GITHUB_WORKSPACE/scripts/ci/contextual_orchestrator_review_sidecar.sh" @@ -322,7 +323,7 @@ jobs: fi source "$GITHUB_WORKSPACE/scripts/ci/load_contextual_orchestrator_token.sh" export NOEMA_LLM_API_URL="${CONTEXTUAL_ORCHESTRATOR_BASE_URL%/}/v1/chat/completions" - export NOEMA_LLM_MODEL="orchestrator/free" + export NOEMA_LLM_MODEL="orchestrator/auto" export NOEMA_LLM_API_KEY="${CONTEXTUAL_ORCHESTRATOR_TOKEN}" export NOEMA_LLM_VIA_ORCHESTRATOR=1 python3 scripts/ci/noema_review_gate.py \ From 40bf16d5b0f5a87f239dc55957917c6c1b6c7237 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 23:15:21 +0900 Subject: [PATCH 06/65] fix(noema): batch sidecar route preflight --- CHANGELOG.md | 4 ++ ...ntextual-orchestrator-vendored-free-zdr.md | 8 +++ ...ontextual-orchestrator-vendored-sidecar.md | 13 +++++ ...contextual_orchestrator_review_launcher.py | 58 +++++++++++++++++-- .../contextual_orchestrator_review_sidecar.sh | 31 +++++----- ...l_orchestrator_review_runtime_preflight.py | 30 +++++++++- 6 files changed, 125 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3eab104fc2..ae59bf6af1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ this file. The format follows Keep a Changelog, and versioned releases follow Semantic Versioning where the repository publishes a release. ## [Unreleased] +- Probe contextual-orchestrator review routes in bounded concurrent batches so + a rejected first discovery slice can advance to later routes, and capture the + intentional oversized-body 413 self-test without mislabeling it as provider + discovery failure. - Skip trusted base Python lock materialization for exact-head reviews with no Python source or dependency-manifest changes, while preserving the fail-closed wheel-only path when Python coverage is relevant. diff --git a/docs/adr/0003-contextual-orchestrator-vendored-free-zdr.md b/docs/adr/0003-contextual-orchestrator-vendored-free-zdr.md index 3e40886e5f..54f8a12a95 100644 --- a/docs/adr/0003-contextual-orchestrator-vendored-free-zdr.md +++ b/docs/adr/0003-contextual-orchestrator-vendored-free-zdr.md @@ -105,6 +105,14 @@ all five, and auto-optimize routing by cost. ## Consequences +- **Bounded discovery preflight (2026-08-29):** the sidecar probes at most 24 + selected routes in concurrent batches of four and stops after the first batch + with a usable text route. This preserves a finite startup budget while + allowing a rejected first catalog slice to fall through to later discovered + routes. The intentional oversized-body contract probe captures its expected + 413 diagnostic locally so it cannot be mistaken for provider discovery + failure. Exhausting every bounded batch still fails closed before healthz. + - The autofix/OpenCode review paths no longer hard-code any provider base URL or model id; upstream model selection is delegated to the orchestrator's discovery under the zero-cost pool. Strix uses the separately governed auto diff --git a/docs/doctoring/contextual-orchestrator-vendored-sidecar.md b/docs/doctoring/contextual-orchestrator-vendored-sidecar.md index 15766abcd8..df76fe30e8 100644 --- a/docs/doctoring/contextual-orchestrator-vendored-sidecar.md +++ b/docs/doctoring/contextual-orchestrator-vendored-sidecar.md @@ -33,6 +33,19 @@ streaming/spooling path and provider capability checks; adding `/files` alone would not handle an inline Base64 image in an ordinary JSON request. The sidecar must measure representative Strix envelopes and keep provider/model context failures distinct from its own HTTP framing failure. + +## 2026-08-29 Noema preflight batching + +DiskSage Noema jobs `99111099730` and `99110885279` logged +`request_failed status=413 code=request_too_large` before startup. That line was +the sidecar's intentional oversized `Content-Length` contract test, not model +discovery or a provider response. The actual terminal condition was exhaustion +of the initially selected provider routes before healthz. The contract probe now +captures and asserts its expected diagnostic without emitting it, while runtime +preflight tries a maximum of 24 discovered routes in concurrent batches of four +and stops after the first batch with usable text. Every route still uses the +ten-second timeout, zero retries, the same plain-chat payload, and sanitized +evidence; exhausting the bounded batches remains a startup failure. The pin includes upstream `#887` (`2591b66`), which fixes the gateway's incorrect 1024-character rejection. The same probe sends Strix-shaped function tools with 1025-, 1026-, and 2000-character descriptions and verifies that diff --git a/scripts/ci/contextual_orchestrator_review_launcher.py b/scripts/ci/contextual_orchestrator_review_launcher.py index abb9af3b21..7367081670 100644 --- a/scripts/ci/contextual_orchestrator_review_launcher.py +++ b/scripts/ci/contextual_orchestrator_review_launcher.py @@ -22,6 +22,7 @@ from __future__ import annotations import argparse +from concurrent.futures import ThreadPoolExecutor import json import os from pathlib import Path @@ -39,10 +40,12 @@ # temperatures, while 1.0 is the OpenAI-compatible default. REVIEW_TEMPERATURE = 1.0 # A selected route that cannot answer within ten seconds is not reliable enough -# for a required CI gate. With at most twelve sequential candidates, startup is -# bounded below the sidecar's three-minute readiness deadline. +# for a required CI gate. Four-route batches let discovery try a broader but +# still finite catalog while keeping the worst-case provider wait below the +# sidecar's three-minute readiness deadline. REVIEW_PREFLIGHT_TIMEOUT_SECONDS = 10 -REVIEW_PREFLIGHT_MAX_TOTAL_ROUTES = 12 +REVIEW_PREFLIGHT_BATCH_SIZE = 4 +REVIEW_PREFLIGHT_MAX_TOTAL_ROUTES = 24 REVIEW_PREFLIGHT_PRIMARY_ROUTE_LIMIT = 8 @@ -229,13 +232,13 @@ def _preflight_with_fallback( ) -> tuple[list[object], dict[str, object], bool]: """Use the priced catalog only after every primary route rejects.""" try: - viable, report = _preflight_review_agents(primary_agents, client=client) + viable, report = _preflight_review_agent_batches(primary_agents, client=client) return viable, report, False except ReviewPreflightError as primary_error: if not fallback_agents: raise try: - viable, report = _preflight_review_agents(fallback_agents, client=client) + viable, report = _preflight_review_agent_batches(fallback_agents, client=client) except ReviewPreflightError as fallback_error: fallback_error.report["primary_attempt"] = primary_error.report raise @@ -244,6 +247,51 @@ def _preflight_with_fallback( return viable, report, True +def _preflight_review_agent_batches( + agents: list[object], *, client: Any +) -> tuple[list[object], dict[str, object]]: + """Probe bounded concurrent batches until one batch contains a ready route.""" + attempted_routes: list[dict[str, object]] = [] + attempted_count = 0 + for offset in range(0, len(agents), REVIEW_PREFLIGHT_BATCH_SIZE): + batch = agents[offset : offset + REVIEW_PREFLIGHT_BATCH_SIZE] + with ThreadPoolExecutor(max_workers=len(batch)) as executor: + futures = [ + executor.submit(_preflight_review_agents, [agent], client=client) + for agent in batch + ] + viable: list[object] = [] + for future in futures: + try: + route_viable, route_report = future.result() + except ReviewPreflightError as exc: + route_viable = [] + route_report = exc.report + viable.extend(route_viable) + attempted_routes.extend(route_report["routes"]) + attempted_count += int(route_report["probed_count"]) + if viable: + return viable, { + "contract": "strix-plain-chat-preflight-v1", + "probed_count": attempted_count, + "ready_count": len(viable), + "rejected_count": attempted_count - len(viable), + "routes": attempted_routes, + "batch_size": REVIEW_PREFLIGHT_BATCH_SIZE, + } + report: dict[str, object] = { + "contract": "strix-plain-chat-preflight-v1", + "probed_count": attempted_count, + "ready_count": 0, + "rejected_count": attempted_count, + "routes": attempted_routes, + "batch_size": REVIEW_PREFLIGHT_BATCH_SIZE, + } + raise ReviewPreflightError( + "no provider route passed the Strix plain-chat preflight", report + ) + + def _write_json(path: str, payload: object) -> None: """Write one deterministic UTF-8 JSON evidence file.""" Path(path).write_text( diff --git a/scripts/ci/contextual_orchestrator_review_sidecar.sh b/scripts/ci/contextual_orchestrator_review_sidecar.sh index bca9d5c00e..5f86676595 100755 --- a/scripts/ci/contextual_orchestrator_review_sidecar.sh +++ b/scripts/ci/contextual_orchestrator_review_sidecar.sh @@ -99,6 +99,8 @@ PYTHONPATH="$ORCHESTRATOR_SOURCE:$ORG_REPO_ROOT" "$sidecar_python" -c \ 'from contextual_orchestrator.credentials import get_credential; from contextual_orchestrator.model_discovery import discover_all_models, free_discovered_models; from contextual_orchestrator.orchestrator import ModelClient, TaskOrchestrator, load_agents; from contextual_orchestrator.review_gateway import register_review_credentials; from contextual_orchestrator.server import SecurityConfig, serve' PYTHONPATH="$ORCHESTRATOR_SOURCE:$ORG_REPO_ROOT" "$sidecar_python" - <<'PY' import http.client +import contextlib +import io import json import threading @@ -135,19 +137,22 @@ thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() try: connection = http.client.HTTPConnection("127.0.0.1", server.server_address[1], timeout=5) - connection.request( - "POST", - "/v1/chat/completions", - body=b"", - headers={ - "Authorization": "Bearer contract", - "Content-Type": "application/json", - "Content-Length": str(REVIEW_MAX_BODY_BYTES + 1), - }, - ) - response = connection.getresponse() - assert response.status == 413, response.status - response.read() + expected_rejection_log = io.StringIO() + with contextlib.redirect_stderr(expected_rejection_log): + connection.request( + "POST", + "/v1/chat/completions", + body=b"", + headers={ + "Authorization": "Bearer contract", + "Content-Type": "application/json", + "Content-Length": str(REVIEW_MAX_BODY_BYTES + 1), + }, + ) + response = connection.getresponse() + assert response.status == 413, response.status + response.read() + assert "request_failed status=413 code=request_too_large" in expected_rejection_log.getvalue() connection.close() def post_payload(payload): diff --git a/tests/test_contextual_orchestrator_review_runtime_preflight.py b/tests/test_contextual_orchestrator_review_runtime_preflight.py index baa1a8df76..a01283706d 100644 --- a/tests/test_contextual_orchestrator_review_runtime_preflight.py +++ b/tests/test_contextual_orchestrator_review_runtime_preflight.py @@ -165,6 +165,33 @@ def test_preflight_uses_priced_fallback_only_after_primary_routes_reject() -> No assert failure.value.report["primary_attempt"]["ready_count"] == 0 +def test_preflight_advances_to_next_bounded_batch() -> None: + """Rejected first-batch routes do not hide a later discovered live route.""" + namespace = _load_launcher() + preflight = namespace["_preflight_review_agent_batches"] + batch_size = namespace["REVIEW_PREFLIGHT_BATCH_SIZE"] + agents = [ + SimpleNamespace(id=f"route_{index}", provider_name="openrouter", model=f"model/{index}") + for index in range(batch_size + 1) + ] + outcomes = { + agent.id: TimeoutError("unavailable") for agent in agents[:-1] + } + outcomes[agents[-1].id] = _openai_text("OK") + client = _ProbeClient(outcomes) + + viable, report = preflight(agents, client=client) + + assert viable == [agents[-1]] + assert report["probed_count"] == batch_size + 1 + assert report["ready_count"] == 1 + assert report["batch_size"] == batch_size + assert {call[0].id for call in client.calls[:batch_size]} == { + agent.id for agent in agents[:batch_size] + } + assert client.calls[-1][0] == agents[-1] + + def test_preflight_stage_limits_share_one_startup_budget() -> None: """Free-first and priced-fallback probes share one bounded route budget.""" namespace = _load_launcher() @@ -174,7 +201,7 @@ def test_preflight_stage_limits_share_one_startup_budget() -> None: fallback = namespace["_bounded_fallback_catalog_limit"]( 99, primary_count=primary ) - assert (primary, fallback) == (8, 4) + assert (primary, fallback) == (8, 16) assert primary + fallback == namespace["REVIEW_PREFLIGHT_MAX_TOTAL_ROUTES"] @@ -275,6 +302,7 @@ def test_sidecar_preserves_diagnostics_and_probes_the_real_gateway() -> None: assert '"model":"orchestrator/free"' not in sidecar assert "gateway preflight returned unusable chat content" in sidecar assert 'SIDECAR_LOG_SANITIZER="$ORG_REPO_ROOT/scripts/ci/sanitize_contextual_orchestrator_sidecar_stream.py"' in sidecar + assert "contextlib.redirect_stderr(expected_rejection_log)" in sidecar assert '"$sidecar_python" -u "$SIDECAR_LOG_SANITIZER" > "$sidecar_stdout"' in sidecar assert '"$sidecar_python" -u "$SIDECAR_LOG_SANITIZER" > "$sidecar_stderr"' in sidecar assert '> "$sidecar_stdout" 2> "$sidecar_stderr" &' not in sidecar From 8c2e0ba0f0853eaf4cc3ecd43ad3115355283fd0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 23:21:27 +0900 Subject: [PATCH 07/65] Revert "fix(noema): use free-first auto provider fallback" This reverts commit 4741d8e0450b8537beeb8ec4cfe2ea9213a829e5. --- .github/workflows/noema-review.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/noema-review.yml b/.github/workflows/noema-review.yml index 9affc48bef..5c60782adb 100644 --- a/.github/workflows/noema-review.yml +++ b/.github/workflows/noema-review.yml @@ -297,7 +297,6 @@ jobs: OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} CONTEXTUAL_ORCHESTRATOR_REQUIRE_ZDR: ${{ steps.target_visibility.outputs.require_zdr }} - CONTEXTUAL_ORCHESTRATOR_POOL: auto run: | set -euo pipefail bash "$GITHUB_WORKSPACE/scripts/ci/contextual_orchestrator_review_sidecar.sh" @@ -323,7 +322,7 @@ jobs: fi source "$GITHUB_WORKSPACE/scripts/ci/load_contextual_orchestrator_token.sh" export NOEMA_LLM_API_URL="${CONTEXTUAL_ORCHESTRATOR_BASE_URL%/}/v1/chat/completions" - export NOEMA_LLM_MODEL="orchestrator/auto" + export NOEMA_LLM_MODEL="orchestrator/free" export NOEMA_LLM_API_KEY="${CONTEXTUAL_ORCHESTRATOR_TOKEN}" export NOEMA_LLM_VIA_ORCHESTRATOR=1 python3 scripts/ci/noema_review_gate.py \ From 6bcf304dc78cfdc7dda447f9e70e78061abbdb16 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 23:21:27 +0900 Subject: [PATCH 08/65] Revert "ci(noema): run gateway contracts on changed paths" This reverts commit 0c4f0992f6f4130ce277d8017061246177ff6f0c. --- .../noema-orchestrator-quality-ci.yml | 74 ------------------- 1 file changed, 74 deletions(-) delete mode 100644 .github/workflows/noema-orchestrator-quality-ci.yml diff --git a/.github/workflows/noema-orchestrator-quality-ci.yml b/.github/workflows/noema-orchestrator-quality-ci.yml deleted file mode 100644 index 09b806cbb9..0000000000 --- a/.github/workflows/noema-orchestrator-quality-ci.yml +++ /dev/null @@ -1,74 +0,0 @@ -name: Noema Orchestrator Quality CI - -on: - pull_request: - branches: [main] - paths: - - ".github/workflows/noema-orchestrator-quality-ci.yml" - - ".github/workflows/noema-review.yml" - - "CHANGELOG.md" - - "docs/doctoring/noema-orchestrator-auto-fallback.md" - - "scripts/ci/contextual_orchestrator_review_launcher.py" - - "scripts/ci/contextual_orchestrator_review_policy.py" - - "scripts/ci/contextual_orchestrator_review_sidecar.sh" - - "scripts/ci/load_contextual_orchestrator_token.sh" - - "scripts/ci/zdr_policy.py" - - "tests/test_contextual_orchestrator_review_sidecar_contract.py" - - "tests/test_noema_orchestrator_workflow_contract.py" - -permissions: - contents: read - -concurrency: - group: noema-orchestrator-quality-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - -jobs: - exact-head-contract: - runs-on: ubuntu-24.04 - timeout-minutes: 10 - steps: - - name: Checkout exact source revision - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - ref: ${{ github.event.pull_request.head.sha || github.sha }} - persist-credentials: false - - - name: Set up Python - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - with: - python-version: "3.14" - - - name: Install exact hash-verified test runner dependencies - env: - PIP_DISABLE_PIP_VERSION_CHECK: "1" - PIP_NO_INPUT: "1" - shell: bash --noprofile --norc -e -o pipefail {0} - run: | - cat >"${RUNNER_TEMP}/noema-quality-requirements.txt" <<'EOF' - coverage==7.15.2 --hash=sha256:b9a6367e4aff723e8ee8190836836124284e8fcd4265e307c844010cfa074f3f - iniconfig==2.1.0 --hash=sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760 - packaging==26.2 --hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e - pluggy==1.6.0 --hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746 - pygments==2.20.0 --hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176 - pytest==9.1.1 --hash=sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c - EOF - python -m pip install \ - --only-binary=:all: \ - --require-hashes \ - -r "${RUNNER_TEMP}/noema-quality-requirements.txt" - - - name: Verify exact-head Noema gateway contracts - shell: bash --noprofile --norc -e -o pipefail {0} - run: | - test "$(git rev-parse HEAD)" = "${{ github.event.pull_request.head.sha || github.sha }}" - python -m coverage run -m pytest \ - tests/test_noema_orchestrator_workflow_contract.py \ - tests/test_contextual_orchestrator_review_sidecar_contract.py \ - -q - python -m compileall -q \ - tests/test_noema_orchestrator_workflow_contract.py \ - tests/test_contextual_orchestrator_review_sidecar_contract.py \ - scripts/ci/contextual_orchestrator_review_launcher.py - bash -n scripts/ci/contextual_orchestrator_review_sidecar.sh - git diff --exit-code From 52c43dcad31b0aa872caa5a82587e9553af2fe13 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 23:21:28 +0900 Subject: [PATCH 09/65] Revert "test(noema): require free-first auto fallback route" This reverts commit d10a333c579997383f281184d9fecd0cc0e85ca3. --- tests/test_noema_orchestrator_workflow_contract.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/tests/test_noema_orchestrator_workflow_contract.py b/tests/test_noema_orchestrator_workflow_contract.py index c40a30b5c4..dfa9aa2c8f 100644 --- a/tests/test_noema_orchestrator_workflow_contract.py +++ b/tests/test_noema_orchestrator_workflow_contract.py @@ -1,4 +1,4 @@ -"""Noema review uses the vendored orchestrator auto pool with free-first fallback.""" +"""Noema review now uses the vendored orchestrator sidecar, not NVIDIA NIM.""" from __future__ import annotations @@ -11,8 +11,8 @@ from tests.test_required_workflow_queue_contract import workflow_step, workflow_text -def test_noema_review_credentials_and_llm_use_orchestrator_auto() -> None: - """Require reviewer credentials and the auto sidecar; direct NIM stays absent.""" +def test_noema_review_credentials_and_llm_use_orchestrator_free() -> None: + """Require reviewer credentials and the sidecar; the public NIM hardcode is gone.""" workflow = workflow_text("noema-review.yml") assert "fail_unavailable()" in workflow @@ -32,7 +32,6 @@ def test_noema_review_credentials_and_llm_use_orchestrator_auto() -> None: assert "Resolve Noema target repository visibility" in workflow assert "target_visibility.outputs.require_zdr" in workflow assert "CONTEXTUAL_ORCHESTRATOR_REQUIRE_ZDR" in workflow - assert "CONTEXTUAL_ORCHESTRATOR_POOL: auto" in workflow assert ( "NOEMA_LLM_API_KEY: ${{ secrets.NOEMA_LLM_API_KEY || secrets.OPENAI_API_KEY || '' }}" not in workflow @@ -43,8 +42,7 @@ def test_noema_review_credentials_and_llm_use_orchestrator_auto() -> None: assert "NVIDIA_NIM_API_KEY_SUB: ${{ secrets.NVIDIA_NIM_API_KEY_SUB }}" in workflow assert "OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}" in workflow assert "OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}" in workflow - assert 'export NOEMA_LLM_MODEL="orchestrator/auto"' in workflow - assert 'export NOEMA_LLM_MODEL="orchestrator/free"' not in workflow + assert 'export NOEMA_LLM_MODEL="orchestrator/free"' in workflow assert ( "contextual-orchestrator review sidecar must be provisioned before Noema LLM review." in workflow From 6fd376f16cb2a855de05ac41ec0fcd0e3f9b7a19 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 23:21:49 +0900 Subject: [PATCH 10/65] fix(noema): expose full bounded free catalog --- scripts/ci/contextual_orchestrator_review_launcher.py | 2 +- scripts/ci/contextual_orchestrator_review_sidecar.sh | 2 +- ...t_contextual_orchestrator_review_runtime_preflight.py | 9 +++++++++ 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/scripts/ci/contextual_orchestrator_review_launcher.py b/scripts/ci/contextual_orchestrator_review_launcher.py index 7367081670..48c92b9eac 100644 --- a/scripts/ci/contextual_orchestrator_review_launcher.py +++ b/scripts/ci/contextual_orchestrator_review_launcher.py @@ -470,7 +470,7 @@ def main(argv: list[str] | None = None) -> int: zdr_endpoints=zdr_endpoints, checker=is_zdr_model, ) - requested_catalog_limit = int(os.environ.get("ORCHESTRATOR_CATALOG_LIMIT", "12")) + requested_catalog_limit = int(os.environ.get("ORCHESTRATOR_CATALOG_LIMIT", "24")) primary_limit = _bounded_primary_catalog_limit( requested_catalog_limit, pool=args.pool, has_free_rows=bool(admitted_free_rows) ) diff --git a/scripts/ci/contextual_orchestrator_review_sidecar.sh b/scripts/ci/contextual_orchestrator_review_sidecar.sh index 5f86676595..639de05521 100755 --- a/scripts/ci/contextual_orchestrator_review_sidecar.sh +++ b/scripts/ci/contextual_orchestrator_review_sidecar.sh @@ -29,7 +29,7 @@ STRIX_EVIDENCE_DIR="${GITHUB_WORKSPACE:-$ORCHESTRATOR_WORK}/strix_runs" ORCHESTRATOR_LAUNCHER="${ORCHESTRATOR_LAUNCHER:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/contextual_orchestrator_review_launcher.py}" ORG_REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" SIDECAR_LOG_SANITIZER="$ORG_REPO_ROOT/scripts/ci/sanitize_contextual_orchestrator_sidecar_stream.py" -CATALOG_LIMIT="${ORCHESTRATOR_CATALOG_LIMIT:-12}" +CATALOG_LIMIT="${ORCHESTRATOR_CATALOG_LIMIT:-24}" CATALOG_FAMILY_CAP="${ORCHESTRATOR_CATALOG_FAMILY_CAP:-4}" ORCHESTRATOR_GITHUB_ENV="${GITHUB_ENV:-}" sidecar_python="$(command -v python3)" diff --git a/tests/test_contextual_orchestrator_review_runtime_preflight.py b/tests/test_contextual_orchestrator_review_runtime_preflight.py index a01283706d..95facdc138 100644 --- a/tests/test_contextual_orchestrator_review_runtime_preflight.py +++ b/tests/test_contextual_orchestrator_review_runtime_preflight.py @@ -205,6 +205,15 @@ def test_preflight_stage_limits_share_one_startup_budget() -> None: assert primary + fallback == namespace["REVIEW_PREFLIGHT_MAX_TOTAL_ROUTES"] +def test_production_defaults_expose_the_complete_bounded_catalog() -> None: + """Launcher and shell defaults must not silently restore the old 12-route cap.""" + launcher = _LAUNCHER.read_text(encoding="utf-8") + sidecar = _SIDECAR.read_text(encoding="utf-8") + + assert 'ORCHESTRATOR_CATALOG_LIMIT", "24"' in launcher + assert 'CATALOG_LIMIT="${ORCHESTRATOR_CATALOG_LIMIT:-24}"' in sidecar + + def test_zdr_admission_selects_priced_tier_when_free_routes_are_not_private() -> None: """Privacy admission precedes the free-first tier decision.""" namespace = _load_launcher() From 205b4850de2cbb24d007a2162cb66fe815d11b55 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 23:44:01 +0900 Subject: [PATCH 11/65] fix(opencode): keep bootstrap event-independent --- .github/workflows/opencode-review.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/opencode-review.yml b/.github/workflows/opencode-review.yml index f3e3c24996..d66979d406 100644 --- a/.github/workflows/opencode-review.yml +++ b/.github/workflows/opencode-review.yml @@ -197,7 +197,6 @@ jobs: fi - name: Enforce Cloudflare Pingora edge policy - if: ${{ github.event_name == 'pull_request_target' }} env: GITHUB_TOKEN: ${{ github.token }} TARGET_REPOSITORY: ${{ github.event.pull_request.base.repo.full_name || github.repository }} From e39028c83ce48464b159cff7a28b0cf812d6e08b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 23:47:21 +0900 Subject: [PATCH 12/65] test(opencode): align event-independent bootstrap contract --- tests/test_pingora_edge_workflow_contract.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/test_pingora_edge_workflow_contract.py b/tests/test_pingora_edge_workflow_contract.py index 82a85986a0..ee1f796d10 100644 --- a/tests/test_pingora_edge_workflow_contract.py +++ b/tests/test_pingora_edge_workflow_contract.py @@ -40,7 +40,10 @@ def test_required_workflow_enforces_pingora_without_executing_pr_content() -> No assert '[ -L "$trusted_source_dir/$EXPECTED_FILE" ]' in text assert '[ ! -f "$trusted_source_dir/scripts/ci/pingora_edge_policy.py" ]' in text assert '[ -L "$trusted_source_dir/scripts/ci/pingora_edge_policy.py" ]' in text - assert "if: ${{ github.event_name == 'pull_request_target' }}" in text + # The workflow is already pull_request_target-only. Keeping an event + # expression inside the required bootstrap makes the materialized gate + # depend on caller event payload fields and violates the bootstrap policy. + assert "if: ${{ github.event_name == 'pull_request_target' }}" not in text assert text.index("Verify immutable central policy source") < text.index( "Enforce Cloudflare Pingora edge policy" ) From 14ec0e200ed152a5fee7593358e0139753aeca65 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 09:11:36 -0700 Subject: [PATCH 13/65] docs(noema): align shared preflight budget --- .../0003-contextual-orchestrator-vendored-free-zdr.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/adr/0003-contextual-orchestrator-vendored-free-zdr.md b/docs/adr/0003-contextual-orchestrator-vendored-free-zdr.md index 54f8a12a95..e108ee6e89 100644 --- a/docs/adr/0003-contextual-orchestrator-vendored-free-zdr.md +++ b/docs/adr/0003-contextual-orchestrator-vendored-free-zdr.md @@ -48,10 +48,12 @@ all five, and auto-optimize routing by cost. route rejects the real runtime request contract does it rebuild once from fully price-attested routes and record the rejected primary attempt. This is evidence-triggered failover, not an arbitrary free/paid mixing ratio. - Both stages share one twelve-route startup budget: no more than eight routes - enter the free primary stage and only its remaining capacity may enter priced - fallback. Full discovery counts remain in policy evidence, and the transient - priced catalog is removed immediately after loading. + Both stages share one 24-route startup budget: no more than eight routes + enter the free primary stage and only the remaining capacity (at most sixteen + routes) may enter the price-attested fallback when the `auto` pool is in use. + The `free` pool never admits priced fallback. Full discovery counts remain in + policy evidence, and the transient priced catalog is removed immediately + after loading. 3. **ZDR-first within each cost tier**: `scripts/ci/zdr_policy.py` defines ZDR the way OpenRouter does ("a provider will not store your data for any period of time"; zero retention also implies no training) and is deliberately From c44e8cdb801fdd8670aa0ca72a6bf7a3f2bdaba2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 09:13:00 -0700 Subject: [PATCH 14/65] chore(noema): drop overlapping OpenCode workflow drift --- .github/workflows/opencode-review.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/opencode-review.yml b/.github/workflows/opencode-review.yml index d66979d406..f3e3c24996 100644 --- a/.github/workflows/opencode-review.yml +++ b/.github/workflows/opencode-review.yml @@ -197,6 +197,7 @@ jobs: fi - name: Enforce Cloudflare Pingora edge policy + if: ${{ github.event_name == 'pull_request_target' }} env: GITHUB_TOKEN: ${{ github.token }} TARGET_REPOSITORY: ${{ github.event.pull_request.base.repo.full_name || github.repository }} From 03b7dd803856d49a303add9e64b8fb46ce4f0cd1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 09:13:20 -0700 Subject: [PATCH 15/65] chore(noema): drop overlapping OpenCode contract drift --- tests/test_pingora_edge_workflow_contract.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/test_pingora_edge_workflow_contract.py b/tests/test_pingora_edge_workflow_contract.py index ee1f796d10..82a85986a0 100644 --- a/tests/test_pingora_edge_workflow_contract.py +++ b/tests/test_pingora_edge_workflow_contract.py @@ -40,10 +40,7 @@ def test_required_workflow_enforces_pingora_without_executing_pr_content() -> No assert '[ -L "$trusted_source_dir/$EXPECTED_FILE" ]' in text assert '[ ! -f "$trusted_source_dir/scripts/ci/pingora_edge_policy.py" ]' in text assert '[ -L "$trusted_source_dir/scripts/ci/pingora_edge_policy.py" ]' in text - # The workflow is already pull_request_target-only. Keeping an event - # expression inside the required bootstrap makes the materialized gate - # depend on caller event payload fields and violates the bootstrap policy. - assert "if: ${{ github.event_name == 'pull_request_target' }}" not in text + assert "if: ${{ github.event_name == 'pull_request_target' }}" in text assert text.index("Verify immutable central policy source") < text.index( "Enforce Cloudflare Pingora edge policy" ) From 17f695d82d7560d1ac523a0d3486679b321a043c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 30 Aug 2026 05:18:35 +0900 Subject: [PATCH 16/65] test(strix): align bootstrap path policy --- scripts/ci/test_strix_quick_gate.sh | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/scripts/ci/test_strix_quick_gate.sh b/scripts/ci/test_strix_quick_gate.sh index c44e82c5ab..b435c2cbc0 100644 --- a/scripts/ci/test_strix_quick_gate.sh +++ b/scripts/ci/test_strix_quick_gate.sh @@ -519,9 +519,12 @@ assert_opencode_review_uses_codegraph_and_contextual_orchestrator() { assert_file_not_contains "$workflow_file" "Wait for trusted OpenCode approval review" "opencode pull_request bridge was removed to avoid duplicate required-check resource use" assert_file_not_contains "$workflow_file" "Trusted OpenCode requested changes for head" "opencode pull_request bridge no longer reconsumes stale trusted review state" assert_file_not_contains "$workflow_file" "github.event.pull_request.number == 240" "opencode review workflow must not hard-code repository-specific PR bypasses" - if awk '/^ required-workflow-bootstrap:$/,/^[^ ]/' "$bootstrap_file" | grep -q '^[[:space:]]*if:'; then - record_failure "opencode required workflow bootstrap must not depend on required-workflow event payload fields" - fi + local bootstrap_conditions + bootstrap_conditions="$(awk '/^ required-workflow-bootstrap:$/,/^[^ ]/' "$bootstrap_file" | grep '^[[:space:]]*if:' || true)" + assert_equals \ + " if: \${{ github.event_name == 'pull_request_target' }}" \ + "$bootstrap_conditions" \ + "opencode bootstrap permits only the explicit pull_request_target Pingora policy condition" assert_file_contains "$workflow_file" 'github.event.client_payload.target_repository || github.repository' "opencode review scopes concurrency by target repository" assert_file_contains "$workflow_file" "format('pr-{0}', github.event.client_payload.pr_number)" "opencode review scopes repository_dispatch concurrency by current PR" assert_file_not_contains "$workflow_file" "format('pr-{0}-{1}'" "opencode review does not keep stale head-specific concurrency groups" From 765732d47bc6855f0d1c5e7baab13f124c0542db Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 30 Aug 2026 05:26:56 +0900 Subject: [PATCH 17/65] test(strix): stop bootstrap scan at job boundary --- scripts/ci/test_strix_quick_gate.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/ci/test_strix_quick_gate.sh b/scripts/ci/test_strix_quick_gate.sh index b435c2cbc0..65c045f1ca 100644 --- a/scripts/ci/test_strix_quick_gate.sh +++ b/scripts/ci/test_strix_quick_gate.sh @@ -520,7 +520,7 @@ assert_opencode_review_uses_codegraph_and_contextual_orchestrator() { assert_file_not_contains "$workflow_file" "Trusted OpenCode requested changes for head" "opencode pull_request bridge no longer reconsumes stale trusted review state" assert_file_not_contains "$workflow_file" "github.event.pull_request.number == 240" "opencode review workflow must not hard-code repository-specific PR bypasses" local bootstrap_conditions - bootstrap_conditions="$(awk '/^ required-workflow-bootstrap:$/,/^[^ ]/' "$bootstrap_file" | grep '^[[:space:]]*if:' || true)" + bootstrap_conditions="$(awk '/^ required-workflow-bootstrap:$/ { in_bootstrap = 1; next } in_bootstrap && /^ [^ ]/ { exit } in_bootstrap' "$bootstrap_file" | grep '^[[:space:]]*if:' || true)" assert_equals \ " if: \${{ github.event_name == 'pull_request_target' }}" \ "$bootstrap_conditions" \ From 966c39b5583dd21bb71c5a3e17a8f967d8798c43 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 30 Aug 2026 05:40:05 +0900 Subject: [PATCH 18/65] fix(noema): fail closed on partial discovery --- CHANGELOG.md | 4 +- ...ontextual-orchestrator-vendored-sidecar.md | 4 + ...contextual_orchestrator_review_launcher.py | 157 ++++++++++++++---- ...l_orchestrator_review_runtime_preflight.py | 156 ++++++++++++----- ...al_orchestrator_review_sidecar_contract.py | 15 +- 5 files changed, 262 insertions(+), 74 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ae59bf6af1..7acb733343 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,9 @@ Semantic Versioning where the repository publishes a release. - Probe contextual-orchestrator review routes in bounded concurrent batches so a rejected first discovery slice can advance to later routes, and capture the intentional oversized-body 413 self-test without mislabeling it as provider - discovery failure. + discovery failure. Incomplete provider discovery now writes sanitized + `complete: false` evidence and stops startup instead of serving a partial + catalog. - Skip trusted base Python lock materialization for exact-head reviews with no Python source or dependency-manifest changes, while preserving the fail-closed wheel-only path when Python coverage is relevant. diff --git a/docs/doctoring/contextual-orchestrator-vendored-sidecar.md b/docs/doctoring/contextual-orchestrator-vendored-sidecar.md index df76fe30e8..56be488251 100644 --- a/docs/doctoring/contextual-orchestrator-vendored-sidecar.md +++ b/docs/doctoring/contextual-orchestrator-vendored-sidecar.md @@ -46,6 +46,10 @@ preflight tries a maximum of 24 discovered routes in concurrent batches of four and stops after the first batch with usable text. Every route still uses the ten-second timeout, zero retries, the same plain-chat payload, and sanitized evidence; exhausting the bounded batches remains a startup failure. +Provider discovery must also be complete. If any configured provider reports a +discovery error, the launcher records only sanitized provider and error +identifiers with `complete: false`, omits the partial model list, and stops +before serving traffic. A partial catalog is not availability evidence. The pin includes upstream `#887` (`2591b66`), which fixes the gateway's incorrect 1024-character rejection. The same probe sends Strix-shaped function tools with 1025-, 1026-, and 2000-character descriptions and verifies that diff --git a/scripts/ci/contextual_orchestrator_review_launcher.py b/scripts/ci/contextual_orchestrator_review_launcher.py index 48c92b9eac..01aef81996 100644 --- a/scripts/ci/contextual_orchestrator_review_launcher.py +++ b/scripts/ci/contextual_orchestrator_review_launcher.py @@ -65,7 +65,9 @@ def _has_text_output(model: object) -> bool: return False if isinstance(modalities, str): modalities = (modalities,) - return not modalities or "text" in {str(modality).casefold() for modality in modalities} + return not modalities or "text" in { + str(modality).casefold() for modality in modalities + } def _route_identity(model: object) -> tuple[str, str]: @@ -104,21 +106,30 @@ def _report_rows( model_id = str(getattr(model, "model_id", None) or "") if not provider or not model_id: continue - base_url = str(getattr(model, "chat_base_url", None) or zdr_policy.PROVIDER_BASE_URLS[provider]) + base_url = str( + getattr(model, "chat_base_url", None) + or zdr_policy.PROVIDER_BASE_URLS[provider] + ) credential_key = str( - getattr(model, "credential_name", None) or zdr_policy.PROVIDER_CREDENTIAL_NAMES[provider] + getattr(model, "credential_name", None) + or zdr_policy.PROVIDER_CREDENTIAL_NAMES[provider] ) auth_scheme = str( - getattr(model, "auth_scheme", None) or zdr_policy.PROVIDER_AUTH_SCHEMES[provider] + getattr(model, "auth_scheme", None) + or zdr_policy.PROVIDER_AUTH_SCHEMES[provider] ) rows.append( { "provider": provider, "model": model_id, - "agent_id": str(getattr(model, "agent_id", None) or f"{provider}_{model_id}"), + "agent_id": str( + getattr(model, "agent_id", None) or f"{provider}_{model_id}" + ), "is_free": (provider, model_id) in free_route_identities, "prompt_price_per_1k": getattr(model, "prompt_price_per_1k", None), - "completion_price_per_1k": getattr(model, "completion_price_per_1k", None), + "completion_price_per_1k": getattr( + model, "completion_price_per_1k", None + ), "currency_code": getattr(model, "currency_code", None), "base_url": base_url, "credential_key": credential_key, @@ -153,6 +164,42 @@ def _safe_http_status(exc: Exception) -> int | None: return None +def _sanitized_discovery_errors(errors: list[object]) -> list[dict[str, str]]: + """Return stable provider discovery failures without response text.""" + rows: list[dict[str, str]] = [] + for error in errors: + provider = str(getattr(error, "provider_name", "")) + code = str(getattr(error, "error_code", "")) + rows.append( + { + "provider": provider + if provider.isidentifier() and len(provider) <= 64 + else "unknown", + "error_code": code + if code.isidentifier() and len(code) <= 64 + else "provider_error", + } + ) + return rows + + +def _require_complete_discovery( + discovered: list[object], errors: list[object], output_path: str +) -> list[object]: + """Return a complete catalog or persist sanitized failure evidence.""" + if not errors: + return discovered + _write_json( + output_path, + { + "complete": False, + "models": [], + "errors": _sanitized_discovery_errors(errors), + }, + ) + raise SystemExit("review sidecar discovery incomplete") + + def _preflight_review_agents( agents: list[object], *, client: Any ) -> tuple[list[object], dict[str, object]]: @@ -197,7 +244,9 @@ def _preflight_review_agents( row["status"] = "rejected" error_type = type(exc).__name__ row["error_type"] = ( - error_type if error_type.isidentifier() and len(error_type) <= 64 else "ProviderError" + error_type + if error_type.isidentifier() and len(error_type) <= 64 + else "ProviderError" ) http_status = _safe_http_status(exc) if http_status is not None: @@ -238,7 +287,9 @@ def _preflight_with_fallback( if not fallback_agents: raise try: - viable, report = _preflight_review_agent_batches(fallback_agents, client=client) + viable, report = _preflight_review_agent_batches( + fallback_agents, client=client + ) except ReviewPreflightError as fallback_error: fallback_error.report["primary_attempt"] = primary_error.report raise @@ -311,9 +362,7 @@ def _bounded_primary_catalog_limit( return total_limit -def _bounded_fallback_catalog_limit( - requested_limit: int, *, primary_count: int -) -> int: +def _bounded_fallback_catalog_limit(requested_limit: int, *, primary_count: int) -> int: """Return remaining priced-fallback capacity after primary selection.""" if requested_limit < 1: raise ValueError("ORCHESTRATOR_CATALOG_LIMIT must be positive") @@ -331,9 +380,15 @@ def _with_discovery_counts( enriched.update( { "total_routes": len(rows), - "total_free_routes": sum(row.get("cost_evidence") == "free" for row in rows), - "total_priced_routes": sum(row.get("cost_evidence") == "priced" for row in rows), - "total_unknown_routes": sum(row.get("cost_evidence") == "unknown" for row in rows), + "total_free_routes": sum( + row.get("cost_evidence") == "free" for row in rows + ), + "total_priced_routes": sum( + row.get("cost_evidence") == "priced" for row in rows + ), + "total_unknown_routes": sum( + row.get("cost_evidence") == "unknown" for row in rows + ), } ) return enriched @@ -389,23 +444,52 @@ def main(argv: list[str] | None = None) -> int: preflight, or no auth token is available — the sidecar must fail closed rather than boot a mock or unaudited pool. """ - parser = argparse.ArgumentParser(description="Serve the contextual-orchestrator review sidecar.") + parser = argparse.ArgumentParser( + description="Serve the contextual-orchestrator review sidecar." + ) parser.add_argument("--host", default="127.0.0.1") parser.add_argument("--port", type=int, default=18080) - parser.add_argument("--auth-token", default="", help="Explicit bearer token; else resolve from the KV") - parser.add_argument("--discovery-out", required=True, help="Path to write the free-only discovery report JSON") - parser.add_argument("--catalog-out", required=True, help="Path to write the agents catalog JSON") - parser.add_argument("--report-out", required=True, help="Path to write the policy evidence JSON") - parser.add_argument("--preflight-out", required=True, help="Path to write sanitized runtime preflight JSON") - parser.add_argument("--zdr-endpoints", default=None, help="Optional OpenRouter /api/v1/endpoints/zdr JSON path") + parser.add_argument( + "--auth-token", + default="", + help="Explicit bearer token; else resolve from the KV", + ) + parser.add_argument( + "--discovery-out", + required=True, + help="Path to write the free-only discovery report JSON", + ) + parser.add_argument( + "--catalog-out", required=True, help="Path to write the agents catalog JSON" + ) + parser.add_argument( + "--report-out", required=True, help="Path to write the policy evidence JSON" + ) + parser.add_argument( + "--preflight-out", + required=True, + help="Path to write sanitized runtime preflight JSON", + ) + parser.add_argument( + "--zdr-endpoints", + default=None, + help="Optional OpenRouter /api/v1/endpoints/zdr JSON path", + ) parser.add_argument("--require-zdr", action="store_true") parser.add_argument("--pool", choices=("free", "auto"), default="free") args = parser.parse_args(argv) from contextual_orchestrator.credentials import get_credential from contextual_orchestrator.chat_capability import is_general_chat_agent_model_id - from contextual_orchestrator.model_discovery import discover_all_models, free_discovered_models - from contextual_orchestrator.orchestrator import ModelClient, TaskOrchestrator, load_agents + from contextual_orchestrator.model_discovery import ( + discover_all_models, + free_discovered_models, + ) + from contextual_orchestrator.orchestrator import ( + ModelClient, + TaskOrchestrator, + load_agents, + ) from contextual_orchestrator.review_gateway import ( REVIEW_AUTH_CREDENTIAL_NAME, register_review_credentials, @@ -426,13 +510,23 @@ def main(argv: list[str] | None = None) -> int: "review sidecar requires an explicit --auth-token or the " f"KV credential {REVIEW_AUTH_CREDENTIAL_NAME!r}" ) - if not any(name.startswith(("BYTEZ_", "NVIDIA_", "OPENROUTER_", "OPENAI_")) for name in registered): - raise SystemExit("review sidecar requires at least one provider credential in the KV") + if not any( + name.startswith(("BYTEZ_", "NVIDIA_", "OPENROUTER_", "OPENAI_")) + for name in registered + ): + raise SystemExit( + "review sidecar requires at least one provider credential in the KV" + ) try: - discovered, _ = discover_all_models() - except Exception as exc: # pragma: no cover - provider/networking failure is runtime-only + discovered, discovery_errors = discover_all_models() + except ( + Exception + ) as exc: # pragma: no cover - provider/networking failure is runtime-only raise SystemExit(f"review sidecar discovery failed: {exc}") from exc + discovered = _require_complete_discovery( + list(discovered), list(discovery_errors), args.discovery_out + ) free_models = list(free_discovered_models(discovered)) if discovered else [] free_route_identities = frozenset(_route_identity(model) for model in free_models) selected_models = [] @@ -449,12 +543,13 @@ def main(argv: list[str] | None = None) -> int: ) rows = _report_rows(selected_models, free_route_identities) - _write_json(args.discovery_out, {"models": rows}) + _write_json( + args.discovery_out, + {"complete": True, "models": rows, "errors": []}, + ) zdr_endpoints = _load_zdr_endpoints(args.zdr_endpoints) normalized_rows = parse_discovery_report({"models": rows}) - free_rows = [ - row for row in normalized_rows if row.get("cost_evidence") == "free" - ] + free_rows = [row for row in normalized_rows if row.get("cost_evidence") == "free"] priced_rows = [ row for row in normalized_rows if row.get("cost_evidence") == "priced" ] diff --git a/tests/test_contextual_orchestrator_review_runtime_preflight.py b/tests/test_contextual_orchestrator_review_runtime_preflight.py index 95facdc138..4891231be1 100644 --- a/tests/test_contextual_orchestrator_review_runtime_preflight.py +++ b/tests/test_contextual_orchestrator_review_runtime_preflight.py @@ -15,7 +15,9 @@ _REPO_ROOT = Path(__file__).resolve().parents[1] _LAUNCHER = _REPO_ROOT / "scripts/ci/contextual_orchestrator_review_launcher.py" _SIDECAR = _REPO_ROOT / "scripts/ci/contextual_orchestrator_review_sidecar.sh" -_SANITIZER = _REPO_ROOT / "scripts/ci/sanitize_contextual_orchestrator_sidecar_stream.py" +_SANITIZER = ( + _REPO_ROOT / "scripts/ci/sanitize_contextual_orchestrator_sidecar_stream.py" +) class _ProbeClient: @@ -110,7 +112,9 @@ def test_preflight_fails_closed_when_every_route_rejects() -> None: preflight = namespace.get("_preflight_review_agents") error_type = namespace.get("ReviewPreflightError") assert callable(preflight), "launcher must expose provider-route preflight" - assert isinstance(error_type, type), "launcher must expose a typed preflight failure" + assert isinstance(error_type, type), ( + "launcher must expose a typed preflight failure" + ) agent = SimpleNamespace( id="openrouter_rejected", provider_name="openrouter", model="rejected/free" @@ -135,9 +139,7 @@ def test_preflight_uses_priced_fallback_only_after_primary_routes_reject() -> No {primary.id: TimeoutError("unavailable"), fallback.id: _openai_text("OK")} ) - viable, report, fallback_used = preflight( - [primary], [fallback], client=client - ) + viable, report, fallback_used = preflight([primary], [fallback], client=client) assert viable == [fallback] assert fallback_used is True @@ -171,12 +173,12 @@ def test_preflight_advances_to_next_bounded_batch() -> None: preflight = namespace["_preflight_review_agent_batches"] batch_size = namespace["REVIEW_PREFLIGHT_BATCH_SIZE"] agents = [ - SimpleNamespace(id=f"route_{index}", provider_name="openrouter", model=f"model/{index}") + SimpleNamespace( + id=f"route_{index}", provider_name="openrouter", model=f"model/{index}" + ) for index in range(batch_size + 1) ] - outcomes = { - agent.id: TimeoutError("unavailable") for agent in agents[:-1] - } + outcomes = {agent.id: TimeoutError("unavailable") for agent in agents[:-1]} outcomes[agents[-1].id] = _openai_text("OK") client = _ProbeClient(outcomes) @@ -192,15 +194,47 @@ def test_preflight_advances_to_next_bounded_batch() -> None: assert client.calls[-1][0] == agents[-1] +def test_discovery_errors_are_sanitized_and_fail_closed(tmp_path: Path) -> None: + """A partial provider catalog must retain safe evidence and fail closed.""" + namespace = _load_launcher() + sanitize = namespace["_sanitized_discovery_errors"] + require_complete = namespace["_require_complete_discovery"] + secret = "sk-secret-must-not-enter-evidence" + errors = [ + SimpleNamespace(provider_name="openrouter", error_code="http_status_413"), + SimpleNamespace(provider_name=f"bad/{secret}", error_code=f"failure/{secret}"), + ] + + rows = sanitize(errors) + + assert rows == [ + {"provider": "openrouter", "error_code": "http_status_413"}, + {"provider": "unknown", "error_code": "provider_error"}, + ] + assert secret not in repr(rows) + + evidence_path = tmp_path / "discovery.json" + with pytest.raises(SystemExit, match="review sidecar discovery incomplete"): + require_complete( + [SimpleNamespace(model_id="partial")], errors, str(evidence_path) + ) + assert json.loads(evidence_path.read_text(encoding="utf-8")) == { + "complete": False, + "models": [], + "errors": rows, + } + + complete = [SimpleNamespace(model_id="complete")] + assert require_complete(complete, [], str(evidence_path)) is complete + + def test_preflight_stage_limits_share_one_startup_budget() -> None: """Free-first and priced-fallback probes share one bounded route budget.""" namespace = _load_launcher() primary = namespace["_bounded_primary_catalog_limit"]( 99, pool="auto", has_free_rows=True ) - fallback = namespace["_bounded_fallback_catalog_limit"]( - 99, primary_count=primary - ) + fallback = namespace["_bounded_fallback_catalog_limit"](99, primary_count=primary) assert (primary, fallback) == (8, 16) assert primary + fallback == namespace["REVIEW_PREFLIGHT_MAX_TOTAL_ROUTES"] @@ -247,9 +281,15 @@ def test_discovery_counts_survive_stage_specific_policy_reports() -> None: ] enriched = namespace["_with_discovery_counts"](base, rows) assert base == {"selected_count": 1, "selected": [{"model": "priced/model"}]} - assert [enriched[key] for key in ( - "total_routes", "total_free_routes", "total_priced_routes", "total_unknown_routes" - )] == [4, 1, 2, 1] + assert [ + enriched[key] + for key in ( + "total_routes", + "total_free_routes", + "total_priced_routes", + "total_unknown_routes", + ) + ] == [4, 1, 2, 1] def test_temporary_fallback_catalog_is_removed_after_loading(tmp_path: Path) -> None: @@ -262,7 +302,9 @@ def loader(value: str) -> list[object]: assert json.loads(Path(value).read_text(encoding="utf-8")) == {"agents": agents} return [SimpleNamespace(id="priced_route")] - assert [agent.id for agent in helper(str(path), agents, loader=loader)] == ["priced_route"] + assert [agent.id for agent in helper(str(path), agents, loader=loader)] == [ + "priced_route" + ] assert not path.exists() def failing_loader(value: str) -> list[object]: @@ -292,28 +334,56 @@ def test_sidecar_preserves_diagnostics_and_probes_the_real_gateway() -> None: sidecar = _SIDECAR.read_text(encoding="utf-8") assert "_preflight_with_fallback(" in launcher + assert "_require_complete_discovery(" in launcher + assert '"complete": False' in launcher + assert '"complete": True' in launcher assert "preflight-out" in launcher assert "max_output_tokens=REVIEW_MAX_OUTPUT_TOKENS" in launcher assert "temperature=REVIEW_TEMPERATURE" in launcher - assert 'STRIX_EVIDENCE_DIR="${GITHUB_WORKSPACE:-$ORCHESTRATOR_WORK}/strix_runs"' in sidecar - assert 'sidecar_stdout="$STRIX_EVIDENCE_DIR/contextual-orchestrator-sidecar.stdout.log"' in sidecar - assert 'sidecar_stderr="$STRIX_EVIDENCE_DIR/contextual-orchestrator-sidecar.stderr.log"' in sidecar - assert 'preflight_report="$STRIX_EVIDENCE_DIR/contextual-orchestrator-preflight.json"' in sidecar + assert ( + 'STRIX_EVIDENCE_DIR="${GITHUB_WORKSPACE:-$ORCHESTRATOR_WORK}/strix_runs"' + in sidecar + ) + assert ( + 'sidecar_stdout="$STRIX_EVIDENCE_DIR/contextual-orchestrator-sidecar.stdout.log"' + in sidecar + ) + assert ( + 'sidecar_stderr="$STRIX_EVIDENCE_DIR/contextual-orchestrator-sidecar.stderr.log"' + in sidecar + ) + assert ( + 'preflight_report="$STRIX_EVIDENCE_DIR/contextual-orchestrator-preflight.json"' + in sidecar + ) assert '--preflight-out "$preflight_report"' in sidecar - assert 'gateway_preflight_response="$ORCHESTRATOR_WORK/gateway-preflight.json"' in sidecar - assert '"http://${ORCHESTRATOR_HOST}:${ORCHESTRATOR_PORT}/v1/chat/completions"' in sidecar - assert 'Authorization: Bearer ${ORCHESTRATOR_TOKEN}' in sidecar + assert ( + 'gateway_preflight_response="$ORCHESTRATOR_WORK/gateway-preflight.json"' + in sidecar + ) + assert ( + '"http://${ORCHESTRATOR_HOST}:${ORCHESTRATOR_PORT}/v1/chat/completions"' + in sidecar + ) + assert "Authorization: Bearer ${ORCHESTRATOR_TOKEN}" in sidecar assert 'orchestrator_pool="${CONTEXTUAL_ORCHESTRATOR_POOL:-free}"' in sidecar assert 'gateway_virtual_model="orchestrator/${orchestrator_pool}"' in sidecar assert '"model":"%s"' in sidecar assert '"$gateway_virtual_model" > "$gateway_preflight_request"' in sidecar assert '"model":"orchestrator/free"' not in sidecar assert "gateway preflight returned unusable chat content" in sidecar - assert 'SIDECAR_LOG_SANITIZER="$ORG_REPO_ROOT/scripts/ci/sanitize_contextual_orchestrator_sidecar_stream.py"' in sidecar + assert ( + 'SIDECAR_LOG_SANITIZER="$ORG_REPO_ROOT/scripts/ci/sanitize_contextual_orchestrator_sidecar_stream.py"' + in sidecar + ) assert "contextlib.redirect_stderr(expected_rejection_log)" in sidecar - assert '"$sidecar_python" -u "$SIDECAR_LOG_SANITIZER" > "$sidecar_stdout"' in sidecar - assert '"$sidecar_python" -u "$SIDECAR_LOG_SANITIZER" > "$sidecar_stderr"' in sidecar + assert ( + '"$sidecar_python" -u "$SIDECAR_LOG_SANITIZER" > "$sidecar_stdout"' in sidecar + ) + assert ( + '"$sidecar_python" -u "$SIDECAR_LOG_SANITIZER" > "$sidecar_stderr"' in sidecar + ) assert '> "$sidecar_stdout" 2> "$sidecar_stderr" &' not in sidecar @@ -322,19 +392,29 @@ def test_sidecar_stream_sanitizer_allowlists_only_bounded_diagnostics() -> None: namespace = _load_sanitizer() sanitize_line = namespace["sanitize_line"] - assert sanitize_line( - "request_failed status=500 code=internal_error upstream sk-secret" - ) == "request_failed status=500 code=internal_error" + assert ( + sanitize_line( + "request_failed status=500 code=internal_error upstream sk-secret" + ) + == "request_failed status=500 code=internal_error" + ) assert sanitize_line("client_disconnected") == "client_disconnected" - assert sanitize_line( - "review sidecar preflight failed: upstream sk-secret" - ) == "review sidecar preflight failed" - assert sanitize_line( - "review sidecar discovery failed: https://provider.invalid/?key=sk-secret" - ) == "review sidecar discovery failed" - assert sanitize_line( - "review sidecar discovered no zero-cost models; orchestrator/free would fail closed" - ) == "review sidecar discovered no zero-cost models" + assert ( + sanitize_line("review sidecar preflight failed: upstream sk-secret") + == "review sidecar preflight failed" + ) + assert ( + sanitize_line( + "review sidecar discovery failed: https://provider.invalid/?key=sk-secret" + ) + == "review sidecar discovery failed" + ) + assert ( + sanitize_line( + "review sidecar discovered no zero-cost models; orchestrator/free would fail closed" + ) + == "review sidecar discovered no zero-cost models" + ) assert sanitize_line("provider response sk-secret") is None diff --git a/tests/test_contextual_orchestrator_review_sidecar_contract.py b/tests/test_contextual_orchestrator_review_sidecar_contract.py index a1c0746f03..dc9003e86e 100644 --- a/tests/test_contextual_orchestrator_review_sidecar_contract.py +++ b/tests/test_contextual_orchestrator_review_sidecar_contract.py @@ -280,11 +280,15 @@ def test_launcher_uses_orchestrator_discovery_and_governed_pools() -> None: """Discovery, price evidence, and serving come from the vendored library.""" text = _read(LAUNCHER) assert "from contextual_orchestrator.chat_capability import is_general_chat_agent_model_id" in text - assert "from contextual_orchestrator.model_discovery import discover_all_models, free_discovered_models" in text + normalized = " ".join(text.split()) + assert ( + "from contextual_orchestrator.model_discovery import ( discover_all_models, " + "free_discovered_models, )" in normalized + ) assert "free_discovered_models(discovered)" in text assert 'getattr(model, "output_modalities", None)' in text assert 'isinstance(modalities, str)' in text - assert '"text" in {str(modality).casefold() for modality in modalities}' in text + assert '"text" in { str(modality).casefold() for modality in modalities }' in normalized assert "not _has_text_output(model)" in text assert 'model_id = getattr(model, "model_id", "")' in text @@ -313,8 +317,11 @@ def test_launcher_uses_orchestrator_discovery_and_governed_pools() -> None: rows = report_rows([free, priced], frozenset({("openrouter", "free/model")})) assert [row["is_free"] for row in rows] == [True, False] assert rows[1]["prompt_price_per_1k"] == 0.002 - assert "from contextual_orchestrator.orchestrator import ModelClient, TaskOrchestrator, load_agents" in text - assert "from contextual_orchestrator.server import SecurityConfig, serve" in text + assert ( + "from contextual_orchestrator.orchestrator import ( ModelClient, " + "TaskOrchestrator, load_agents, )" in normalized + ) + assert "from contextual_orchestrator.server import SecurityConfig, serve" in normalized assert 'parser.add_argument("--pool", choices=("free", "auto"), default="free")' in text assert "orchestrator/{args.pool} would fail closed" in text assert "scripts.ci.contextual_orchestrator_review_policy" in text From 85f45b8a3d52eeb054bf23bdabbb156ea043a107 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 30 Aug 2026 06:05:56 +0900 Subject: [PATCH 19/65] fix(noema): do not truncate single-provider route catalog --- CHANGELOG.md | 4 ++++ ...ntextual-orchestrator-vendored-free-zdr.md | 5 ++++- ...ontextual-orchestrator-vendored-sidecar.md | 10 ++++++++++ ...contextual_orchestrator_review_launcher.py | 14 ++++++++++++-- .../contextual_orchestrator_review_sidecar.sh | 2 +- ...l_orchestrator_review_runtime_preflight.py | 19 +++++++++++++++++++ tests/test_pingora_edge_policy.py | 12 ++++++++++++ 7 files changed, 62 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7acb733343..8391aec262 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ this file. The format follows Keep a Changelog, and versioned releases follow Semantic Versioning where the repository publishes a release. ## [Unreleased] +- Keep the sidecar's provider-family cap aligned with its 24-route total + startup budget by default, so a single provider catalog is not truncated to + four routes before bounded preflight; explicit `ORCHESTRATOR_CATALOG_FAMILY_CAP` + overrides remain honored. - Probe contextual-orchestrator review routes in bounded concurrent batches so a rejected first discovery slice can advance to later routes, and capture the intentional oversized-body 413 self-test without mislabeling it as provider diff --git a/docs/adr/0003-contextual-orchestrator-vendored-free-zdr.md b/docs/adr/0003-contextual-orchestrator-vendored-free-zdr.md index e108ee6e89..d0edc226bc 100644 --- a/docs/adr/0003-contextual-orchestrator-vendored-free-zdr.md +++ b/docs/adr/0003-contextual-orchestrator-vendored-free-zdr.md @@ -72,7 +72,10 @@ all five, and auto-optimize routing by cost. report into a free-first, cost-evidence-ranked, ZDR-prioritized, provider-family-diverse agents catalog (primary/secondary NVIDIA keys share one outage-domain family), capped in size, in the orchestrator's own - `ModelAgent` schema. + `ModelAgent` schema. The sidecar defaults the family cap to the same 24-route + total budget so a single configured provider's catalog is not silently + truncated before preflight; `ORCHESTRATOR_CATALOG_FAMILY_CAP` remains an + explicit operator override for a stricter cap. 4. **Wiring**: `pr-review-autofix.yml` and the Required OpenCode dispatch provision the sidecar with the five secrets before OpenCode runs and point every model/diagnosis candidate at `contextual-orchestrator/orchestrator/free`; diff --git a/docs/doctoring/contextual-orchestrator-vendored-sidecar.md b/docs/doctoring/contextual-orchestrator-vendored-sidecar.md index 56be488251..f768d8fec3 100644 --- a/docs/doctoring/contextual-orchestrator-vendored-sidecar.md +++ b/docs/doctoring/contextual-orchestrator-vendored-sidecar.md @@ -57,6 +57,16 @@ each reaches the provider payload byte-for-byte; arbitrary truncation is not used. Provider/model-specific context limits remain provider errors, not a reason for this gateway to rewrite the request. +## 2026-08-30 Provider-family catalog bound + +The 24-route startup budget is a total bound, not a promise to stop after four +routes from the first provider family. The sidecar now defaults +`ORCHESTRATOR_CATALOG_FAMILY_CAP` to 24, allowing an OpenRouter-only discovery +catalog to expose every route within the same bounded preflight budget. An +operator may still set a lower explicit family cap when outage-domain diversity +is more important than route breadth; the generic policy CLI retains its +independent family-cap default. + ## What changed `pr-review-autofix.yml` now provisions the sidecar diff --git a/scripts/ci/contextual_orchestrator_review_launcher.py b/scripts/ci/contextual_orchestrator_review_launcher.py index 01aef81996..993feaf86a 100644 --- a/scripts/ci/contextual_orchestrator_review_launcher.py +++ b/scripts/ci/contextual_orchestrator_review_launcher.py @@ -372,6 +372,16 @@ def _bounded_fallback_catalog_limit(requested_limit: int, *, primary_count: int) return total_limit - primary_count +def _catalog_family_cap() -> int: + """Return the configured family cap without narrowing the bounded default.""" + return int( + os.environ.get( + "ORCHESTRATOR_CATALOG_FAMILY_CAP", + str(REVIEW_PREFLIGHT_MAX_TOTAL_ROUTES), + ) + ) + + def _with_discovery_counts( report: dict[str, object], rows: list[dict[str, Any]] ) -> dict[str, object]: @@ -577,7 +587,7 @@ def main(argv: list[str] | None = None) -> int: result = build_zdr_prioritized_catalog( primary_rows, limit=primary_limit, - family_cap=int(os.environ.get("ORCHESTRATOR_CATALOG_FAMILY_CAP", "4")), + family_cap=_catalog_family_cap(), zdr_endpoints=zdr_endpoints, require_zdr=args.require_zdr, pool=args.pool, @@ -606,7 +616,7 @@ def main(argv: list[str] | None = None) -> int: fallback_result = build_zdr_prioritized_catalog( admitted_priced_rows, limit=fallback_limit, - family_cap=int(os.environ.get("ORCHESTRATOR_CATALOG_FAMILY_CAP", "4")), + family_cap=_catalog_family_cap(), zdr_endpoints=zdr_endpoints, require_zdr=args.require_zdr, pool="auto", diff --git a/scripts/ci/contextual_orchestrator_review_sidecar.sh b/scripts/ci/contextual_orchestrator_review_sidecar.sh index 639de05521..d1414ce6d8 100755 --- a/scripts/ci/contextual_orchestrator_review_sidecar.sh +++ b/scripts/ci/contextual_orchestrator_review_sidecar.sh @@ -30,7 +30,7 @@ ORCHESTRATOR_LAUNCHER="${ORCHESTRATOR_LAUNCHER:-$(cd "$(dirname "${BASH_SOURCE[0 ORG_REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" SIDECAR_LOG_SANITIZER="$ORG_REPO_ROOT/scripts/ci/sanitize_contextual_orchestrator_sidecar_stream.py" CATALOG_LIMIT="${ORCHESTRATOR_CATALOG_LIMIT:-24}" -CATALOG_FAMILY_CAP="${ORCHESTRATOR_CATALOG_FAMILY_CAP:-4}" +CATALOG_FAMILY_CAP="${ORCHESTRATOR_CATALOG_FAMILY_CAP:-24}" ORCHESTRATOR_GITHUB_ENV="${GITHUB_ENV:-}" sidecar_python="$(command -v python3)" diff --git a/tests/test_contextual_orchestrator_review_runtime_preflight.py b/tests/test_contextual_orchestrator_review_runtime_preflight.py index 4891231be1..c6cb6e3b80 100644 --- a/tests/test_contextual_orchestrator_review_runtime_preflight.py +++ b/tests/test_contextual_orchestrator_review_runtime_preflight.py @@ -246,6 +246,25 @@ def test_production_defaults_expose_the_complete_bounded_catalog() -> None: assert 'ORCHESTRATOR_CATALOG_LIMIT", "24"' in launcher assert 'CATALOG_LIMIT="${ORCHESTRATOR_CATALOG_LIMIT:-24}"' in sidecar + assert "REVIEW_PREFLIGHT_MAX_TOTAL_ROUTES = 24" in launcher + assert '"ORCHESTRATOR_CATALOG_FAMILY_CAP",' in launcher + assert "str(REVIEW_PREFLIGHT_MAX_TOTAL_ROUTES)" in launcher + assert 'CATALOG_FAMILY_CAP="${ORCHESTRATOR_CATALOG_FAMILY_CAP:-24}"' in sidecar + + +def test_family_cap_default_covers_the_bounded_catalog_and_honors_overrides( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The default does not truncate one provider, while an explicit cap remains binding.""" + namespace = _load_launcher() + family_cap = namespace["_catalog_family_cap"] + assert callable(family_cap) + + monkeypatch.delenv("ORCHESTRATOR_CATALOG_FAMILY_CAP", raising=False) + assert family_cap() == 24 + + monkeypatch.setenv("ORCHESTRATOR_CATALOG_FAMILY_CAP", "4") + assert family_cap() == 4 def test_zdr_admission_selects_priced_tier_when_free_routes_are_not_private() -> None: diff --git a/tests/test_pingora_edge_policy.py b/tests/test_pingora_edge_policy.py index 584e540749..4f70ec1bf5 100644 --- a/tests/test_pingora_edge_policy.py +++ b/tests/test_pingora_edge_policy.py @@ -295,6 +295,18 @@ def test_changed_file_pagination_bound_is_fail_closed() -> None: policy._load_changed_files("api", "a/b", 1, "x", lambda _url, _token: page) +def test_changed_file_pagination_rejects_nonterminating_full_pages() -> None: + """A full-page response on every bounded request fails closed.""" + + class NonTerminatingPage(list[dict[str, object]]): + def __len__(self) -> int: + return 100 + + page = NonTerminatingPage() + with pytest.raises(policy.PolicyError, match="3,000"): + policy._load_changed_files("api", "a/b", 1, "x", lambda _url, _token: page) + + def test_changed_file_pagination_accepts_the_inclusive_bound() -> None: """Exactly 3,000 changed files are accepted only after an empty next page.""" From 6ac9e01a0972f2e903152202537ce25724e7a94e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 30 Aug 2026 06:09:58 +0900 Subject: [PATCH 20/65] test(noema): prove single-provider catalog breadth --- ...l_orchestrator_review_runtime_preflight.py | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/test_contextual_orchestrator_review_runtime_preflight.py b/tests/test_contextual_orchestrator_review_runtime_preflight.py index c6cb6e3b80..e33b1f9cbd 100644 --- a/tests/test_contextual_orchestrator_review_runtime_preflight.py +++ b/tests/test_contextual_orchestrator_review_runtime_preflight.py @@ -256,12 +256,32 @@ def test_family_cap_default_covers_the_bounded_catalog_and_honors_overrides( monkeypatch: pytest.MonkeyPatch, ) -> None: """The default does not truncate one provider, while an explicit cap remains binding.""" + from scripts.ci import contextual_orchestrator_review_policy as policy + namespace = _load_launcher() family_cap = namespace["_catalog_family_cap"] assert callable(family_cap) monkeypatch.delenv("ORCHESTRATOR_CATALOG_FAMILY_CAP", raising=False) assert family_cap() == 24 + rows = [ + { + "provider": "openrouter", + "model": f"model/{index}", + "agent_id": f"openrouter_model_{index}", + "is_free": True, + "prompt_price_per_1k": 0.0, + "completion_price_per_1k": 0.0, + "currency_code": "USD", + } + for index in range(24) + ] + catalog = policy.build_zdr_prioritized_catalog( + policy.parse_discovery_report({"models": rows}), + limit=24, + family_cap=family_cap(), + ) + assert len(catalog["agents"]) == 24 monkeypatch.setenv("ORCHESTRATOR_CATALOG_FAMILY_CAP", "4") assert family_cap() == 4 From f0c768fe9e48444b8bac14d3fa2957db4e7d5585 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 30 Aug 2026 07:16:12 +0900 Subject: [PATCH 21/65] fix(review): classify unavailable gateway evidence --- .github/workflows/security-scan.yml | 18 ++++++++++++++++-- CHANGELOG.md | 4 ++++ .../doctoring/dependency-review-fail-closed.md | 6 +++++- .../contextual_orchestrator_review_launcher.py | 2 ++ .../contextual_orchestrator_review_sidecar.sh | 14 +++++++++++--- ...al_orchestrator_review_runtime_preflight.py | 6 ++++++ tests/test_required_workflow_queue_contract.py | 7 ++++++- 7 files changed, 50 insertions(+), 7 deletions(-) diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index 148e944310..f374475b75 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -339,8 +339,22 @@ jobs: echo "DEPENDENCY_REVIEW_SUPPORT repository=${REPOSITORY} visibility=${repository_visibility} base_sha=${BASE_SHA} head_sha=${HEAD_SHA} http_status=${http_status} curl_exit=${curl_status}" - if [ "$curl_status" -ne 0 ] || [ "$http_status" != "200" ]; then - echo "::error::Dependency review evidence unavailable for ${REPOSITORY} at exact base ${BASE_SHA} and head ${HEAD_SHA}: HTTP ${http_status}; curl exit ${curl_status}. Verify dependency-graph/security configuration and GitHub service behavior, then rerun. Failing closed." + evidence_state="complete" + unavailable_reason="none" + if [ "$curl_status" -ne 0 ]; then + evidence_state="unavailable" + unavailable_reason="transport" + elif [ "$http_status" = "403" ]; then + evidence_state="unavailable" + unavailable_reason="api_authorization" + elif [ "$http_status" != "200" ]; then + evidence_state="unavailable" + unavailable_reason="api_response" + fi + echo "DEPENDENCY_REVIEW_EVIDENCE state=${evidence_state} reason=${unavailable_reason} repository=${REPOSITORY} visibility=${repository_visibility} http_status=${http_status} curl_exit=${curl_status}" + + if [ "$evidence_state" != "complete" ]; then + echo "::error::Dependency review evidence unavailable for ${REPOSITORY} at exact base ${BASE_SHA} and head ${HEAD_SHA}: classification ${unavailable_reason}; HTTP ${http_status}; curl exit ${curl_status}. This is not a vulnerability-free result. Verify dependency-graph/security configuration and GitHub service behavior, then rerun. Failing closed." exit 1 fi diff --git a/CHANGELOG.md b/CHANGELOG.md index 8391aec262..5c78100bc6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ this file. The format follows Keep a Changelog, and versioned releases follow Semantic Versioning where the repository publishes a release. ## [Unreleased] +- Keep serving-time model calls on the same bounded timeout and zero-retry + policy proven during sidecar startup, distinguish loopback transport timeouts + from connection failures, and classify dependency-review API denial as + unavailable evidence without treating it as vulnerability-free. - Keep the sidecar's provider-family cap aligned with its 24-route total startup budget by default, so a single provider catalog is not truncated to four routes before bounded preflight; explicit `ORCHESTRATOR_CATALOG_FAMILY_CAP` diff --git a/docs/doctoring/dependency-review-fail-closed.md b/docs/doctoring/dependency-review-fail-closed.md index 81681d3f0c..e6d05937d3 100644 --- a/docs/doctoring/dependency-review-fail-closed.md +++ b/docs/doctoring/dependency-review-fail-closed.md @@ -19,7 +19,11 @@ Checks, status contexts, review submissions, and merge authorization remain sepa ## Failure classification and remediation - Transport exit `0` plus HTTP `200`: proceed to the pinned dependency-review action. -- Any other result: fail the job and retain exact repository/base/head/status and transport-exit evidence. An HTTP `200` emitted by a failed or partial transfer is unavailable evidence. +- Any other result: emit `DEPENDENCY_REVIEW_EVIDENCE state=unavailable` with a + bounded reason (`transport`, `api_authorization`, or `api_response`), fail the + job, and retain exact repository/base/head/status and transport-exit evidence. + The classification is diagnostic evidence, never a vulnerability-free result. + An HTTP `200` emitted by a failed or partial transfer is unavailable evidence. - Public repository failure: verify dependency graph and security configuration, organization policy, token read access, and GitHub service health. - Private or internal exception: require a separately reviewed organization policy with explicit entitlement evidence and compensating controls. Never infer `not-applicable` from an unavailable response. diff --git a/scripts/ci/contextual_orchestrator_review_launcher.py b/scripts/ci/contextual_orchestrator_review_launcher.py index 993feaf86a..cbd0aae811 100644 --- a/scripts/ci/contextual_orchestrator_review_launcher.py +++ b/scripts/ci/contextual_orchestrator_review_launcher.py @@ -661,7 +661,9 @@ def main(argv: list[str] | None = None) -> int: _write_json(args.preflight_out, preflight_report) client = ModelClient( + timeout=REVIEW_PREFLIGHT_TIMEOUT_SECONDS, max_output_tokens=REVIEW_MAX_OUTPUT_TOKENS, + max_retries=0, temperature=REVIEW_TEMPERATURE, ) orchestrator = TaskOrchestrator(agents, client=client) diff --git a/scripts/ci/contextual_orchestrator_review_sidecar.sh b/scripts/ci/contextual_orchestrator_review_sidecar.sh index d1414ce6d8..f8edc412a4 100755 --- a/scripts/ci/contextual_orchestrator_review_sidecar.sh +++ b/scripts/ci/contextual_orchestrator_review_sidecar.sh @@ -328,7 +328,8 @@ log "healthz and provider-route preflight confirmed after ${i}s (pid $sidecar_pi gateway_virtual_model="orchestrator/${orchestrator_pool}" printf '{"model":"%s","messages":[{"role":"system","content":"You are a helpful assistant."},{"role":"user","content":"Reply with just '\''OK'\''."}],"temperature":1.0,"max_tokens":16,"stream":false}\n' \ "$gateway_virtual_model" > "$gateway_preflight_request" -if ! gateway_http_status="$( +set +e +gateway_http_status="$( curl -sS --max-time 30 \ -o "$gateway_preflight_response" \ -w '%{http_code}' \ @@ -337,8 +338,15 @@ if ! gateway_http_status="$( -H 'Content-Type: application/json' \ --data-binary "@$gateway_preflight_request" \ "http://${ORCHESTRATOR_HOST}:${ORCHESTRATOR_PORT}/v1/chat/completions" -)"; then - fail "gateway preflight request could not reach the local sidecar" +)" +gateway_curl_status=$? +set -e +if [ "$gateway_curl_status" -ne 0 ]; then + gateway_transport_status="transport_error" + if [ "$gateway_curl_status" -eq 28 ]; then + gateway_transport_status="transport_timeout" + fi + fail "gateway preflight ${gateway_transport_status} (curl exit ${gateway_curl_status})" fi if [ "$gateway_http_status" != "200" ]; then "$sidecar_python" - "$preflight_report" "$gateway_preflight_response" "$gateway_http_status" <<'PY' diff --git a/tests/test_contextual_orchestrator_review_runtime_preflight.py b/tests/test_contextual_orchestrator_review_runtime_preflight.py index e33b1f9cbd..61e3bf67d5 100644 --- a/tests/test_contextual_orchestrator_review_runtime_preflight.py +++ b/tests/test_contextual_orchestrator_review_runtime_preflight.py @@ -378,6 +378,8 @@ def test_sidecar_preserves_diagnostics_and_probes_the_real_gateway() -> None: assert '"complete": True' in launcher assert "preflight-out" in launcher assert "max_output_tokens=REVIEW_MAX_OUTPUT_TOKENS" in launcher + assert launcher.count("timeout=REVIEW_PREFLIGHT_TIMEOUT_SECONDS") == 2 + assert launcher.count("max_retries=0") == 2 assert "temperature=REVIEW_TEMPERATURE" in launcher assert ( @@ -397,6 +399,10 @@ def test_sidecar_preserves_diagnostics_and_probes_the_real_gateway() -> None: in sidecar ) assert '--preflight-out "$preflight_report"' in sidecar + assert "gateway_curl_status=$?" in sidecar + assert 'gateway_transport_status="transport_timeout"' in sidecar + assert "gateway preflight ${gateway_transport_status}" in sidecar + assert "gateway preflight request could not reach the local sidecar" not in sidecar assert ( 'gateway_preflight_response="$ORCHESTRATOR_WORK/gateway-preflight.json"' in sidecar diff --git a/tests/test_required_workflow_queue_contract.py b/tests/test_required_workflow_queue_contract.py index a00b0c4260..71df34a412 100644 --- a/tests/test_required_workflow_queue_contract.py +++ b/tests/test_required_workflow_queue_contract.py @@ -1358,7 +1358,8 @@ def test_security_scan_fails_closed_when_dependency_review_is_unavailable() -> N assert "/dependency-graph/compare/${BASE_SHA}...${HEAD_SHA}" in workflow assert "repository: ${{ github.event.pull_request.head.repo.full_name }}" in workflow assert "ref: ${{ github.event.pull_request.head.sha }}" in workflow - assert 'if [ "$curl_status" -ne 0 ] || [ "$http_status" != "200" ]; then' in workflow + assert 'if [ "$curl_status" -ne 0 ]; then' in support_probe + assert 'elif [ "$http_status" != "200" ]; then' in support_probe assert "--connect-timeout 10" in workflow assert "--max-time 30" in workflow assert "-o /dev/null" in workflow @@ -1380,6 +1381,10 @@ def test_security_scan_fails_closed_when_dependency_review_is_unavailable() -> N ) assert "supported=false" not in workflow assert "skipping dependency-review hard gate" not in workflow + assert 'evidence_state="unavailable"' in support_probe + assert 'unavailable_reason="api_authorization"' in support_probe + assert "This is not a vulnerability-free result" in support_probe + assert 'if [ "$evidence_state" != "complete" ]; then' in support_probe assert ( "steps.dependency_review_support.outputs.supported == 'true'" in workflow ) From a281686e8ab8ca224510ef0f4f560a955a8ea069 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 30 Aug 2026 09:50:30 +0900 Subject: [PATCH 22/65] fix(review): separate startup and serving timeouts --- CHANGELOG.md | 7 ++-- ...ntextual-orchestrator-vendored-free-zdr.md | 6 ++++ ...ontextual-orchestrator-vendored-sidecar.md | 11 ++++++ ...contextual_orchestrator_review_launcher.py | 27 ++++++++------ ...l_orchestrator_review_runtime_preflight.py | 36 +++++++++++++------ 5 files changed, 64 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c78100bc6..6410dc25b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,9 +5,10 @@ this file. The format follows Keep a Changelog, and versioned releases follow Semantic Versioning where the repository publishes a release. ## [Unreleased] -- Keep serving-time model calls on the same bounded timeout and zero-retry - policy proven during sidecar startup, distinguish loopback transport timeouts - from connection failures, and classify dependency-review API denial as +- Keep startup route probes on a ten-second timeout while giving serving-time + model calls the Noema gate's 120-second transport budget; both retain the + same zero-retry policy. Distinguish loopback transport timeouts from + connection failures, and classify dependency-review API denial as unavailable evidence without treating it as vulnerability-free. - Keep the sidecar's provider-family cap aligned with its 24-route total startup budget by default, so a single provider catalog is not truncated to diff --git a/docs/adr/0003-contextual-orchestrator-vendored-free-zdr.md b/docs/adr/0003-contextual-orchestrator-vendored-free-zdr.md index d0edc226bc..063247573b 100644 --- a/docs/adr/0003-contextual-orchestrator-vendored-free-zdr.md +++ b/docs/adr/0003-contextual-orchestrator-vendored-free-zdr.md @@ -118,6 +118,12 @@ all five, and auto-optimize routing by cost. 413 diagnostic locally so it cannot be mistaken for provider discovery failure. Exhausting every bounded batch still fails closed before healthz. +- **Separate startup and serving budgets (2026-08-30):** route admission keeps + the ten-second timeout so unavailable providers cannot delay healthz, while + the serving `ModelClient` uses the Noema gate's 120-second transport budget. + Both phases keep zero retries and the same bounded request policy; the + launcher test verifies the two constructed client configurations separately. + - The autofix/OpenCode review paths no longer hard-code any provider base URL or model id; upstream model selection is delegated to the orchestrator's discovery under the zero-cost pool. Strix uses the separately governed auto diff --git a/docs/doctoring/contextual-orchestrator-vendored-sidecar.md b/docs/doctoring/contextual-orchestrator-vendored-sidecar.md index f768d8fec3..60fff17ba4 100644 --- a/docs/doctoring/contextual-orchestrator-vendored-sidecar.md +++ b/docs/doctoring/contextual-orchestrator-vendored-sidecar.md @@ -67,6 +67,17 @@ operator may still set a lower explicit family cap when outage-domain diversity is more important than route breadth; the generic policy CLI retains its independent family-cap default. +## 2026-08-30 Startup and serving timeout separation + +The ten-second route timeout is a startup-admission budget: a route that does +not answer the bounded readiness probe quickly enough is excluded before +healthz. It must not also bound the real review request. The serving +`ModelClient` now uses the 120-second transport budget already used by the +Noema review gate, while retaining zero retries and the same output-token and +temperature policy. The launcher test constructs both client policies and +asserts their distinct timeouts; it does not infer the contract from duplicate +source text. + ## What changed `pr-review-autofix.yml` now provisions the sidecar diff --git a/scripts/ci/contextual_orchestrator_review_launcher.py b/scripts/ci/contextual_orchestrator_review_launcher.py index cbd0aae811..71795c5230 100644 --- a/scripts/ci/contextual_orchestrator_review_launcher.py +++ b/scripts/ci/contextual_orchestrator_review_launcher.py @@ -44,6 +44,9 @@ # still finite catalog while keeping the worst-case provider wait below the # sidecar's three-minute readiness deadline. REVIEW_PREFLIGHT_TIMEOUT_SECONDS = 10 +# The serving call has the same 120-second transport budget as the Noema review +# gate; startup admission stays short so an unavailable route cannot delay healthz. +REVIEW_SERVING_TIMEOUT_SECONDS = 120 REVIEW_PREFLIGHT_BATCH_SIZE = 4 REVIEW_PREFLIGHT_MAX_TOTAL_ROUTES = 24 REVIEW_PREFLIGHT_PRIMARY_ROUTE_LIMIT = 8 @@ -58,6 +61,16 @@ def __init__(self, message: str, report: dict[str, object]) -> None: self.report = report +def _build_model_client(client_type: Any, *, timeout: int) -> Any: + """Build a no-retry client with the transport policy for its lifecycle phase.""" + return client_type( + timeout=timeout, + max_output_tokens=REVIEW_MAX_OUTPUT_TOKENS, + max_retries=0, + temperature=REVIEW_TEMPERATURE, + ) + + def _has_text_output(model: object) -> bool: """Return whether a discovered model can emit text responses.""" modalities = getattr(model, "output_modalities", None) @@ -636,11 +649,8 @@ def main(argv: list[str] | None = None) -> int: fallback_result["agents"], loader=load_agents, ) - client = ModelClient( - timeout=REVIEW_PREFLIGHT_TIMEOUT_SECONDS, - max_output_tokens=REVIEW_MAX_OUTPUT_TOKENS, - max_retries=0, - temperature=REVIEW_TEMPERATURE, + client = _build_model_client( + ModelClient, timeout=REVIEW_PREFLIGHT_TIMEOUT_SECONDS ) try: agents, preflight_report, fallback_used = _preflight_with_fallback( @@ -660,11 +670,8 @@ def main(argv: list[str] | None = None) -> int: _write_json(args.report_out, result["report"]) _write_json(args.preflight_out, preflight_report) - client = ModelClient( - timeout=REVIEW_PREFLIGHT_TIMEOUT_SECONDS, - max_output_tokens=REVIEW_MAX_OUTPUT_TOKENS, - max_retries=0, - temperature=REVIEW_TEMPERATURE, + client = _build_model_client( + ModelClient, timeout=REVIEW_SERVING_TIMEOUT_SECONDS ) orchestrator = TaskOrchestrator(agents, client=client) serve( diff --git a/tests/test_contextual_orchestrator_review_runtime_preflight.py b/tests/test_contextual_orchestrator_review_runtime_preflight.py index 61e3bf67d5..183d846d6b 100644 --- a/tests/test_contextual_orchestrator_review_runtime_preflight.py +++ b/tests/test_contextual_orchestrator_review_runtime_preflight.py @@ -356,15 +356,29 @@ def failing_loader(value: str) -> list[object]: def test_preflight_transport_is_bounded_and_provider_neutral() -> None: - """Sequential route probes must fit inside the sidecar startup budget.""" - launcher = _LAUNCHER.read_text(encoding="utf-8") + """Startup probes stay short while serving gets the Noema review budget.""" + namespace = _load_launcher() - assert "REVIEW_MAX_OUTPUT_TOKENS = 4096" in launcher - assert "REVIEW_TEMPERATURE = 1.0" in launcher - assert "REVIEW_PREFLIGHT_TIMEOUT_SECONDS = 10" in launcher - assert "timeout=REVIEW_PREFLIGHT_TIMEOUT_SECONDS" in launcher - assert "max_retries=0" in launcher - assert "temperature=REVIEW_TEMPERATURE" in launcher + class CaptureClient: + instances: list[dict[str, object]] = [] + + def __init__(self, **kwargs: object) -> None: + self.__class__.instances.append(kwargs) + + build_client = namespace["_build_model_client"] + build_client( + CaptureClient, timeout=namespace["REVIEW_PREFLIGHT_TIMEOUT_SECONDS"] + ) + build_client(CaptureClient, timeout=namespace["REVIEW_SERVING_TIMEOUT_SECONDS"]) + + preflight, serving = CaptureClient.instances + + assert preflight["timeout"] == 10 + assert serving["timeout"] == 120 + assert preflight["timeout"] != serving["timeout"] + assert preflight["max_output_tokens"] == serving["max_output_tokens"] == 4096 + assert preflight["max_retries"] == serving["max_retries"] == 0 + assert preflight["temperature"] == serving["temperature"] == 1.0 def test_sidecar_preserves_diagnostics_and_probes_the_real_gateway() -> None: @@ -378,8 +392,10 @@ def test_sidecar_preserves_diagnostics_and_probes_the_real_gateway() -> None: assert '"complete": True' in launcher assert "preflight-out" in launcher assert "max_output_tokens=REVIEW_MAX_OUTPUT_TOKENS" in launcher - assert launcher.count("timeout=REVIEW_PREFLIGHT_TIMEOUT_SECONDS") == 2 - assert launcher.count("max_retries=0") == 2 + assert "REVIEW_SERVING_TIMEOUT_SECONDS = 120" in launcher + assert "timeout=REVIEW_PREFLIGHT_TIMEOUT_SECONDS" in launcher + assert "timeout=REVIEW_SERVING_TIMEOUT_SECONDS" in launcher + assert launcher.count("max_retries=0") == 1 assert "temperature=REVIEW_TEMPERATURE" in launcher assert ( From fcc376fc220f356dbe2f1d2dd445c46d1a9b5a1d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 30 Aug 2026 09:52:10 +0900 Subject: [PATCH 23/65] test(review): pin sidecar preflight contract --- ...extual_orchestrator_review_sidecar_contract.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/test_contextual_orchestrator_review_sidecar_contract.py b/tests/test_contextual_orchestrator_review_sidecar_contract.py index dc9003e86e..c6cbfb2c4f 100644 --- a/tests/test_contextual_orchestrator_review_sidecar_contract.py +++ b/tests/test_contextual_orchestrator_review_sidecar_contract.py @@ -71,6 +71,21 @@ def test_sidecar_adr_names_the_current_vendored_revision() -> None: assert ORCH_PIN_SHA in _read(SIDECAR_ADR) +def test_sidecar_and_adr_pin_the_bounded_preflight_contract() -> None: + """Runtime defaults and the accepted prose must describe one startup budget.""" + launcher = _read(LAUNCHER) + sidecar = _read(SIDECAR) + adr = _read(SIDECAR_ADR) + + assert 'CATALOG_LIMIT="${ORCHESTRATOR_CATALOG_LIMIT:-24}"' in sidecar + assert 'CATALOG_FAMILY_CAP="${ORCHESTRATOR_CATALOG_FAMILY_CAP:-24}"' in sidecar + assert "REVIEW_PREFLIGHT_MAX_TOTAL_ROUTES = 24" in launcher + assert "REVIEW_PREFLIGHT_BATCH_SIZE = 4" in launcher + assert "at most 24" in adr + assert "concurrent batches of four" in adr + assert "fails closed before healthz" in adr + + def test_sidecar_requires_the_five_provider_secrets() -> None: """At least one of the five secrets must be present as bootstrap transport.""" text = _read(SIDECAR) From 5032c3ace245d03af6053cf789eb7f7c88cc9f84 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 30 Aug 2026 11:46:05 +0900 Subject: [PATCH 24/65] fix(sidecar): smoke virtual pools in route mode --- CHANGELOG.md | 3 +++ .../0003-contextual-orchestrator-vendored-free-zdr.md | 5 +++++ .../contextual-orchestrator-vendored-sidecar.md | 11 +++++++++++ scripts/ci/contextual_orchestrator_review_sidecar.sh | 2 +- ...ontextual_orchestrator_review_runtime_preflight.py | 1 + 5 files changed, 21 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6410dc25b1..6b1da9a7e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ Semantic Versioning where the repository publishes a release. same zero-retry policy. Distinguish loopback transport timeouts from connection failures, and classify dependency-review API denial as unavailable evidence without treating it as vulnerability-free. +- Make the sidecar gateway smoke request explicit `orchestration: route` so it + exercises the direct virtual-pool path used by tool-bearing reviews without + invoking auto-mode triage; provider response errors remain fail-closed. - Keep the sidecar's provider-family cap aligned with its 24-route total startup budget by default, so a single provider catalog is not truncated to four routes before bounded preflight; explicit `ORCHESTRATOR_CATALOG_FAMILY_CAP` diff --git a/docs/adr/0003-contextual-orchestrator-vendored-free-zdr.md b/docs/adr/0003-contextual-orchestrator-vendored-free-zdr.md index 063247573b..26d2cf2917 100644 --- a/docs/adr/0003-contextual-orchestrator-vendored-free-zdr.md +++ b/docs/adr/0003-contextual-orchestrator-vendored-free-zdr.md @@ -124,6 +124,11 @@ all five, and auto-optimize routing by cost. Both phases keep zero retries and the same bounded request policy; the launcher test verifies the two constructed client configurations separately. +- **Direct gateway smoke (2026-08-30):** the sidecar's startup request selects + explicit `route` orchestration so it validates the direct virtual-pool path + used by tool-bearing reviews without invoking auto-mode triage. Provider + response validation and fail-closed non-200 handling are unchanged. + - The autofix/OpenCode review paths no longer hard-code any provider base URL or model id; upstream model selection is delegated to the orchestrator's discovery under the zero-cost pool. Strix uses the separately governed auto diff --git a/docs/doctoring/contextual-orchestrator-vendored-sidecar.md b/docs/doctoring/contextual-orchestrator-vendored-sidecar.md index 60fff17ba4..900da681e4 100644 --- a/docs/doctoring/contextual-orchestrator-vendored-sidecar.md +++ b/docs/doctoring/contextual-orchestrator-vendored-sidecar.md @@ -78,6 +78,17 @@ temperature policy. The launcher test constructs both client policies and asserts their distinct timeouts; it does not infer the contract from duplicate source text. +## 2026-08-30 Gateway smoke route mode + +The exact-head PR #1415 preflight found usable routes, but its gateway smoke +request then defaulted to `auto` orchestration and reached the pinned +server's triage/conduct path. That path returned `invalid_structured_output` +with HTTP 502 even though route admission had succeeded. The smoke request now +sets `orchestration: route`, which exercises the direct virtual-pool path used +by tool-bearing Strix requests and avoids an unrelated auto-mode triage call. +This changes only the smoke request mode: provider response validation and the +fail-closed treatment of every non-200 response remain unchanged. + ## What changed `pr-review-autofix.yml` now provisions the sidecar diff --git a/scripts/ci/contextual_orchestrator_review_sidecar.sh b/scripts/ci/contextual_orchestrator_review_sidecar.sh index f8edc412a4..ef6221c790 100755 --- a/scripts/ci/contextual_orchestrator_review_sidecar.sh +++ b/scripts/ci/contextual_orchestrator_review_sidecar.sh @@ -326,7 +326,7 @@ log "healthz and provider-route preflight confirmed after ${i}s (pid $sidecar_pi # internal error, which is the failure this contract prevents from reaching the # scanner step. gateway_virtual_model="orchestrator/${orchestrator_pool}" -printf '{"model":"%s","messages":[{"role":"system","content":"You are a helpful assistant."},{"role":"user","content":"Reply with just '\''OK'\''."}],"temperature":1.0,"max_tokens":16,"stream":false}\n' \ +printf '{"model":"%s","orchestration":"route","messages":[{"role":"system","content":"You are a helpful assistant."},{"role":"user","content":"Reply with just '\''OK'\''."}],"temperature":1.0,"max_tokens":16,"stream":false}\n' \ "$gateway_virtual_model" > "$gateway_preflight_request" set +e gateway_http_status="$( diff --git a/tests/test_contextual_orchestrator_review_runtime_preflight.py b/tests/test_contextual_orchestrator_review_runtime_preflight.py index 183d846d6b..31ed918878 100644 --- a/tests/test_contextual_orchestrator_review_runtime_preflight.py +++ b/tests/test_contextual_orchestrator_review_runtime_preflight.py @@ -431,6 +431,7 @@ def test_sidecar_preserves_diagnostics_and_probes_the_real_gateway() -> None: assert 'orchestrator_pool="${CONTEXTUAL_ORCHESTRATOR_POOL:-free}"' in sidecar assert 'gateway_virtual_model="orchestrator/${orchestrator_pool}"' in sidecar assert '"model":"%s"' in sidecar + assert '"orchestration":"route"' in sidecar assert '"$gateway_virtual_model" > "$gateway_preflight_request"' in sidecar assert '"model":"orchestrator/free"' not in sidecar assert "gateway preflight returned unusable chat content" in sidecar From a832eb0166cc821b1e90f906020c15ea804cf3b9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 30 Aug 2026 11:59:45 +0900 Subject: [PATCH 25/65] fix(noema): route sidecar reviews directly --- CHANGELOG.md | 4 ++-- ...ntextual-orchestrator-vendored-free-zdr.md | 10 +++++---- ...ontextual-orchestrator-vendored-sidecar.md | 6 ++++-- scripts/ci/noema_review_gate.py | 2 ++ tests/test_noema_review_gate.py | 21 +++++++++++++++++++ 5 files changed, 35 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b1da9a7e3..5f52eff7b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,8 +10,8 @@ Semantic Versioning where the repository publishes a release. same zero-retry policy. Distinguish loopback transport timeouts from connection failures, and classify dependency-review API denial as unavailable evidence without treating it as vulnerability-free. -- Make the sidecar gateway smoke request explicit `orchestration: route` so it - exercises the direct virtual-pool path used by tool-bearing reviews without +- Make sidecar-backed gateway requests explicit `orchestration: route` so the + smoke and Noema review paths exercise the direct virtual-pool route without invoking auto-mode triage; provider response errors remain fail-closed. - Keep the sidecar's provider-family cap aligned with its 24-route total startup budget by default, so a single provider catalog is not truncated to diff --git a/docs/adr/0003-contextual-orchestrator-vendored-free-zdr.md b/docs/adr/0003-contextual-orchestrator-vendored-free-zdr.md index 26d2cf2917..2a6cbd18d9 100644 --- a/docs/adr/0003-contextual-orchestrator-vendored-free-zdr.md +++ b/docs/adr/0003-contextual-orchestrator-vendored-free-zdr.md @@ -124,10 +124,12 @@ all five, and auto-optimize routing by cost. Both phases keep zero retries and the same bounded request policy; the launcher test verifies the two constructed client configurations separately. -- **Direct gateway smoke (2026-08-30):** the sidecar's startup request selects - explicit `route` orchestration so it validates the direct virtual-pool path - used by tool-bearing reviews without invoking auto-mode triage. Provider - response validation and fail-closed non-200 handling are unchanged. +- **Direct gateway requests (2026-08-30):** the sidecar's startup request and + Noema request select explicit `route` orchestration so they validate and use + the direct virtual-pool path without invoking auto-mode triage. The Noema + change is limited to the exact process-local sidecar origin; external + OpenAI-compatible URLs retain their original payload. Provider response + validation and fail-closed non-200 handling are unchanged. - The autofix/OpenCode review paths no longer hard-code any provider base URL or model id; upstream model selection is delegated to the orchestrator's diff --git a/docs/doctoring/contextual-orchestrator-vendored-sidecar.md b/docs/doctoring/contextual-orchestrator-vendored-sidecar.md index 900da681e4..fe561b6b2b 100644 --- a/docs/doctoring/contextual-orchestrator-vendored-sidecar.md +++ b/docs/doctoring/contextual-orchestrator-vendored-sidecar.md @@ -85,8 +85,10 @@ request then defaulted to `auto` orchestration and reached the pinned server's triage/conduct path. That path returned `invalid_structured_output` with HTTP 502 even though route admission had succeeded. The smoke request now sets `orchestration: route`, which exercises the direct virtual-pool path used -by tool-bearing Strix requests and avoids an unrelated auto-mode triage call. -This changes only the smoke request mode: provider response validation and the +by Noema's strict-JSON request and tool-bearing Strix requests and avoids an +unrelated auto-mode triage call. Noema now sends the same mode when its API URL +matches the process-local sidecar origin; unrelated external OpenAI-compatible +URLs retain their original payload. Provider response validation and the fail-closed treatment of every non-200 response remain unchanged. ## What changed diff --git a/scripts/ci/noema_review_gate.py b/scripts/ci/noema_review_gate.py index c8c55b65e9..be377d4ad5 100644 --- a/scripts/ci/noema_review_gate.py +++ b/scripts/ci/noema_review_gate.py @@ -570,6 +570,8 @@ def call_llm( prompt, ], } + if is_allowed_orchestrator_sidecar_url(api_url): + payload["orchestration"] = "route" request = urllib.request.Request( api_url, data=json.dumps(payload).encode("utf-8"), diff --git a/tests/test_noema_review_gate.py b/tests/test_noema_review_gate.py index 408bb95b98..b1c08c9bf8 100644 --- a/tests/test_noema_review_gate.py +++ b/tests/test_noema_review_gate.py @@ -432,6 +432,27 @@ def fake_getaddrinfo_invalid_ip(host, port, *args, **kwargs): assert noema.call_llm("owner/repo", 1, pr, "diff", True)["decision"] == "approve" +def test_call_llm_selects_direct_route_for_the_process_local_sidecar(monkeypatch): + """The sidecar-backed Noema request must bypass the gateway's auto triage.""" + pr = make_pr() + monkeypatch.setenv("CONTEXTUAL_ORCHESTRATOR_BASE_URL", "http://127.0.0.1:18080") + monkeypatch.setenv("NOEMA_LLM_API_URL", "http://127.0.0.1:18080/v1/chat/completions") + monkeypatch.setenv("NOEMA_LLM_API_KEY", "secret") + seen = {} + + def fake_urlopen(request, timeout): + seen["body"] = json.loads(request.data.decode("utf-8")) + return FakeResponse({"choices": [{"message": {"content": '{"decision":"approve","summary":"ok","findings":[]}'}}]}) + + class FakeOpener: + def open(self, request, timeout=None): + return fake_urlopen(request, timeout) + + monkeypatch.setattr(noema.urllib.request, "build_opener", lambda *args: FakeOpener()) + assert noema.call_llm("owner/repo", 1, pr, "diff", False)["decision"] == "approve" + assert seen["body"]["orchestration"] == "route" + + def test_noema_redirect_handler_rejects_redirects(): """Noema must not follow redirects after validating the initial URL.""" handler = noema.NoRedirectHandler() From 55867cd7aafcb41c1f099fba39c39d739fb178db Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 03:02:09 +0000 Subject: [PATCH 26/65] fix(ci): restore missing pull_request_target guard on Pingora policy step test_strix_quick_gate.sh pins an exact contract: the required-workflow-bootstrap job's "Enforce Cloudflare Pingora edge policy" step must carry an explicit `if: ${{ github.event_name == 'pull_request_target' }}` guard. A prior merge into this branch dropped the line (following a since-superseded "redundant guard" removal elsewhere), breaking the pinned exact-head-path-policy contract test on this PR's head. Restore it. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01KPmJErfkcHer4UVEgrQxUX --- .github/workflows/opencode-review.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/opencode-review.yml b/.github/workflows/opencode-review.yml index d66979d406..f3e3c24996 100644 --- a/.github/workflows/opencode-review.yml +++ b/.github/workflows/opencode-review.yml @@ -197,6 +197,7 @@ jobs: fi - name: Enforce Cloudflare Pingora edge policy + if: ${{ github.event_name == 'pull_request_target' }} env: GITHUB_TOKEN: ${{ github.token }} TARGET_REPOSITORY: ${{ github.event.pull_request.base.repo.full_name || github.repository }} From b0917a64db3fc9b5ae6657abc29f1ffb7223b021 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 03:08:33 +0000 Subject: [PATCH 27/65] fix(ci): coordinate sidecar startup watchdog with launcher's real worst case Devin Review flagged a real bug on this PR's head (2937e520): the sidecar shell script's health-loop watchdog killed the review sidecar after a fixed 180s, but the launcher's own startup sequence (discovery, then batched preflight probing) can legitimately need more than that -- a run behaving correctly, just spending its own allotted budget, was misreported as "sidecar failed to become healthy" because the two budgets were never actually coordinated. Verified against the literal current constants (not Devin's original rough ~105s/~100s estimate) rather than reusing it blindly: - discover_all_models() worst case: 7 sequential HTTP calls (shared models.dev fetch + one per bootstrapped provider credential [openai/openrouter/nvidia_nim/nvidia_nim_sub/bytez] + the OpenRouter ZDR endpoints fetch) at DISCOVERY_TIMEOUT_SECONDS=15.0s each = 105.0s. - Batched preflight worst case: ceil(24/4)=6 batches x 2 x 10s = 120s (already documented in-code; confirmed still accurate). - Combined real worst case: 225s -- already over the previous flat 180s watchdog on its own, before any headroom. Fix (option a, single source of truth): the launcher now defines REVIEW_DISCOVERY_TIMEOUT_SECONDS, REVIEW_DISCOVERY_MAX_SEQUENTIAL_HTTP_CALLS, REVIEW_DISCOVERY_WORST_CASE_SECONDS, REVIEW_PREFLIGHT_WORST_CASE_SECONDS, REVIEW_STARTUP_HEADROOM_SECONDS (30s, explicit and justified), and REVIEW_STARTUP_WATCHDOG_SECONDS = 255 (their sum). The launcher module's top-level imports are stdlib-only, so contextual_orchestrator_review_sidecar.sh imports REVIEW_STARTUP_WATCHDOG_SECONDS directly (no vendored dependency needed yet at that point in the script) instead of hard-coding its own timeout, so a future change to either phase's budget constants cannot silently desynchronize the two again. Fail-closed behavior is preserved: a genuinely stuck process is still killed, just after the correct deadline. Adds a purely static regression test (no timing simulation, non-flaky in CI) that recomputes both worst cases independently from the primitive constants, asserts REVIEW_STARTUP_WATCHDOG_SECONDS covers their sum with non-negative headroom, locks in the real numbers (105.0 / 120 / 255), and asserts the shell script actually imports the constant rather than hard-coding "180". Also updates the existing fallback-escalation worst-case test to compare against the launcher's own REVIEW_PREFLIGHT_WORST_CASE_SECONDS instead of the stale bare 180 literal. Also fixes a smaller doc-accuracy finding from the same review round: docs/doctoring/contextual-orchestrator-vendored-sidecar.md claimed "any provider discovery error stops startup," but the launcher actually logs each provider failure and continues with whatever succeeded, only failing closed if the resulting eligible-model set is empty -- matching CHANGELOG.md's already-endorsed "log the failure, continue with whatever succeeded" description. Prose-only change, no behavior change. Verification: coverage run -m pytest tests (2099 passed, 1 pre-existing unrelated failure -- see PR comment); coverage report --show-missing (100% on scripts/ci, per pyproject.toml); interrogate (100% docstrings); bash -n on the sidecar script; git diff --check; and the contextual-orchestrator review launcher/sidecar/policy/live-discovery test files run together (144 passed). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01KPmJErfkcHer4UVEgrQxUX --- ...ontextual-orchestrator-vendored-sidecar.md | 20 ++- ...contextual_orchestrator_review_launcher.py | 104 ++++++++++++--- .../contextual_orchestrator_review_sidecar.sh | 52 ++++++-- ...l_orchestrator_review_runtime_preflight.py | 124 ++++++++++++++++-- 4 files changed, 260 insertions(+), 40 deletions(-) diff --git a/docs/doctoring/contextual-orchestrator-vendored-sidecar.md b/docs/doctoring/contextual-orchestrator-vendored-sidecar.md index fe561b6b2b..e613639112 100644 --- a/docs/doctoring/contextual-orchestrator-vendored-sidecar.md +++ b/docs/doctoring/contextual-orchestrator-vendored-sidecar.md @@ -46,10 +46,22 @@ preflight tries a maximum of 24 discovered routes in concurrent batches of four and stops after the first batch with usable text. Every route still uses the ten-second timeout, zero retries, the same plain-chat payload, and sanitized evidence; exhausting the bounded batches remains a startup failure. -Provider discovery must also be complete. If any configured provider reports a -discovery error, the launcher records only sanitized provider and error -identifiers with `complete: false`, omits the partial model list, and stops -before serving traffic. A partial catalog is not availability evidence. +Provider discovery failures are non-fatal per provider, not a whole-run gate. +When one configured provider's discovery call fails, the launcher logs a +sanitized `provider_discovery_failed provider=... code=...` diagnostic to +stderr (never partial provider response text) and continues with whatever +models the other providers successfully returned; it does not stop startup +and does not require the whole discovery pass to be error-free. Startup only +fails closed if the resulting eligible-model set ends up empty -- no +provider's discovery succeeded at all, or none of what did succeed contains a +general-chat, text-output model matching the selected pool +(`orchestrator/free` or `orchestrator/auto`). A partial catalog assembled from +N-1 successful providers is real availability evidence, not an aborted run. +An earlier "fail closed on any partial provider discovery error" design was +considered and rejected in favor of this "log the failure, continue with +whatever succeeded" behavior once production evidence showed single-provider +hiccups are common and should not be fatal to the whole pool; see +`CHANGELOG.md`'s `[Unreleased]` entry for that decision. The pin includes upstream `#887` (`2591b66`), which fixes the gateway's incorrect 1024-character rejection. The same probe sends Strix-shaped function tools with 1025-, 1026-, and 2000-character descriptions and verifies that diff --git a/scripts/ci/contextual_orchestrator_review_launcher.py b/scripts/ci/contextual_orchestrator_review_launcher.py index 74865d3fbc..4f8fdffb5a 100644 --- a/scripts/ci/contextual_orchestrator_review_launcher.py +++ b/scripts/ci/contextual_orchestrator_review_launcher.py @@ -80,29 +80,99 @@ # REVIEW_PREFLIGHT_MAX_TOTAL_ROUTES=24 candidates in batches of # REVIEW_PREFLIGHT_BATCH_SIZE=4, that is ceil(24/4)=6 batches; even the fully # pessimistic bound of every batch needing its worst case is -# 6 * 2 * 10 = 120s, comfortably under the sidecar's 180s healthz-readiness -# wait -- with real margin, since REVIEW_PREFLIGHT_MAX_ESCALATIONS=4 means at -# most 4 of those 6 batches can actually contain an escalating candidate. See +# 6 * 2 * 10 = 120s (REVIEW_PREFLIGHT_WORST_CASE_SECONDS below), since +# REVIEW_PREFLIGHT_MAX_ESCALATIONS=4 means at most 4 of those 6 batches can +# actually contain an escalating candidate. See # docs/adr/0005-sidecar-preflight-token-budget.md, Decision section 3 for the # ADR's own (pre-batching, sequential) 160s derivation of this same shared # cap's value; batching changes the wall-clock arithmetic, not the cap itself. # -# KNOWN GAP, tracked (not yet fixed): the bound above covers only probing, not -# the discover_all_models() call that runs before it inside the SAME 180s -# watchdog. Verified directly against the vendored contextual-orchestrator -# source: discover_all_models() makes up to ~7 sequential HTTP calls (the -# shared models.dev fetch, one per PROVIDER_MODEL_SOURCES entry with a -# registered credential, and the OpenRouter ZDR endpoint fetch), each up to -# DISCOVERY_TIMEOUT_SECONDS = 15s -- up to ~105s worst case, before probing -# even starts. See ContextualWisdomLab/.github#1455 for the tracked fix (a -# shared monotonic deadline, scaled-down probing, or an evidence-justified -# watchdog extension) and #1454 for the related, separately-tracked gap that -# a base-probe *success* never confirms the candidate at the real serving -# budget (REVIEW_MAX_OUTPUT_TOKENS). Neither blocks this design; both are -# architecturally significant enough to need their own design pass rather -# than a guessed patch here. +# FIXED (ContextualWisdomLab/.github#1455, Devin Review finding "Startup +# watchdog preempts valid preflight"): the bound above covers only probing, +# not the discover_all_models() call that runs before it, inside the SAME +# sidecar startup watchdog (contextual_orchestrator_review_sidecar.sh). Both +# phases run sequentially in one process before the server can start +# accepting `/healthz`, so the watchdog must cover their SUM, not either one +# alone -- previously the watchdog was a bare, uncoordinated 180s shell +# constant that only happened to exceed the probing-only figure above by +# coincidence, while the combined real worst case (see +# REVIEW_STARTUP_WATCHDOG_SECONDS below) is larger than that. Verified +# directly against the vendored contextual-orchestrator source at +# ORCHESTRATOR_PIN_SHA (contextual_orchestrator_review_sidecar.sh): +# discover_all_models() makes up to REVIEW_DISCOVERY_MAX_SEQUENTIAL_HTTP_CALLS +# sequential HTTP calls (the shared models.dev fetch; one per +# PROVIDER_MODEL_SOURCES entry with a registered credential -- of the sidecar's +# five bootstrapped secrets, that is openai/openrouter/nvidia_nim/ +# nvidia_nim_sub/bytez, since opencode_zen's OPENCODE_ZEN_API_KEY is never one +# of the five secrets the sidecar registers and so it always short-circuits +# with zero calls; and the OpenRouter ZDR endpoint fetch, unconditional), each +# up to REVIEW_DISCOVERY_TIMEOUT_SECONDS. contextual_orchestrator_review_sidecar.sh +# imports REVIEW_STARTUP_WATCHDOG_SECONDS from this module (a stdlib-only, +# dependency-free import) as its watchdog loop bound -- a single source of +# truth so a future change to either phase's constants cannot silently +# desynchronize the two budgets again. #1454 (a base-probe *success* never +# confirms the candidate at the real serving budget, REVIEW_MAX_OUTPUT_TOKENS) +# is a separate, still-open gap this fix does not address. REVIEW_PREFLIGHT_MAX_ESCALATIONS = 4 +# Mirrors contextual_orchestrator.model_discovery.DISCOVERY_TIMEOUT_SECONDS at +# ORCHESTRATOR_PIN_SHA (contextual_orchestrator_review_sidecar.sh) exactly. Not +# imported directly: that module's own dependency tree is only installed after +# the sidecar's vendoring step, while this constant must be readable earlier +# (this module's top-level imports are deliberately stdlib-only). Re-verify +# this mirror whenever ORCHESTRATOR_PIN_SHA moves. +REVIEW_DISCOVERY_TIMEOUT_SECONDS = 15.0 +# Verified against the vendored contextual_orchestrator.model_discovery source +# at ORCHESTRATOR_PIN_SHA: discover_all_models() calls, strictly sequentially, +# one shared models.dev fetch (triggered once any source with a registered +# credential declares models_dev_provider_id), then discover_provider_models() +# once per PROVIDER_MODEL_SOURCES entry with a registered credential (skipped +# instantly, no HTTP call, for an entry with none), then one unconditional +# OpenRouter ZDR endpoints fetch. With every one of the sidecar's five +# bootstrapped secrets present (openai, openrouter, nvidia_nim, nvidia_nim_sub, +# bytez -- opencode_zen is never among them), that is 1 (models.dev) + 5 +# (providers) + 1 (ZDR) = 7 sequential calls, each independently bounded by +# REVIEW_DISCOVERY_TIMEOUT_SECONDS. +REVIEW_DISCOVERY_MAX_SEQUENTIAL_HTTP_CALLS = 7 +REVIEW_DISCOVERY_WORST_CASE_SECONDS = ( + REVIEW_DISCOVERY_MAX_SEQUENTIAL_HTTP_CALLS * REVIEW_DISCOVERY_TIMEOUT_SECONDS +) +# The batched-probing worst case derived in the comment above +# REVIEW_PREFLIGHT_MAX_ESCALATIONS: ceil(REVIEW_PREFLIGHT_MAX_TOTAL_ROUTES / +# REVIEW_PREFLIGHT_BATCH_SIZE) batches, each up to +# 2 * REVIEW_PREFLIGHT_TIMEOUT_SECONDS (one base + one escalated attempt, +# sequential within a single candidate's own thread). +REVIEW_PREFLIGHT_WORST_CASE_SECONDS = ( + -(-REVIEW_PREFLIGHT_MAX_TOTAL_ROUTES // REVIEW_PREFLIGHT_BATCH_SIZE) +) * 2 * REVIEW_PREFLIGHT_TIMEOUT_SECONDS +# Explicit, justified slack beyond the two computed network-bound worst cases +# above, for the parts of startup that formula does not (and should not try +# to) model precisely: Python interpreter/module import overhead, in-memory +# catalog construction and JSON evidence-file writes, and the sidecar shell +# script's own 1-second `/healthz` polling granularity. None of those is +# individually large, but the fix here is specifically about correcting a +# previously-absent deadline, not about shaving margin as tight as possible -- +# a generous, explicit constant is preferable to a precise-looking one that +# quietly under-covers real (non-network) startup cost. Deliberately kept +# small relative to the two network-bound terms above so it cannot itself +# mask a future regression in either of them. +REVIEW_STARTUP_HEADROOM_SECONDS = 30 +# The single source of truth for the sidecar's startup watchdog. Both startup +# phases (discovery, then batched preflight probing) run sequentially in one +# process before `/healthz` can respond, so the watchdog covering both must be +# their sum, not either phase's own bound alone. +# contextual_orchestrator_review_sidecar.sh imports this exact constant +# (rather than hard-coding its own timeout) so a future change to any input +# constant above automatically propagates to the watchdog, instead of +# silently reintroducing the coordination bug this fixes +# (ContextualWisdomLab/.github#1415, Devin Review "Startup watchdog preempts +# valid preflight"). +REVIEW_STARTUP_WATCHDOG_SECONDS = int( + REVIEW_DISCOVERY_WORST_CASE_SECONDS + + REVIEW_PREFLIGHT_WORST_CASE_SECONDS + + REVIEW_STARTUP_HEADROOM_SECONDS +) + class _EscalationBudget: """Thread-safe shared counter bounding ADR-0005 escalation retries. diff --git a/scripts/ci/contextual_orchestrator_review_sidecar.sh b/scripts/ci/contextual_orchestrator_review_sidecar.sh index b214ea12b2..7fadb36433 100755 --- a/scripts/ci/contextual_orchestrator_review_sidecar.sh +++ b/scripts/ci/contextual_orchestrator_review_sidecar.sh @@ -319,6 +319,33 @@ case "$orchestrator_pool" in ;; esac +# Single source of truth for the startup watchdog below (Devin Review finding +# "Startup watchdog preempts valid preflight", ContextualWisdomLab/.github#1415): +# read the launcher's own coordinated worst-case constant instead of a +# hard-coded shell timeout, so a future change to either discovery's or +# preflight's own budget constants in contextual_orchestrator_review_launcher.py +# cannot silently desynchronize from this watchdog again. The launcher module's +# top-level imports are deliberately stdlib-only (see its module docstring), +# so this works with plain "$ORG_REPO_ROOT" on PYTHONPATH -- no vendored +# dependency needed yet at this point in the script. +sidecar_startup_watchdog_seconds="$( + PYTHONPATH="$ORG_REPO_ROOT" "$sidecar_python" -c \ + 'from scripts.ci.contextual_orchestrator_review_launcher import REVIEW_STARTUP_WATCHDOG_SECONDS; print(REVIEW_STARTUP_WATCHDOG_SECONDS)' +)" || fail "could not derive the startup watchdog seconds from the launcher module" +# Same digit-count defense as REVIEW_PREFLIGHT_GATEWAY_MAX_ATTEMPTS below: a +# non-numeric value would make the "$i" -ge "$sidecar_startup_watchdog_seconds" +# comparison itself a bash integer-comparison error rather than a controlled +# failure, and an all-digit value can still overflow the shell's integer +# range the same way. Six digits (up to 999999s, over eleven days) is already +# far beyond any realistic startup budget and stays safely representable. +case "$sidecar_startup_watchdog_seconds" in + ''|*[!0-9]*|0) + fail "REVIEW_STARTUP_WATCHDOG_SECONDS must be a positive integer, got: ${sidecar_startup_watchdog_seconds}" ;; + ???????*) + fail "REVIEW_STARTUP_WATCHDOG_SECONDS must be at most 999999" ;; +esac +log "startup watchdog: ${sidecar_startup_watchdog_seconds}s (derived from contextual_orchestrator_review_launcher.py's REVIEW_STARTUP_WATCHDOG_SECONDS)" + log "starting review sidecar on ${ORCHESTRATOR_HOST}:${ORCHESTRATOR_PORT}" cp "$ORCHESTRATOR_LAUNCHER" "$ORCHESTRATOR_WORK/launch_sidecar.py" export ORCHESTRATOR_CATALOG_LIMIT="$CATALOG_LIMIT" @@ -397,17 +424,20 @@ until curl -fsSL --max-time 2 "http://${ORCHESTRATOR_HOST}:${ORCHESTRATOR_PORT}/ fail "sidecar exited before healthz (status ${sidecar_status}); stderr: $(sed -n '1,20p' "$sidecar_stderr")" fi i=$((i + 1)) - # KNOWN GAP, tracked as ContextualWisdomLab/.github#1455 (not yet fixed): - # this 180s covers the launcher's ENTIRE startup sequence -- discovery, - # catalog build, AND preflight probing -- not just probing. Layer 1's own - # "160s worst case" comment - # (contextual_orchestrator_review_launcher.py's REVIEW_PREFLIGHT_MAX_ESCALATIONS) - # accounts only for probing; discover_all_models() runs first, inside this - # same 180s, and can itself take up to ~105s worst case (verified against - # the vendored contextual_orchestrator.model_discovery source: ~7 - # sequential HTTP calls at up to 15s each). - if [ "$i" -ge 180 ]; then - fail "sidecar did not become healthy; stderr: $(sed -n '1,20p' "$sidecar_stderr")" + # FIXED (ContextualWisdomLab/.github#1455, Devin Review finding "Startup + # watchdog preempts valid preflight"): this bound covers the launcher's + # ENTIRE startup sequence -- discovery, catalog build, AND preflight + # probing -- not just probing, because none of that work can complete + # (and /healthz cannot respond) until every phase before it has finished in + # the SAME process. $sidecar_startup_watchdog_seconds is derived above from + # contextual_orchestrator_review_launcher.py's own REVIEW_STARTUP_WATCHDOG_SECONDS + # (discovery's real worst case, ~105s, PLUS batched preflight's own real + # worst case, ~120s, PLUS explicit headroom) rather than a bare, previously + # uncoordinated shell constant that only covered probing's own budget by + # coincidence -- see that constant's own module-level comment for the full, + # numbered derivation this single source of truth keeps in sync. + if [ "$i" -ge "$sidecar_startup_watchdog_seconds" ]; then + fail "sidecar did not become healthy within ${sidecar_startup_watchdog_seconds}s; stderr: $(sed -n '1,20p' "$sidecar_stderr")" fi sleep 1 done diff --git a/tests/test_contextual_orchestrator_review_runtime_preflight.py b/tests/test_contextual_orchestrator_review_runtime_preflight.py index 32c5e5089d..2cb1d10344 100644 --- a/tests/test_contextual_orchestrator_review_runtime_preflight.py +++ b/tests/test_contextual_orchestrator_review_runtime_preflight.py @@ -1414,7 +1414,7 @@ def test_fallback_escalation_budget_is_shared_with_primary_and_bounds_worst_case finding: ``_preflight_review_agents`` used to start ``escalations_used`` fresh on every call, so ``_preflight_with_fallback`` calling it twice could spend the full ``REVIEW_PREFLIGHT_MAX_ESCALATIONS`` budget in EACH - stage -- blowing past Layer 1's 180s healthz-readiness watchdog and + stage -- blowing past the preflight phase's own worst-case budget and contradicting the ADR's own claimed worst case. This drives every primary and fallback route (the exact @@ -1422,9 +1422,13 @@ def test_fallback_escalation_budget_is_shared_with_primary_and_bounds_worst_case always qualifies for escalation and never resolves, so every one of them *would* escalate if the budget were not shared. Asserts the run spends at most ``REVIEW_PREFLIGHT_MAX_ESCALATIONS`` escalations in total (not per - stage), and that the resulting worst case stays within the 180s - healthz-readiness watchdog -- both stages' escalation counts are visible - in the returned evidence. + stage), and that the resulting worst case stays within + ``REVIEW_PREFLIGHT_WORST_CASE_SECONDS`` -- the preflight phase's own + coordinated budget, which the sidecar's startup watchdog now composes + with discovery's own worst case rather than treating as the whole startup + budget (see ``test_startup_watchdog_covers_discovery_plus_preflight_with_headroom`` + for that composition) -- both stages' escalation counts are visible in + the returned evidence. The worst-case *formula* (not the shared-budget invariant it measures) differs from this test's pre-batching original: routes are now probed in @@ -1478,11 +1482,115 @@ def test_fallback_escalation_budget_is_shared_with_primary_and_bounds_worst_case # at most max_escalations of the batches actually can. num_batches = -(-total_route_limit // batch_size) # ceil division worst_case_seconds = num_batches * 2 * timeout_seconds - assert worst_case_seconds <= 180, ( - f"worst-case preflight time ({worst_case_seconds}s across " - f"{num_batches} batches) must stay within the 180s " - "healthz-readiness watchdog" + assert worst_case_seconds == namespace["REVIEW_PREFLIGHT_WORST_CASE_SECONDS"], ( + f"observed worst-case preflight time ({worst_case_seconds}s across " + f"{num_batches} batches) must match the launcher's own declared " + "REVIEW_PREFLIGHT_WORST_CASE_SECONDS -- a mismatch means that " + "constant no longer reflects this module's real batching behavior, " + "which would desynchronize it from the sidecar's derived startup " + "watchdog (REVIEW_STARTUP_WATCHDOG_SECONDS)" + ) + + +def test_startup_watchdog_covers_discovery_plus_preflight_with_headroom() -> None: + """Regression for Devin Review's "Startup watchdog preempts valid preflight" + finding: the sidecar's startup watchdog used to be a bare, uncoordinated + 180s shell constant that only happened to exceed the *probing-only* worst + case (120s) by coincidence, while never accounting for discovery's own + worst case (which runs first, in the SAME process, before ``/healthz`` can + respond) at all -- a fully correct, on-budget run of ~105s discovery + + ~120s probing = ~225s could be, and was, killed by the 180s watchdog + before it ever reported a result. + + This is a purely static consistency check (no timing simulation, no real + sleeps -- CI-safe and non-flaky) that recomputes both worst cases + independently from the launcher's own primitive constants and asserts + ``REVIEW_STARTUP_WATCHDOG_SECONDS`` -- the single source of truth the + shell sidecar now imports rather than hard-coding its own number -- + actually covers their sum, with non-negative explicit headroom. It also + locks in the real, literal current numbers (not Devin's original rough + ~105s/~100s estimate) as a regression: any future change to a budget + constant that silently desynchronizes the derived watchdog fails this + test immediately, rather than only failing much later in a live CI run + that happens to hit the worst case. + """ + namespace = _load_launcher() + + discovery_calls = namespace["REVIEW_DISCOVERY_MAX_SEQUENTIAL_HTTP_CALLS"] + discovery_timeout = namespace["REVIEW_DISCOVERY_TIMEOUT_SECONDS"] + recomputed_discovery_worst_case = discovery_calls * discovery_timeout + assert recomputed_discovery_worst_case == namespace["REVIEW_DISCOVERY_WORST_CASE_SECONDS"] + # Verified directly against the vendored contextual_orchestrator.model_discovery + # source at ORCHESTRATOR_PIN_SHA (see the launcher's own module-level + # comment for the full call-by-call derivation): 7 sequential calls at up + # to 15.0s each. + assert recomputed_discovery_worst_case == 105.0 + + total_routes = namespace["REVIEW_PREFLIGHT_MAX_TOTAL_ROUTES"] + batch_size = namespace["REVIEW_PREFLIGHT_BATCH_SIZE"] + preflight_timeout = namespace["REVIEW_PREFLIGHT_TIMEOUT_SECONDS"] + num_batches = -(-total_routes // batch_size) # ceil division + recomputed_preflight_worst_case = num_batches * 2 * preflight_timeout + assert recomputed_preflight_worst_case == namespace["REVIEW_PREFLIGHT_WORST_CASE_SECONDS"] + assert recomputed_preflight_worst_case == 120 + + headroom = namespace["REVIEW_STARTUP_HEADROOM_SECONDS"] + assert headroom >= 0, "headroom must never be negative -- that would silently under-cover" + + combined_worst_case = recomputed_discovery_worst_case + recomputed_preflight_worst_case + watchdog = namespace["REVIEW_STARTUP_WATCHDOG_SECONDS"] + assert isinstance(watchdog, int) + assert watchdog == int(combined_worst_case + headroom) + # The core invariant Devin Review's finding is about: the watchdog must + # cover the full combined worst case, not just one phase of it. + assert watchdog >= combined_worst_case + # Locks in the real current total (225s combined + 30s headroom), not a + # loosely-fitting range, so a future change to any input constant is a + # deliberate, visible edit to this test rather than a silent drift. + assert watchdog == 255 + + +def test_sidecar_derives_its_watchdog_from_the_launcher_single_source_of_truth() -> None: + """The shell watchdog must import, not hard-code, the coordinated deadline. + + Guards against the exact regression class this fix addresses: a future + edit that changes a launcher timing constant (discovery calls, batch + size, escalation timeout, ...) must automatically change the sidecar's + watchdog too, with no second place to remember to update by hand. + """ + namespace = _load_launcher() + sidecar_text = _SIDECAR.read_text(encoding="utf-8") + + assert ( + "from scripts.ci.contextual_orchestrator_review_launcher " + "import REVIEW_STARTUP_WATCHDOG_SECONDS" in sidecar_text + ) + assert 'sidecar_startup_watchdog_seconds="$(' in sidecar_text + assert '[ "$i" -ge "$sidecar_startup_watchdog_seconds" ]' in sidecar_text + # The old, uncoordinated hard-coded bound must be gone from the watchdog + # comparison -- not just supplemented by the new derived one. + assert '[ "$i" -ge 180 ]' not in sidecar_text + assert "-ge 180" not in sidecar_text + + # Exercise the exact derivation command the sidecar script runs, proving + # it truly needs no vendored dependency yet at that point in the script + # (the launcher module's top-level imports are deliberately stdlib-only). + result = subprocess.run( + [ + sys.executable, + "-c", + ( + "from scripts.ci.contextual_orchestrator_review_launcher import " + "REVIEW_STARTUP_WATCHDOG_SECONDS; print(REVIEW_STARTUP_WATCHDOG_SECONDS)" + ), + ], + cwd=str(_REPO_ROOT), + env={**os.environ, "PYTHONPATH": str(_REPO_ROOT)}, + capture_output=True, + text=True, + check=True, ) + assert result.stdout.strip() == str(namespace["REVIEW_STARTUP_WATCHDOG_SECONDS"]) def test_preflight_stage_limits_share_one_startup_budget() -> None: From 3770d77966833e7a4df10bcfe39f3dbe428bdfd6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 03:20:57 +0000 Subject: [PATCH 28/65] fix(ci): reconcile contradictory Pingora guard contracts by removing the redundant guard My earlier commit 55867cd7 satisfied test_strix_quick_gate.sh's pinned expectation that the "Enforce Cloudflare Pingora edge policy" step carry an explicit `if: ${{ github.event_name == 'pull_request_target' }}` guard, but Devin's review correctly flagged that this directly contradicts tests/test_pingora_edge_workflow_contract.py, which asserts the opposite: that exact string must NOT appear in the file at all. Checked which side is actually correct: opencode-review.yml's *entire* workflow trigger (`on:`) is exclusively `pull_request_target` with no other event type, so `github.event_name` is unconditionally 'pull_request_target' for every run of this job. The step-level guard is therefore provably dead code -- exactly the reasoning behind the prior "fix(opencode): remove redundant bootstrap event guard" commit that removed it. That removal was correct; it just never updated this sibling bash contract, which is why the guard kept getting readded and re-removed across several branches' independent "fixes" without either side reconciling with the other. Revert 55867cd7's guard re-addition and fix the actually-stale contract: test_strix_quick_gate.sh now asserts the required-workflow-bootstrap job carries no if: condition at all, matching the deliberate no-redundant- guard design and tests/test_pingora_edge_workflow_contract.py. Verified together: bash scripts/ci/test_strix_quick_gate.sh -> PASS; python -m pytest tests/test_pingora_edge_workflow_contract.py -> 1 passed; full suite -> 2100 passed, 1 skipped, 21 subtests; coverage 100% on scripts/ci; interrogate 100%; git diff --check clean. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01KPmJErfkcHer4UVEgrQxUX --- .github/workflows/opencode-review.yml | 1 - scripts/ci/test_strix_quick_gate.sh | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/opencode-review.yml b/.github/workflows/opencode-review.yml index f3e3c24996..d66979d406 100644 --- a/.github/workflows/opencode-review.yml +++ b/.github/workflows/opencode-review.yml @@ -197,7 +197,6 @@ jobs: fi - name: Enforce Cloudflare Pingora edge policy - if: ${{ github.event_name == 'pull_request_target' }} env: GITHUB_TOKEN: ${{ github.token }} TARGET_REPOSITORY: ${{ github.event.pull_request.base.repo.full_name || github.repository }} diff --git a/scripts/ci/test_strix_quick_gate.sh b/scripts/ci/test_strix_quick_gate.sh index 462a8ba2ed..4fbb4da56e 100644 --- a/scripts/ci/test_strix_quick_gate.sh +++ b/scripts/ci/test_strix_quick_gate.sh @@ -525,9 +525,9 @@ assert_opencode_review_uses_codegraph_and_contextual_orchestrator() { local bootstrap_conditions bootstrap_conditions="$(awk '/^ required-workflow-bootstrap:$/ { in_bootstrap = 1; next } in_bootstrap && /^ [^ ]/ { exit } in_bootstrap' "$bootstrap_file" | grep '^[[:space:]]*if:' || true)" assert_equals \ - " if: \${{ github.event_name == 'pull_request_target' }}" \ + "" \ "$bootstrap_conditions" \ - "opencode bootstrap permits only the explicit pull_request_target Pingora policy condition" + "opencode bootstrap carries no redundant event-name guard: the workflow's sole trigger is already pull_request_target" assert_file_contains "$workflow_file" 'github.event.client_payload.target_repository || github.repository' "opencode review scopes concurrency by target repository" assert_file_contains "$workflow_file" "format('pr-{0}', github.event.client_payload.pr_number)" "opencode review scopes repository_dispatch concurrency by current PR" assert_file_not_contains "$workflow_file" "format('pr-{0}-{1}'" "opencode review does not keep stale head-specific concurrency groups" From d9900c1a6c368ac661b99fd5b7d6b24f7bcc019f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 03:35:18 +0000 Subject: [PATCH 29/65] fix(sidecar): require serving-budget confirmation before route admission Devin Review found a real, live bug on this PR ("Serving-incompatible routes pass startup", ContextualWisdomLab/.github#1454, previously tracked in-code as a known-but-unfixed gap): _preflight_review_agents admitted a candidate the moment its cheap REVIEW_PREFLIGHT_BASE_TOKENS (16) probe returned any text, without ever confirming that same candidate also serves the real REVIEW_MAX_OUTPUT_TOKENS (4096) budget _build_model_client uses for actual review traffic. A route whose provider satisfies a 16-token completion but rejects or empties out at 4096 passed startup and only failed once real serving began -- silently defeating the preflight's purpose. Fix: a base-probe success no longer admits by itself. It now falls into the SAME second-attempt path a budget-too-small failure already used (ADR-0005's escalation retry), drawing from the identical shared, bounded REVIEW_PREFLIGHT_MAX_ESCALATIONS counter -- there is no new, separate, or unbounded allowance. Success is recorded `confirmed_at_serving_budget` (base already worked, this just reconfirms it); a rescued failure keeps the existing `escalated` label (base failed, this second attempt is what rescued it). Either way a candidate takes at most one base attempt plus one more, so REVIEW_PREFLIGHT_WORST_CASE_SECONDS and the derived REVIEW_STARTUP_WATCHDOG_SECONDS (both already computed independent of how many candidates in a batch escalate) are unchanged -- verified by the existing static regression tests, which still pass unmodified. Added regression coverage for the exact reported scenario (a route that succeeds at the base probe but fails/rejects at the real serving budget must not be admitted, both for an empty response and for an outright rejection) and for the shared escalation budget bounding confirmations the same way it already bounded escalations. Updated the three existing tests whose assertions pinned the old (buggy) single-call admission behavior. Verification: coverage run -m pytest tests (2103 passed, 1 skipped) + coverage report --show-missing (100% on scripts/ci; the launcher itself is coverage-omitted per pyproject.toml, exercised via runpy in tests instead), interrogate (100%), bash -n on the sidecar script, git diff --check, and a targeted re-run of both tests/test_contextual_orchestrator_review_runtime_preflight.py (65 passed) and tests/test_contextual_orchestrator_review_sidecar_contract.py (29 passed). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01KPmJErfkcHer4UVEgrQxUX --- CHANGELOG.md | 19 ++ ...contextual_orchestrator_review_launcher.py | 196 +++++++++++------- ...l_orchestrator_review_runtime_preflight.py | 172 +++++++++++++-- 3 files changed, 293 insertions(+), 94 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ff53772462..b508bf45d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,25 @@ Semantic Versioning where the repository publishes a release. whatever succeeded" handling, which downstream evidence showed is needed since single-provider hiccups are common and should not be fatal to the whole pool). See the merge commit and PR #1415 for full evidence. +- Fix a real gap Devin Review found on this same PR ("Serving-incompatible + routes pass startup", `ContextualWisdomLab/.github#1454`): the routing + probe's base attempt (`REVIEW_PREFLIGHT_BASE_TOKENS`, 16) alone was enough + to admit a candidate, even though real review traffic always requests + `REVIEW_MAX_OUTPUT_TOKENS` (4096) — a route whose provider could satisfy a + 16-token completion but rejected or emptied out at 4096 passed startup and + only failed once real serving began. `_preflight_review_agents` now + requires a SECOND, confirming attempt at the real serving budget + (`REVIEW_PREFLIGHT_ESCALATED_TOKENS`) before admitting ANY route — whether + the base probe already succeeded (now recorded `confirmed_at_serving_budget`) + or failed with a budget-too-small signature (still recorded `escalated`, + unchanged) — both draw from the same shared, bounded + `REVIEW_PREFLIGHT_MAX_ESCALATIONS` counter rather than a new, separate one, + so the per-candidate worst case stays at most one base attempt plus one + more, and `REVIEW_PREFLIGHT_WORST_CASE_SECONDS`/ + `REVIEW_STARTUP_WATCHDOG_SECONDS` are unchanged. Added regression coverage + for a mocked route that succeeds at the base probe but fails/rejects at the + serving budget (must not be admitted) and for the shared budget bounding + confirmations the same way it already bounded escalations. - Keep startup route probes on a ten-second timeout while giving serving-time model calls the Noema gate's 120-second transport budget; both retain a zero-retry transport policy at the client level (ADR-0005's own, diff --git a/scripts/ci/contextual_orchestrator_review_launcher.py b/scripts/ci/contextual_orchestrator_review_launcher.py index 4f8fdffb5a..911df6069b 100644 --- a/scripts/ci/contextual_orchestrator_review_launcher.py +++ b/scripts/ci/contextual_orchestrator_review_launcher.py @@ -112,7 +112,14 @@ # truth so a future change to either phase's constants cannot silently # desynchronize the two budgets again. #1454 (a base-probe *success* never # confirms the candidate at the real serving budget, REVIEW_MAX_OUTPUT_TOKENS) -# is a separate, still-open gap this fix does not address. +# is FIXED (Devin Review, "Serving-incompatible routes pass startup"): a +# base-probe success now always draws one confirming attempt at +# REVIEW_PREFLIGHT_ESCALATED_TOKENS from this SAME shared counter before +# being admitted, exactly like a base-probe failure's existing escalation +# attempt -- see _preflight_review_agents's docstring. Per-candidate worst +# case stays at most one base + one second attempt either way, so the +# REVIEW_PREFLIGHT_WORST_CASE_SECONDS/REVIEW_STARTUP_WATCHDOG_SECONDS +# arithmetic derived below is unchanged by this fix. REVIEW_PREFLIGHT_MAX_ESCALATIONS = 4 # Mirrors contextual_orchestrator.model_discovery.DISCOVERY_TIMEOUT_SECONDS at @@ -494,27 +501,54 @@ def _preflight_review_agents( ADR-0005: a single fixed ``max_tokens`` cannot fit every model in a heterogeneous pool. Each candidate gets one cheap base-budget probe - (``REVIEW_PREFLIGHT_BASE_TOKENS``); when that specific candidate's - response is empty for a "budget too small" reason -- either - ``choices[0].finish_reason == "length"`` (OpenAI's documented signature), - or the vendored ``ModelClient._response_content``'s own broader signature - (a populated ``message.reasoning`` with no string ``content``, which a - reasoning model can hit under a different ``finish_reason`` -- provider - ``finish_reason`` semantics for this case are not verified as uniform - across the pool, and this is the exact original failure mode PR #1436 - responded to) -- that *same* candidate is retried once at a larger, - escalated budget (``REVIEW_PREFLIGHT_ESCALATED_TOKENS``) before being - marked rejected -- bounded by a shared ``REVIEW_PREFLIGHT_MAX_ESCALATIONS`` - counter, which the ``escalations_used`` argument carries forward across - calls (not per candidate, and not reset per call): a caller that probes - two stages of the same preflight run (e.g. ``_preflight_with_fallback``'s - primary and fallback stages) must pass the previous stage's ending count - back in here so the two stages share one budget instead of each getting - its own -- otherwise the computed worst-case bound this counter exists to - enforce silently doubles. Every other failure class (transport exception, - non-2xx, or empty content matching neither signature) is not retried: a - genuinely-down candidate never reaches the escalation path, so it cannot - produce a false "healthy" read. + (``REVIEW_PREFLIGHT_BASE_TOKENS``). Admission always requires a SECOND, + confirming probe at the real serving budget + (``REVIEW_PREFLIGHT_ESCALATED_TOKENS``, equal to the ``REVIEW_MAX_OUTPUT_TOKENS`` + ``main()``'s ``ModelClient`` actually requests during review traffic) -- + fixed as `ContextualWisdomLab/.github#1454` (Devin Review, "Serving- + incompatible routes pass startup"): the base probe alone previously + admitted a candidate having proven nothing beyond + ``REVIEW_PREFLIGHT_BASE_TOKENS``, so a candidate whose real completion + ceiling sat strictly between the base and serving budgets passed startup + and only failed once real review traffic began. There are exactly two + ways a candidate reaches that confirming probe: + + 1. **The base probe already returned usable text.** This is the + ordinary, most common case; the second probe exists purely to CONFIRM + that same candidate also serves the real budget, not to diagnose a + failure. Success marks ``confirmed_at_serving_budget`` (not + ``escalated``) on the row -- the base attempt already worked, this + second attempt only re-proves it at the real budget. + 2. **The base probe's response was empty for a "budget too small" reason** + -- either ``choices[0].finish_reason == "length"`` (OpenAI's + documented signature), or the vendored + ``ModelClient._response_content``'s own broader signature (a populated + ``message.reasoning`` with no string ``content``, which a reasoning + model can hit under a different ``finish_reason`` -- provider + ``finish_reason`` semantics for this case are not verified as uniform + across the pool, and this is the exact original failure mode PR #1436 + responded to). Success marks ``escalated`` on the row -- the base + attempt failed and this second attempt is what actually rescued it. + + Either way, the SAME candidate gets at most one additional attempt (never + a third), and that attempt is bounded by one shared + ``REVIEW_PREFLIGHT_MAX_ESCALATIONS`` counter -- there is no separate + counter for "confirming a success" versus "escalating a failure", since + both are the identical "one more attempt at the larger budget" action for + worst-case-timing purposes. The counter's ``escalations_used`` argument + carries forward across calls (not per candidate, and not reset per + call): a caller that probes two stages of the same preflight run (e.g. + ``_preflight_with_fallback``'s primary and fallback stages) must pass the + previous stage's ending count back in here so the two stages share one + budget instead of each getting its own -- otherwise the computed + worst-case bound this counter exists to enforce silently doubles. A base + response that is empty for any OTHER reason (no budget-too-small + signature) is not retried at all: a genuinely-down candidate never + reaches the second-attempt path, so it cannot produce a false "healthy" + read, and a candidate denied its second attempt by the shared budget + (whichever of the two reasons brought it there) is recorded + ``escalation_budget_exhausted`` and not admitted -- fails closed, exactly + like a base failure that never got its own escalation slot. An exception on the escalated attempt (transport failure, auth failure, rate limit, server error, or a genuine budget rejection) is recorded via ``_record_provider_exception`` -- the SAME sanitized classification the @@ -598,58 +632,58 @@ def _preflight_review_agents( _record_provider_exception(row, exc) routes.append(row) continue - if _chat_response_has_text(response): - # KNOWN GAP, tracked (not yet fixed) as - # ContextualWisdomLab/.github#1454: this admits the candidate - # having only proven it works at REVIEW_PREFLIGHT_BASE_TOKENS - # (16), never at the real serving budget - # (REVIEW_MAX_OUTPUT_TOKENS, 4096) main()'s ModelClient actually - # requests. ADR-0005's own Research (axis 2) already documents - # that a provider's hard completion-token ceiling is a real, - # separate-from-reasoning-overhead quantity per model; a - # candidate whose real ceiling sits strictly between 16 and 4096 - # would pass here and only fail later, on real review traffic. - # Mitigated in production (not fixed here) by - # contextual_orchestrator.orchestrator.TaskOrchestrator's own - # per-request failover/circuit-breaker, which this preflight - # does not replace. - row["status"] = "ready" - # Populated on every outcome, including this most-common, - # ordinary success path -- not just failure/escalation -- so - # future tuning has a real "normal" baseline to compare against, - # not just evidence of what went wrong. - row["finish_reason"] = _response_finish_reason(response) or "unknown" - row["reasoning_without_content"] = _response_has_reasoning_without_content(response) - routes.append(row) - viable.append(agent) - continue + + base_has_text = _chat_response_has_text(response) finish_reason = _response_finish_reason(response) + # Populated on every response-bearing outcome, including an + # eventually-superseded base attempt -- not just failure/escalation + # -- so future tuning has a real "normal" baseline to compare + # against. Overwritten below if a second attempt is made (see the + # docstring: both fields always describe the same, most recent + # attempt, never a mix of the two). row["finish_reason"] = finish_reason or "unknown" reasoning_without_content = _response_has_reasoning_without_content(response) row["reasoning_without_content"] = reasoning_without_content - budget_signature = finish_reason == "length" or reasoning_without_content - # KNOWN, ACCEPTED, TRACKED LIMITATION on the escalations_used >= - # REVIEW_PREFLIGHT_MAX_ESCALATIONS branch below, ContextualWisdomLab/.github#1458 - # (originally documented on ADR-0005, docs/adr/0005-sidecar-preflight-token-budget.md): - # escalations_used is one shared, first-come-first-served counter for - # the whole run, consumed in catalog order + + if not base_has_text: + budget_signature = finish_reason == "length" or reasoning_without_content + if not budget_signature: + # Genuinely down (or an unrelated malformed reply): no + # signature suggests a bigger budget would help, so this + # candidate never reaches the second-attempt path -- it + # cannot produce a false "healthy" read. + row["status"] = "rejected" + row["error_type"] = "invalid_chat_response" + routes.append(row) + continue + # Either the base probe already has usable text (fix for + # ContextualWisdomLab/.github#1454: admission still requires + # confirming that text holds at the real serving budget, not just + # REVIEW_PREFLIGHT_BASE_TOKENS) or it matched a "budget too small" + # signature above and needs the existing escalation retry. Both + # reach the SAME shared, bounded second-attempt path below. + # + # KNOWN, ACCEPTED, TRACKED LIMITATION on the budget.try_reserve() + # branch below, ContextualWisdomLab/.github#1458 (originally + # documented on ADR-0005, docs/adr/0005-sidecar-preflight-token-budget.md): + # the shared counter is first-come-first-served in catalog order # (build_zdr_prioritized_catalog's (cost_evidence_rank, - # zdr_attested_rank, provider, model) sort, not random). A - # later-sorting candidate can be denied its own escalation attempt - # purely because REVIEW_PREFLIGHT_MAX_ESCALATIONS earlier candidates - # already claimed the shared budget -- even if it would have been the - # only one to succeed at REVIEW_PREFLIGHT_ESCALATED_TOKENS. - # Deliberately not reordered (round-robin/random): a fixed-size - # shared budget smaller than the candidate pool always has to deny - # someone an escalation, so reordering only changes who, and picking - # a specific policy without real telemetry on which candidates - # actually need escalation would itself be the kind of unjustified - # heuristic this design rejects elsewhere. - if not budget_signature or not budget.try_reserve(): + # zdr_attested_rank, provider, model) sort, not random), for BOTH the + # confirmation and escalation uses added by this fix. A + # later-sorting candidate -- whether it needs confirmation of an + # already-successful base probe, or escalation of a failed one -- can + # be denied its own second attempt purely because + # REVIEW_PREFLIGHT_MAX_ESCALATIONS earlier candidates already claimed + # the shared budget, even if it would have succeeded at + # REVIEW_PREFLIGHT_ESCALATED_TOKENS. Deliberately not reordered + # (round-robin/random): a fixed-size shared budget smaller than the + # candidate pool always has to deny someone a second attempt, so + # reordering only changes who, and picking a specific policy without + # real telemetry on which candidates actually need it would itself be + # the kind of unjustified heuristic this design rejects elsewhere. + if not budget.try_reserve(): row["status"] = "rejected" - row["error_type"] = ( - "invalid_chat_response" if not budget_signature else "escalation_budget_exhausted" - ) + row["error_type"] = "escalation_budget_exhausted" routes.append(row) continue row["attempts"] = 2 @@ -666,18 +700,30 @@ def _preflight_review_agents( # the same sanitized classification the base probe uses, rather # than the previous "escalated_probe_rejected" label, which # over-claimed budget-specific attribution this codebase has no - # validated signal to actually support. + # validated signal to actually support. This also applies to a + # candidate whose base probe already had text: a rejection here + # is exactly the ContextualWisdomLab/.github#1454 scenario -- + # usable at the base budget, rejected outright at the real + # serving budget -- and it must not be admitted just because an + # earlier, smaller attempt happened to succeed. _record_provider_exception(row, exc) routes.append(row) continue if _chat_response_has_text(escalated_response): row["status"] = "ready" - row["escalated"] = True + if base_has_text: + # The base attempt already had usable text; this second + # attempt only confirms that same candidate also serves the + # real budget -- distinct from `escalated`, which means the + # base attempt FAILED and this second attempt is what + # rescued it. + row["confirmed_at_serving_budget"] = True + else: + row["escalated"] = True # Overwrite the base attempt's stale diagnostic fields with the # escalated (successful, final) attempt's own state -- otherwise - # a ready route's evidence would still show the budget-too-small - # signature that triggered the escalation in the first place, - # describing a response this route no longer produced. + # a ready route's evidence would still show the base attempt's + # signature, describing a response this route no longer produced. row["finish_reason"] = _response_finish_reason(escalated_response) or "unknown" row["reasoning_without_content"] = _response_has_reasoning_without_content( escalated_response @@ -685,6 +731,10 @@ def _preflight_review_agents( routes.append(row) viable.append(agent) continue + # ContextualWisdomLab/.github#1454's exact failure mode when + # base_has_text is True: usable at REVIEW_PREFLIGHT_BASE_TOKENS, + # empty at the real REVIEW_PREFLIGHT_ESCALATED_TOKENS serving budget + # -- never admitted, regardless of the earlier, smaller success. row["status"] = "rejected" row["error_type"] = "invalid_chat_response" # Both fields now describe this escalated (2nd, final) attempt, diff --git a/tests/test_contextual_orchestrator_review_runtime_preflight.py b/tests/test_contextual_orchestrator_review_runtime_preflight.py index 2cb1d10344..4fabee3345 100644 --- a/tests/test_contextual_orchestrator_review_runtime_preflight.py +++ b/tests/test_contextual_orchestrator_review_runtime_preflight.py @@ -249,13 +249,19 @@ def test_preflight_mirrors_runtime_request_and_keeps_only_compatible_routes() -> assert secret not in repr(report) # Regression for Devin Review's successful-probes-omit-diagnostics - # finding: the ordinary, most-common outcome (an immediate base-probe - # success, no escalation needed) must still populate finish_reason and - # reasoning_without_content -- not just failure/escalation outcomes -- - # so there is a real "normal" baseline to compare future telemetry - # against. + # finding: the ordinary, most-common outcome (a base-probe success, + # confirmed at the real serving budget -- see below) must still populate + # finish_reason and reasoning_without_content -- not just + # failure/escalation outcomes -- so there is a real "normal" baseline to + # compare future telemetry against. ready_row = report["routes"][2] assert ready_row["status"] == "ready" + assert ready_row["attempts"] == 2 + # ContextualWisdomLab/.github#1454 fix: a base-probe success alone is not + # admission -- it must also be confirmed at the real serving budget + # (REVIEW_PREFLIGHT_ESCALATED_TOKENS) before this route is marked ready. + assert ready_row["confirmed_at_serving_budget"] is True + assert "escalated" not in ready_row assert ready_row["finish_reason"] == "unknown" assert ready_row["reasoning_without_content"] is False @@ -263,7 +269,6 @@ def test_preflight_mirrors_runtime_request_and_keeps_only_compatible_routes() -> assert endpoint == "chat/completions" assert payload["model"] == agent.model assert payload["stream"] is False - assert payload["max_tokens"] == 16 assert payload["temperature"] == 1.0 assert payload["messages"] == [ {"role": "system", "content": "You are a helpful assistant."}, @@ -271,6 +276,16 @@ def test_preflight_mirrors_runtime_request_and_keeps_only_compatible_routes() -> ] assert "tools" not in payload + # The rejected and malformed routes each make exactly one call (base + # budget); the ready route makes two -- its base probe, then the + # mandatory confirmation at the real serving budget. + assert [payload["max_tokens"] for _, _, payload in client.calls] == [ + namespace["REVIEW_PREFLIGHT_BASE_TOKENS"], + namespace["REVIEW_PREFLIGHT_BASE_TOKENS"], + namespace["REVIEW_PREFLIGHT_BASE_TOKENS"], + namespace["REVIEW_PREFLIGHT_ESCALATED_TOKENS"], + ] + def test_log_preflight_rejections_prints_bounded_summary_to_stderr( capsys: pytest.CaptureFixture[str], @@ -399,19 +414,20 @@ def test_gateway_preflight_max_tokens_is_synchronized_with_the_routing_probe() - reasoning tokens, so the gateway rejected a route its own routing probe had just proven healthy. - Since ADR-0005 (this PR), most routes now prove readiness at the much - cheaper ``REVIEW_PREFLIGHT_BASE_TOKENS`` (16) instead -- `4096` is used - by the routing probe only on the ESCALATED retry (a candidate that - failed the cheap probe with a budget-too-small signature) and, always, - by the real serving `ModelClient` for actual review traffic (see - `ContextualWisdomLab/.github#1454` for the resulting known gap: an - ordinary base-probe success is never itself confirmed at this budget). - This test's own assertion is unaffected by that: Layer 2 never - escalates (ADR-0005 Decision SS1) and always uses the real serving + Since ADR-0005 (this PR), most routes first prove liveness at the much + cheaper ``REVIEW_PREFLIGHT_BASE_TOKENS`` (16) -- `4096` is then used by + the routing probe's second attempt for every admitted route, always: to + rescue a candidate that failed the cheap probe with a budget-too-small + signature, AND (fixed as `ContextualWisdomLab/.github#1454`, Devin + Review, "Serving-incompatible routes pass startup") to confirm a + candidate whose cheap probe already succeeded, before that route is ever + marked ready -- and, always, by the real serving `ModelClient` for actual + review traffic. This test's own assertion is unaffected by that: Layer 2 + never escalates (ADR-0005 Decision SS1) and always uses the real serving budget, so its literal must still equal `REVIEW_MAX_OUTPUT_TOKENS` exactly, for the same reason as before -- a smaller Layer 2 budget can - still reject a route the routing probe (at either of its own budgets) - already proved ready. + still reject a route the routing probe (which now confirms every + admitted route at this same real serving budget) already proved ready. """ namespace = _load_launcher() review_max_output_tokens = namespace["REVIEW_MAX_OUTPUT_TOKENS"] @@ -995,7 +1011,8 @@ def test_base_probe_success_with_reasoning_and_content_is_never_flagged_as_starv response that ALSO discloses a reasoning trace alongside real content must never be recorded as ``reasoning_without_content: True`` -- that would falsely pollute the evidence this preflight exists to produce, on - the single most common outcome (an immediate base-probe success). + the single most common outcome (a base-probe success, confirmed at the + real serving budget per the ContextualWisdomLab/.github#1454 fix). """ namespace = _load_launcher() preflight = namespace["_preflight_review_agents"] @@ -1024,9 +1041,17 @@ def test_base_probe_success_with_reasoning_and_content_is_never_flagged_as_starv assert viable == [transparent_reasoner] row = report["routes"][0] assert row["status"] == "ready" - assert row["attempts"] == 1 + # Two attempts: the base probe (16 tokens) plus the mandatory + # confirmation at the real serving budget (ContextualWisdomLab/.github#1454). + assert row["attempts"] == 2 + assert row["confirmed_at_serving_budget"] is True + assert "escalated" not in row assert row["finish_reason"] == "stop" assert row["reasoning_without_content"] is False + assert [call[2]["max_tokens"] for call in client.calls] == [ + namespace["REVIEW_PREFLIGHT_BASE_TOKENS"], + namespace["REVIEW_PREFLIGHT_ESCALATED_TOKENS"], + ] def test_finish_reason_length_escalates_and_can_succeed() -> None: @@ -1078,6 +1103,106 @@ def test_finish_reason_length_escalates_and_can_succeed() -> None: assert report["escalations_used"] == 1 +def test_base_probe_success_not_admitted_when_serving_budget_probe_returns_empty() -> None: + """Regression for Devin Review's "Serving-incompatible routes pass + startup" finding (`ContextualWisdomLab/.github#1454`): a route that + succeeds at the cheap `REVIEW_PREFLIGHT_BASE_TOKENS` probe but returns + empty content at the real `REVIEW_PREFLIGHT_ESCALATED_TOKENS` serving + budget must NOT be marked ready -- admission requires success at the + actual serving-equivalent token budget, not merely at the escalation + sequence's first rung. Before this fix, `_build_model_client` would go + on to serve real reviews at `REVIEW_MAX_OUTPUT_TOKENS` against a route + this preflight had already (wrongly) admitted. + """ + namespace = _load_launcher() + preflight = namespace["_preflight_review_agents"] + + serving_incompatible = SimpleNamespace( + id="nvidia_nim_serving_incompatible", provider_name="nvidia_nim", model="narrow/free" + ) + client = _SequencedClient( + [ + _openai_text("OK"), + {"choices": [{"finish_reason": "length", "message": {"content": ""}}]}, + ] + ) + + with pytest.raises(namespace["ReviewPreflightError"], match="no provider route passed"): + preflight([serving_incompatible], client=client) + + assert [call[2]["max_tokens"] for call in client.calls] == [ + namespace["REVIEW_PREFLIGHT_BASE_TOKENS"], + namespace["REVIEW_PREFLIGHT_ESCALATED_TOKENS"], + ] + + +def test_base_probe_success_not_admitted_when_serving_budget_probe_raises() -> None: + """The sibling shape of the same Devin Review finding: the route's + confirming probe at the real serving budget doesn't just come back + empty, it is rejected outright (a provider whose real completion-token + ceiling sits strictly between the base and serving budgets, exactly the + axis ADR-0005's own Research already documented). Must still fail + closed, not admit the route on the strength of the earlier, smaller + success. + """ + namespace = _load_launcher() + preflight = namespace["_preflight_review_agents"] + + narrow_ceiling = SimpleNamespace( + id="nvidia_nim_narrow_ceiling", provider_name="nvidia_nim", model="narrow/free" + ) + client = _SequencedClient( + [ + _openai_text("OK"), + RuntimeError("provider rejected the request: max_tokens exceeds model ceiling"), + ] + ) + + with pytest.raises(namespace["ReviewPreflightError"]) as failure: + preflight([narrow_ceiling], client=client) + + row = failure.value.report["routes"][0] + assert row["status"] == "rejected" + assert row["error_type"] == "RuntimeError" + assert row["attempts"] == 2 + assert "escalated" not in row + assert "confirmed_at_serving_budget" not in row + + +def test_base_probe_success_confirmation_shares_the_escalation_budget() -> None: + """A base-probe success's mandatory confirmation draws from the SAME + shared ``REVIEW_PREFLIGHT_MAX_ESCALATIONS`` counter a budget-too-small + escalation would -- there is no separate, unbounded allowance for + confirming successes, which would silently reintroduce an unbounded + worst case this fix must not create. + """ + namespace = _load_launcher() + preflight = namespace["_preflight_review_agents"] + max_escalations = namespace["REVIEW_PREFLIGHT_MAX_ESCALATIONS"] + + agents = [ + SimpleNamespace(id=f"confirmed_{index}", provider_name="openrouter", model="x/free") + for index in range(max_escalations) + ] + exhausted = SimpleNamespace( + id="confirmation_exhausted", provider_name="openrouter", model="x/free" + ) + client = _ProbeClient( + {agent.id: _openai_text("OK") for agent in agents} + | {exhausted.id: _openai_text("OK")} + ) + + viable, report = preflight([*agents, exhausted], client=client) + + assert viable == agents + exhausted_row = report["routes"][-1] + assert exhausted_row["status"] == "rejected" + assert exhausted_row["error_type"] == "escalation_budget_exhausted" + assert exhausted_row["attempts"] == 1 + assert report["escalations_used"] == max_escalations + assert len(client.calls) == max_escalations * 2 + 1 + + def test_escalation_budget_is_shared_and_bounded_across_candidates() -> None: """Once ``REVIEW_PREFLIGHT_MAX_ESCALATIONS`` is spent, a further candidate that would otherwise qualify is rejected immediately, without a second @@ -1360,7 +1485,10 @@ def test_preflight_uses_priced_fallback_only_after_primary_routes_reject() -> No assert fallback_used is True assert report["fallback_reason"] == "primary_routes_unavailable" assert report["primary_attempt"]["ready_count"] == 0 - assert [call[0] for call in client.calls] == [primary, fallback] + # The fallback route's base probe succeeds and is then confirmed at the + # real serving budget (ContextualWisdomLab/.github#1454) before being + # admitted, so it makes two calls. + assert [call[0] for call in client.calls] == [primary, fallback, fallback] ready_client = _ProbeClient( {primary.id: _openai_text("OK"), fallback.id: _openai_text("unused")} @@ -1371,7 +1499,9 @@ def test_preflight_uses_priced_fallback_only_after_primary_routes_reject() -> No assert viable == [primary] assert fallback_used is False assert "fallback_reason" not in report - assert [call[0] for call in ready_client.calls] == [primary] + # Likewise, the primary route's base-probe success is confirmed at the + # real serving budget before admission -- two calls, both to primary. + assert [call[0] for call in ready_client.calls] == [primary, primary] failing_client = _ProbeClient( {primary.id: TimeoutError("unavailable"), fallback.id: RuntimeError("rejected")} From b64f1e5566fcc562cebc1d83d7b04a9bfa810ea1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 03:57:36 +0000 Subject: [PATCH 30/65] fix(sidecar): separate confirmation budget from rescue escalation budget Devin Review flagged a high-severity regression introduced by d9900c1a on this same PR ("Later healthy routes cannot start", ContextualWisdomLab/.github#1415): making the serving-budget confirmation mandatory for every successful base probe, while still spending the SAME shared REVIEW_PREFLIGHT_MAX_ESCALATIONS counter that was originally sized only to occasionally rescue a FAILED base probe, meant as few as 4 candidates in the very first batch(es) -- each simply succeeding their base probe, the ordinary case -- could exhaust that counter's four slots on their own mandatory confirmations. Every later candidate's confirmation was then denied by _EscalationBudget.try_reserve() regardless of merit, defeating batching's entire purpose of evaluating up to 24 routes to find one usable one. Confirmation now draws from its own dedicated REVIEW_PREFLIGHT_MAX_CONFIRMATIONS budget, sized to REVIEW_PREFLIGHT_MAX_TOTAL_ROUTES (24) so even the fully pessimistic case -- every candidate ever probed in a run succeeds its base probe -- still gets its confirmation shot. REVIEW_PREFLIGHT_MAX_ESCALATIONS keeps its original, narrower, smaller rescue-only purpose and cap, untouched. _preflight_with_fallback shares one instance of each of the two budgets across its primary/fallback stages, exactly as it already did for the one budget before this fix. Rejections now distinguish confirmation_budget_exhausted from escalation_budget_exhausted in evidence. REVIEW_PREFLIGHT_WORST_CASE_SECONDS/REVIEW_STARTUP_WATCHDOG_SECONDS are unchanged (120s/255s): each batch's wall-clock worst case was already computed as its slowest candidate making at most one base plus one second attempt, independent of either budget's specific cap -- confirmed by re-deriving both numbers and by the existing test_startup_watchdog_covers_discovery_plus_preflight_with_headroom / test_fallback_escalation_budget_is_shared_with_primary_and_bounds_worst_case regressions passing unmodified. Added a red-then-green regression (test_batched_preflight_first_batch_confirmations_do_not_starve_a_later_healthy_route) reproducing the exact reported scenario against _preflight_review_agent_batches: verified failing against the pre-fix code (4 batch-1 candidates each succeed base probe then genuinely fail confirmation, exhausting the old shared budget; a 5th, batch-2 candidate that would pass both attempts was wrongly denied with "no provider route passed"), and passing after the fix. Also rewrote the now-stale test_base_probe_success_confirmation_shares_the_escalation_budget into two tests describing the new dedicated-but-still-bounded confirmation budget. Also fixes a doc-consistency issue CodeRabbit found in docs/adr/0003-contextual-orchestrator-vendored-free-zdr.md: several Decision/Wiring/Consequences passages still described Strix as using orchestrator/auto in the present tense, contradicting the header and the 2026-08-30 amendment recording Strix's move to orchestrator/free. Annotated those passages as superseded by the amendment without rewriting the historical rationale they record. Full verification: coverage run -m pytest tests (2105 passed, 1 skipped, 100% branch coverage on scripts/ci per pyproject.toml's fail_under=100, contextual_orchestrator_review_launcher.py is deliberately coverage-omitted per existing project policy since it only imports under the vendored sidecar runtime), interrogate (100% docstrings), bash -n on the sidecar script, git diff --check, and both tests/test_contextual_orchestrator_review_runtime_preflight.py (67 tests) and tests/test_contextual_orchestrator_review_sidecar_contract.py (29 tests) individually in full. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01KPmJErfkcHer4UVEgrQxUX --- CHANGELOG.md | 38 ++ ...ntextual-orchestrator-vendored-free-zdr.md | 32 +- ...contextual_orchestrator_review_launcher.py | 339 ++++++++++++------ ...l_orchestrator_review_runtime_preflight.py | 200 ++++++++++- 4 files changed, 479 insertions(+), 130 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b508bf45d4..522ed27616 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,44 @@ Semantic Versioning where the repository publishes a release. for a mocked route that succeeds at the base probe but fails/rejects at the serving budget (must not be admitted) and for the shared budget bounding confirmations the same way it already bounded escalations. +- Fix a real, high-severity regression Devin Review found minutes after the + confirmation fix directly above landed on this same PR ("Later healthy + routes cannot start", `ContextualWisdomLab/.github#1415`): making + confirmation mandatory for every successful base probe, while still + spending the SAME shared `REVIEW_PREFLIGHT_MAX_ESCALATIONS` counter that + fix reused from rescue escalation, meant as few as + `REVIEW_PREFLIGHT_MAX_ESCALATIONS` (4) candidates in the very first + batch(es) — each simply succeeding its base probe, the ordinary case — + could each reserve one of the counter's four slots for their own + confirmation, permanently exhausting it. Every later candidate's + confirmation request was then denied by `_EscalationBudget.try_reserve()` + regardless of merit, so a batch 2+ candidate that would have passed both + its base probe and its confirmation could never even attempt the second + one — defeating batching's entire purpose of evaluating up to + `REVIEW_PREFLIGHT_MAX_TOTAL_ROUTES` (24) routes to find one usable one. + Confirmation now draws from its own dedicated `REVIEW_PREFLIGHT_MAX_CONFIRMATIONS` + budget (sized to `REVIEW_PREFLIGHT_MAX_TOTAL_ROUTES` so even the fully + pessimistic case — every candidate ever probed in a run succeeds its base + probe — still gets its confirmation shot), while + `REVIEW_PREFLIGHT_MAX_ESCALATIONS` keeps its original, narrower, smaller + rescue-only purpose and cap, unrelated and untouched. `_preflight_with_fallback` + shares one instance of each of the two budgets across its primary and + fallback stages, exactly as it already did for the one budget before this + fix. `REVIEW_PREFLIGHT_WORST_CASE_SECONDS`/`REVIEW_STARTUP_WATCHDOG_SECONDS` + are unchanged (120s/255s): each batch's wall-clock worst case was already + computed as its slowest candidate making at most one base plus one second + attempt, independent of either budget's specific cap — see + `REVIEW_PREFLIGHT_MAX_ESCALATIONS`'s and `REVIEW_PREFLIGHT_MAX_CONFIRMATIONS`'s + own module-level comments for the full arithmetic. Rejections now + distinguish `confirmation_budget_exhausted` from `escalation_budget_exhausted` + in evidence. Added a red-then-green regression + (`test_batched_preflight_first_batch_confirmations_do_not_starve_a_later_healthy_route`) + reproducing the exact reported scenario against `_preflight_review_agent_batches`: + four batch-1 candidates each succeed their base probe and then genuinely + fail confirmation (consuming, under the old code, the entire shared + budget), while a fifth, batch-2 candidate that would succeed both its base + probe and its confirmation is wrongly denied under the pre-fix code and + correctly admitted after the fix. - Keep startup route probes on a ten-second timeout while giving serving-time model calls the Noema gate's 120-second transport budget; both retain a zero-retry transport policy at the client level (ADR-0005's own, diff --git a/docs/adr/0003-contextual-orchestrator-vendored-free-zdr.md b/docs/adr/0003-contextual-orchestrator-vendored-free-zdr.md index bdc60ba59a..25909422f1 100644 --- a/docs/adr/0003-contextual-orchestrator-vendored-free-zdr.md +++ b/docs/adr/0003-contextual-orchestrator-vendored-free-zdr.md @@ -43,7 +43,9 @@ all five, and auto-optimize routing by cost. price-attested; a partial price vector, malformed numeric value, conflicting free marker, or missing currency for a published vector fails closed. The gateway's `orchestrator/free` virtual id fails closed (`400 invalid_model`) unless an - enabled zero-cost agent exists. Strix uses `orchestrator/auto`; its catalog + enabled zero-cost agent exists. Strix originally used `orchestrator/auto` + (superseded by the 2026-08-30 amendment below: Strix now uses + `orchestrator/free`, like OpenCode and Noema); the `auto` pool's catalog may admit priced routes only through this evidence-bearing policy, never through a direct-provider model identifier. The auto pool probes the free catalog first. Only when every selected free @@ -84,14 +86,16 @@ all five, and auto-optimize routing by cost. the generated dispatch config contains only the gateway provider. The shared `opencode.jsonc` default `model`/`small_model` is the same gateway route. `noema-review.yml` retains `orchestrator/free`. `strix.yml` provisions the - same sidecar and uses the loopback chat-completions/API-compatible URL with - `orchestrator/auto`: the 2026-08-29 exact-head DiskSage scan proved that four + same sidecar and originally used the loopback chat-completions/API-compatible + URL with `orchestrator/auto` (superseded by the 2026-08-30 amendment below: + `strix.yml` now defaults to `orchestrator/free`, like OpenCode and Noema): + the 2026-08-29 exact-head DiskSage scan proved that four discovered free routes all shared the OpenRouter outage domain, which the - gateway correctly collapsed to one provider attempt. Strix therefore uses + gateway correctly collapsed to one provider attempt. Strix therefore used the provider-diverse pool supplied by all five configured credentials. Provider diversity and cost-evidence classification remain delegated to the gateway rather than embedding a second routing policy in GitHub Actions. - Strix has no external fallback and private targets pass visibility through + Strix had no external fallback under `auto` and private targets pass visibility through to the gateway's ZDR requirement. Noema reviewer identity remains `NOEMA_REVIEW_TOKEN` / GitHub App / OIDC and is still never `github.token`; Autofix mutation still requires `PR_REVIEW_MERGE_TOKEN` / @@ -135,13 +139,17 @@ all five, and auto-optimize routing by cost. - The autofix/OpenCode review paths no longer hard-code any provider base URL or model id; upstream model selection is delegated to the orchestrator's - discovery under the zero-cost pool. Strix uses the separately governed auto - pool without treating absent price metadata as either free or paid-route - evidence. -- Strix delegates selection to `orchestrator/auto`. Its correctness-first pool - remains distinct from the zero-cost OpenCode/Noema pool, while private-target - ZDR admission remains fail-closed. Unknown-cost routes remain auditable but - ineligible; free and fully price-attested routes are the only review routes. + discovery under the zero-cost pool. Strix originally used the separately + governed auto pool (superseded by the 2026-08-30 amendment below: Strix now + uses the same zero-cost pool as OpenCode/Noema) without treating absent + price metadata as either free or paid-route evidence. +- Under `orchestrator/auto`, selection is delegated to the gateway's + correctness-first pool, distinct from the zero-cost OpenCode/Noema pool, + while private-target ZDR admission remains fail-closed. Unknown-cost routes + remain auditable but ineligible; free and fully price-attested routes are + the only review routes. (This paragraph describes the `auto` pool mode + itself, which still exists for any caller that opts into it explicitly — + see the 2026-08-30 amendment below for why Strix no longer does.) - Workers need egress to the five provider model-list hosts and, when reachable, `https://openrouter.ai/api/v1/endpoints/zdr`; the feed failure path is graceful (static table). diff --git a/scripts/ci/contextual_orchestrator_review_launcher.py b/scripts/ci/contextual_orchestrator_review_launcher.py index 911df6069b..c24e4a5b94 100644 --- a/scripts/ci/contextual_orchestrator_review_launcher.py +++ b/scripts/ci/contextual_orchestrator_review_launcher.py @@ -69,20 +69,29 @@ # number. REVIEW_PREFLIGHT_ESCALATED_TOKENS = REVIEW_MAX_OUTPUT_TOKENS # Shared cap on how many candidates in one preflight run may use the -# escalation retry above, so Layer 1's PROBING worst case stays computed and -# bounded. Merged with the batched concurrent preflight below +# escalation RESCUE retry below (a FAILED base probe with a "budget too +# small" signature). This budget's purpose is deliberately narrow and +# scarce: rescuing an atypical failure, not confirming an already-successful +# candidate -- see REVIEW_PREFLIGHT_MAX_CONFIRMATIONS below for that +# separate, much more common concern, and why it needs its own budget. +# Merged with the batched concurrent preflight below # (REVIEW_PREFLIGHT_BATCH_SIZE): candidates within one batch of up to # REVIEW_PREFLIGHT_BATCH_SIZE run concurrently, so a batch's own wall time is # its slowest candidate, not the sum of all of them -- worst case, a batch -# containing an escalating candidate costs 2 * REVIEW_PREFLIGHT_TIMEOUT_SECONDS -# (base + escalated attempt, sequential within that one candidate's thread), -# not REVIEW_PREFLIGHT_TIMEOUT_SECONDS. With -# REVIEW_PREFLIGHT_MAX_TOTAL_ROUTES=24 candidates in batches of -# REVIEW_PREFLIGHT_BATCH_SIZE=4, that is ceil(24/4)=6 batches; even the fully -# pessimistic bound of every batch needing its worst case is -# 6 * 2 * 10 = 120s (REVIEW_PREFLIGHT_WORST_CASE_SECONDS below), since -# REVIEW_PREFLIGHT_MAX_ESCALATIONS=4 means at most 4 of those 6 batches can -# actually contain an escalating candidate. See +# containing a candidate that makes a second attempt (rescue OR confirmation) +# costs 2 * REVIEW_PREFLIGHT_TIMEOUT_SECONDS (base + second attempt, +# sequential within that one candidate's own thread), not +# REVIEW_PREFLIGHT_TIMEOUT_SECONDS. Crucially, this per-batch bound holds +# regardless of HOW MANY candidates in that one batch make a second attempt +# (concurrency means the batch's wall time is its slowest member, never a +# sum of every member), and therefore holds regardless of either second- +# attempt budget's specific cap -- REVIEW_PREFLIGHT_MAX_CONFIRMATIONS below +# deliberately has a much larger cap than this one without changing this +# arithmetic at all. With REVIEW_PREFLIGHT_MAX_TOTAL_ROUTES=24 candidates in +# batches of REVIEW_PREFLIGHT_BATCH_SIZE=4, that is ceil(24/4)=6 batches, so +# the worst case is 6 * 2 * 10 = 120s (REVIEW_PREFLIGHT_WORST_CASE_SECONDS +# below) -- already the fully pessimistic case of every batch needing its +# worst case, true independent of either budget's cap value. See # docs/adr/0005-sidecar-preflight-token-budget.md, Decision section 3 for the # ADR's own (pre-batching, sequential) 160s derivation of this same shared # cap's value; batching changes the wall-clock arithmetic, not the cap itself. @@ -112,16 +121,57 @@ # truth so a future change to either phase's constants cannot silently # desynchronize the two budgets again. #1454 (a base-probe *success* never # confirms the candidate at the real serving budget, REVIEW_MAX_OUTPUT_TOKENS) -# is FIXED (Devin Review, "Serving-incompatible routes pass startup"): a +# was FIXED (Devin Review, "Serving-incompatible routes pass startup"): a # base-probe success now always draws one confirming attempt at -# REVIEW_PREFLIGHT_ESCALATED_TOKENS from this SAME shared counter before -# being admitted, exactly like a base-probe failure's existing escalation -# attempt -- see _preflight_review_agents's docstring. Per-candidate worst -# case stays at most one base + one second attempt either way, so the +# REVIEW_PREFLIGHT_ESCALATED_TOKENS before being admitted, exactly like a +# base-probe failure's existing rescue attempt. +# +# FIXED (ContextualWisdomLab/.github#1415, Devin Review finding "Later +# healthy routes cannot start"): that #1454 fix originally drew the +# confirmation attempt from this SAME counter, exactly like a base-probe +# failure's rescue attempt. That was wrong: this counter is sized (4) for +# the RARE rescue case, but every single successful base probe now needs a +# confirmation -- the COMMON case, not the rare one. As few as +# REVIEW_PREFLIGHT_MAX_ESCALATIONS candidates in the very first batch(es) +# each succeeding their base probe could reserve every slot for their own +# confirmations, permanently denying every later candidate's confirmation +# regardless of merit -- defeating the entire point of batching up to +# REVIEW_PREFLIGHT_MAX_TOTAL_ROUTES candidates to find one usable route. +# Confirmation now draws from its own separate +# REVIEW_PREFLIGHT_MAX_CONFIRMATIONS budget (see below); this counter keeps +# its original, narrower rescue-only purpose and its original cap. Per- +# candidate worst case stays at most one base + one second attempt either +# way (confirmation OR rescue, never both on the same candidate), so the # REVIEW_PREFLIGHT_WORST_CASE_SECONDS/REVIEW_STARTUP_WATCHDOG_SECONDS -# arithmetic derived below is unchanged by this fix. +# arithmetic derived below is unchanged by either fix -- see this comment's +# opening paragraph for why the formula never depended on either budget's +# specific cap value in the first place. REVIEW_PREFLIGHT_MAX_ESCALATIONS = 4 +# FIXED (ContextualWisdomLab/.github#1415, Devin Review "Later healthy +# routes cannot start"): the mandatory serving-budget CONFIRMATION of an +# already-successful base probe (see REVIEW_PREFLIGHT_MAX_ESCALATIONS above +# for the full incident) needs its own budget, separate from that counter's +# original, narrow "rescue a failed base probe" purpose. Since confirmation +# runs for EVERY successful base probe -- the common case, not a rare one -- +# this budget is sized to REVIEW_PREFLIGHT_MAX_TOTAL_ROUTES: the maximum +# number of candidates this preflight run can ever probe across BOTH stages +# combined (the primary catalog and, when it runs, the priced-fallback +# catalog -- see _preflight_with_fallback, which shares one instance of this +# budget across both stages exactly like it already does for +# REVIEW_PREFLIGHT_MAX_ESCALATIONS). That size guarantees even the fully +# pessimistic case -- every candidate ever probed in this run succeeds its +# base probe -- still gets its required confirmation shot; this is not an +# unbounded allowance, it is bounded by the same total-route cap this +# preflight can never exceed regardless of how this constant is set. As +# reasoned above, sizing this budget larger than +# REVIEW_PREFLIGHT_MAX_ESCALATIONS does not change +# REVIEW_PREFLIGHT_WORST_CASE_SECONDS: each batch's wall time is bounded by +# its slowest candidate (at most one base + one second attempt), regardless +# of how many candidates in that batch actually make a second attempt or +# which of the two budgets backs it. +REVIEW_PREFLIGHT_MAX_CONFIRMATIONS = REVIEW_PREFLIGHT_MAX_TOTAL_ROUTES + # Mirrors contextual_orchestrator.model_discovery.DISCOVERY_TIMEOUT_SECONDS at # ORCHESTRATOR_PIN_SHA (contextual_orchestrator_review_sidecar.sh) exactly. Not # imported directly: that module's own dependency tree is only installed after @@ -496,6 +546,8 @@ def _preflight_review_agents( client: Any, escalations_used: int = 0, escalation_budget: "_EscalationBudget | None" = None, + confirmations_used: int = 0, + confirmation_budget: "_EscalationBudget | None" = None, ) -> tuple[list[object], dict[str, object]]: """Probe each route with the runtime request contract and keep ready routes. @@ -511,14 +563,20 @@ def _preflight_review_agents( ``REVIEW_PREFLIGHT_BASE_TOKENS``, so a candidate whose real completion ceiling sat strictly between the base and serving budgets passed startup and only failed once real review traffic began. There are exactly two - ways a candidate reaches that confirming probe: + ways a candidate reaches that confirming probe, and each draws from its + OWN, separately-purposed budget (`ContextualWisdomLab/.github#1415`, + Devin Review "Later healthy routes cannot start" -- see the two + constants' own module-level comments for the full incident and sizing + rationale): 1. **The base probe already returned usable text.** This is the ordinary, most common case; the second probe exists purely to CONFIRM that same candidate also serves the real budget, not to diagnose a - failure. Success marks ``confirmed_at_serving_budget`` (not - ``escalated``) on the row -- the base attempt already worked, this - second attempt only re-proves it at the real budget. + failure. This draws from ``confirmation_budget`` + (``REVIEW_PREFLIGHT_MAX_CONFIRMATIONS``). Success marks + ``confirmed_at_serving_budget`` (not ``escalated``) on the row -- the + base attempt already worked, this second attempt only re-proves it at + the real budget. 2. **The base probe's response was empty for a "budget too small" reason** -- either ``choices[0].finish_reason == "length"`` (OpenAI's documented signature), or the vendored @@ -527,36 +585,39 @@ def _preflight_review_agents( model can hit under a different ``finish_reason`` -- provider ``finish_reason`` semantics for this case are not verified as uniform across the pool, and this is the exact original failure mode PR #1436 - responded to). Success marks ``escalated`` on the row -- the base - attempt failed and this second attempt is what actually rescued it. + responded to). This draws from ``escalation_budget`` + (``REVIEW_PREFLIGHT_MAX_ESCALATIONS``). Success marks ``escalated`` on + the row -- the base attempt failed and this second attempt is what + actually rescued it. Either way, the SAME candidate gets at most one additional attempt (never - a third), and that attempt is bounded by one shared - ``REVIEW_PREFLIGHT_MAX_ESCALATIONS`` counter -- there is no separate - counter for "confirming a success" versus "escalating a failure", since - both are the identical "one more attempt at the larger budget" action for - worst-case-timing purposes. The counter's ``escalations_used`` argument - carries forward across calls (not per candidate, and not reset per - call): a caller that probes two stages of the same preflight run (e.g. - ``_preflight_with_fallback``'s primary and fallback stages) must pass the - previous stage's ending count back in here so the two stages share one - budget instead of each getting its own -- otherwise the computed - worst-case bound this counter exists to enforce silently doubles. A base - response that is empty for any OTHER reason (no budget-too-small - signature) is not retried at all: a genuinely-down candidate never - reaches the second-attempt path, so it cannot produce a false "healthy" - read, and a candidate denied its second attempt by the shared budget - (whichever of the two reasons brought it there) is recorded - ``escalation_budget_exhausted`` and not admitted -- fails closed, exactly - like a base failure that never got its own escalation slot. - An exception on the escalated attempt (transport failure, auth failure, + a third, and never both a confirmation AND an escalation). Each of the + two budgets' ``*_used`` argument carries forward across calls (not per + candidate, and not reset per call): a caller that probes two stages of + the same preflight run (e.g. ``_preflight_with_fallback``'s primary and + fallback stages) must pass each previous stage's ending count back in + here so the two stages share one pair of budgets instead of each getting + its own -- otherwise the computed worst-case bound these counters exist + to enforce silently doubles. A base response that is empty for any OTHER + reason (no budget-too-small signature) is not retried at all: a + genuinely-down candidate never reaches the second-attempt path, so it + cannot produce a false "healthy" read, and a candidate denied its second + attempt by its own (exhausted) budget is recorded + ``confirmation_budget_exhausted`` or ``escalation_budget_exhausted`` + (matching which of the two paths it took) and not admitted -- fails + closed, exactly like a base failure that never got its own second-attempt + slot. Exhaustion of ONE budget never blocks a candidate whose path draws + from the OTHER budget -- the exact cross-purpose interaction that made a + confirmation-only path spend a rescue-only allowance is the bug this + separation fixes. + An exception on the second attempt (transport failure, auth failure, rate limit, server error, or a genuine budget rejection) is recorded via ``_record_provider_exception`` -- the SAME sanitized classification the - base probe uses, regardless of attempt. An HTTP status alone does not - distinguish "this candidate's real ceiling is below the escalated - budget" from any other cause (401/429/5xx are not budget evidence); this - codebase has no validated signal today that does, so it does not invent - one via an over-specific label. + base probe uses, regardless of attempt or which path it came from. An + HTTP status alone does not distinguish "this candidate's real ceiling is + below the escalated budget" from any other cause (401/429/5xx are not + budget evidence); this codebase has no validated signal today that does, + so it does not invent one via an over-specific label. The report deliberately records only stable route identity, a bounded exception class name, an optional numeric HTTP status, attempt count, and @@ -566,40 +627,52 @@ def _preflight_review_agents( every response-bearing outcome -- success included, not just failure/escalation, so future tuning has a real "normal" baseline to compare against -- and always describe the same, most recent attempt for - a route (the base attempt when only one was made; the escalated attempt - when a second was made) -- never a mix of the two attempts' state. When - the escalated attempt raises an exception instead of returning a + a route (the base attempt when only one was made; the second attempt + when one was made) -- never a mix of the two attempts' state. When + the second attempt raises an exception instead of returning a response, both fields are absent entirely (there is no response to describe) rather than silently retaining the base attempt's values. Batched concurrent probing (``_preflight_review_agent_batches``) calls this function once per candidate, concurrently, from several threads at - once within one batch. A plain ``escalations_used`` int passed by value - cannot coordinate admission safely once multiple threads can observe and - spend the same budget concurrently, so callers that need cross-thread - coordination pass a shared ``escalation_budget`` instead; a caller that - only ever probes sequentially (every direct call in this module's own - test suite, and any single, unbatched invocation) can keep passing a - plain ``escalations_used`` int, which is wrapped in a private, - single-owner budget for the duration of this one call -- identical - external behavior to before this thread-safety addition. + once within one batch. A plain ``escalations_used``/``confirmations_used`` + int passed by value cannot coordinate admission safely once multiple + threads can observe and spend the same budget concurrently, so callers + that need cross-thread coordination pass a shared ``escalation_budget``/ + ``confirmation_budget`` instead; a caller that only ever probes + sequentially (every direct call in this module's own test suite, and any + single, unbatched invocation) can keep passing plain + ``escalations_used``/``confirmations_used`` ints, each wrapped in a + private, single-owner budget for the duration of this one call -- + identical external behavior to before this thread-safety addition. Args: agents: Selected zero-cost model agents. client: Vendored ``ModelClient``-compatible transport. - escalations_used: Escalations already spent earlier in this same - preflight run (e.g. by a prior stage), so the shared budget is - honored across calls rather than restarted at zero. Ignored when - ``escalation_budget`` is given. - escalation_budget: A shared, thread-safe budget to coordinate - admission across concurrent callers. When omitted, a private - budget seeded from ``escalations_used`` is used instead. + escalations_used: Rescue escalations already spent earlier in this + same preflight run (e.g. by a prior stage), so the shared rescue + budget is honored across calls rather than restarted at zero. + Ignored when ``escalation_budget`` is given. + escalation_budget: A shared, thread-safe budget bounding rescue + attempts (a FAILED base probe) to coordinate admission across + concurrent callers. When omitted, a private budget seeded from + ``escalations_used`` is used instead. + confirmations_used: Confirmations already spent earlier in this same + preflight run, mirroring ``escalations_used`` for the separate + confirmation budget. Ignored when ``confirmation_budget`` is + given. + confirmation_budget: A shared, thread-safe budget bounding + confirmation attempts (a SUCCESSFUL base probe) -- deliberately + separate from ``escalation_budget`` (see + ``REVIEW_PREFLIGHT_MAX_CONFIRMATIONS``'s module-level comment for + why). When omitted, a private budget seeded from + ``confirmations_used`` is used instead. Returns: A pair of viable agents and a sanitized preflight report. The - report's ``escalations_used`` is the running total including - ``escalations_used``'s starting value, so a caller chaining another - stage can pass it straight back in. + report's ``escalations_used``/``confirmations_used`` are each the + running total including that argument's starting value, so a caller + chaining another stage can pass them straight back in. Raises: ReviewPreflightError: If no provider route returns usable text. @@ -607,6 +680,9 @@ def _preflight_review_agents( budget = escalation_budget or _EscalationBudget( REVIEW_PREFLIGHT_MAX_ESCALATIONS, escalations_used ) + confirm_budget = confirmation_budget or _EscalationBudget( + REVIEW_PREFLIGHT_MAX_CONFIRMATIONS, confirmations_used + ) viable: list[object] = [] routes: list[dict[str, object]] = [] for agent in agents: @@ -660,30 +736,46 @@ def _preflight_review_agents( # ContextualWisdomLab/.github#1454: admission still requires # confirming that text holds at the real serving budget, not just # REVIEW_PREFLIGHT_BASE_TOKENS) or it matched a "budget too small" - # signature above and needs the existing escalation retry. Both - # reach the SAME shared, bounded second-attempt path below. + # signature above and needs the existing escalation retry. # - # KNOWN, ACCEPTED, TRACKED LIMITATION on the budget.try_reserve() - # branch below, ContextualWisdomLab/.github#1458 (originally - # documented on ADR-0005, docs/adr/0005-sidecar-preflight-token-budget.md): - # the shared counter is first-come-first-served in catalog order + # FIXED (ContextualWisdomLab/.github#1415, Devin Review "Later + # healthy routes cannot start"): these two cases now draw from TWO + # SEPARATE budgets, not one shared one -- see + # REVIEW_PREFLIGHT_MAX_ESCALATIONS/REVIEW_PREFLIGHT_MAX_CONFIRMATIONS' + # module-level comments for the full incident. Confirming an + # already-successful base probe is the common case (every successful + # candidate needs it); rescuing a failed one is the rare case. A + # scarce rescue budget consumed by a burst of ordinary confirmations + # (or vice versa) must never deny a DIFFERENT candidate's unrelated + # second attempt. + second_attempt_budget = confirm_budget if base_has_text else budget + second_attempt_exhausted_error = ( + "confirmation_budget_exhausted" if base_has_text else "escalation_budget_exhausted" + ) + # + # KNOWN, ACCEPTED, TRACKED LIMITATION on the try_reserve() branch + # below, ContextualWisdomLab/.github#1458 (originally documented on + # ADR-0005, docs/adr/0005-sidecar-preflight-token-budget.md): each + # budget is still first-come-first-served in catalog order # (build_zdr_prioritized_catalog's (cost_evidence_rank, - # zdr_attested_rank, provider, model) sort, not random), for BOTH the - # confirmation and escalation uses added by this fix. A - # later-sorting candidate -- whether it needs confirmation of an - # already-successful base probe, or escalation of a failed one -- can - # be denied its own second attempt purely because - # REVIEW_PREFLIGHT_MAX_ESCALATIONS earlier candidates already claimed - # the shared budget, even if it would have succeeded at - # REVIEW_PREFLIGHT_ESCALATED_TOKENS. Deliberately not reordered + # zdr_attested_rank, provider, model) sort, not random). A + # later-sorting candidate needing a rescue can still be denied its + # own escalation attempt purely because + # REVIEW_PREFLIGHT_MAX_ESCALATIONS earlier-sorting candidates already + # claimed that (deliberately scarce) rescue budget, even if it would + # have succeeded at REVIEW_PREFLIGHT_ESCALATED_TOKENS -- unchanged by + # this fix, and unrelated to it: REVIEW_PREFLIGHT_MAX_CONFIRMATIONS + # is sized to REVIEW_PREFLIGHT_MAX_TOTAL_ROUTES precisely so the same + # exhaustion can never happen on the confirmation path (see that + # constant's own comment). Deliberately not reordered # (round-robin/random): a fixed-size shared budget smaller than the # candidate pool always has to deny someone a second attempt, so # reordering only changes who, and picking a specific policy without # real telemetry on which candidates actually need it would itself be # the kind of unjustified heuristic this design rejects elsewhere. - if not budget.try_reserve(): + if not second_attempt_budget.try_reserve(): row["status"] = "rejected" - row["error_type"] = "escalation_budget_exhausted" + row["error_type"] = second_attempt_exhausted_error routes.append(row) continue row["attempts"] = 2 @@ -752,6 +844,8 @@ def _preflight_review_agents( "rejected_count": len(agents) - len(viable), "escalations_used": budget.used, "escalation_budget": REVIEW_PREFLIGHT_MAX_ESCALATIONS, + "confirmations_used": confirm_budget.used, + "confirmation_budget": REVIEW_PREFLIGHT_MAX_CONFIRMATIONS, "routes": routes, } if not viable: @@ -766,6 +860,7 @@ def _preflight_review_agent_batches( *, client: Any, escalation_budget: "_EscalationBudget | None" = None, + confirmation_budget: "_EscalationBudget | None" = None, ) -> tuple[list[object], dict[str, object]]: """Probe bounded concurrent batches until one batch contains a ready route. @@ -777,25 +872,35 @@ def _preflight_review_agent_batches( batch yields at least one ready route -- a later, unprobed batch can never "hide" a route this run already found usable. - ADR-0005's escalation budget (``REVIEW_PREFLIGHT_MAX_ESCALATIONS``) is - still one shared, run-wide count, not one per batch or per candidate; - since several candidates in the same batch can reach the escalation - decision concurrently, admission is coordinated through - ``_EscalationBudget``'s lock rather than a plain int, so the cap is never - exceeded even under concurrency. One consequence of that concurrency: + Two separate run-wide budgets bound the two distinct second-attempt + purposes (``ContextualWisdomLab/.github#1415``, Devin Review "Later + healthy routes cannot start" -- see ``REVIEW_PREFLIGHT_MAX_ESCALATIONS``/ + ``REVIEW_PREFLIGHT_MAX_CONFIRMATIONS``'s own module-level comments for + the full incident and sizing rationale): ``escalation_budget`` for + rescuing a FAILED base probe (deliberately scarce), and + ``confirmation_budget`` for confirming a SUCCESSFUL one (sized to never + starve a genuinely healthy candidate). Neither is one-per-batch or + one-per-candidate; since several candidates in the same batch can reach + either decision concurrently, admission is coordinated through each + ``_EscalationBudget``'s own lock rather than a plain int, so neither cap + is ever exceeded under concurrency. One consequence of that concurrency: ADR-0005's "first-come-first-served in catalog order" framing holds strictly only ACROSS batches (which stay sequential); WITHIN one batch, - whichever candidate's thread reaches the reservation first wins it. This - changes at most which candidate among a few concurrently-probed ones - claims a scarce slot -- the shared cap itself is a hard, lock-enforced + whichever candidate's thread reaches a given reservation first wins it. + This changes at most which candidate among a few concurrently-probed + ones claims a scarce slot -- each cap itself is a hard, lock-enforced invariant regardless of scheduling. Args: agents: Selected zero-cost model agents, probed in catalog order. client: Vendored ``ModelClient``-compatible transport. - escalation_budget: A shared budget to coordinate escalation + escalation_budget: A shared budget to coordinate rescue-attempt admission with another stage (see ``_preflight_with_fallback``). A fresh, run-local budget is created when omitted. + confirmation_budget: A shared budget to coordinate confirmation- + attempt admission with another stage, separate from + ``escalation_budget``. A fresh, run-local budget is created when + omitted. Returns: A pair of viable agents (from the first batch with any) and a @@ -806,6 +911,9 @@ def _preflight_review_agent_batches( route. """ budget = escalation_budget or _EscalationBudget(REVIEW_PREFLIGHT_MAX_ESCALATIONS) + confirm_budget = confirmation_budget or _EscalationBudget( + REVIEW_PREFLIGHT_MAX_CONFIRMATIONS + ) attempted_routes: list[dict[str, object]] = [] attempted_count = 0 for offset in range(0, len(agents), REVIEW_PREFLIGHT_BATCH_SIZE): @@ -817,6 +925,7 @@ def _preflight_review_agent_batches( [agent], client=client, escalation_budget=budget, + confirmation_budget=confirm_budget, ) for agent in batch ] @@ -838,6 +947,8 @@ def _preflight_review_agent_batches( "rejected_count": attempted_count - len(viable), "escalations_used": budget.used, "escalation_budget": REVIEW_PREFLIGHT_MAX_ESCALATIONS, + "confirmations_used": confirm_budget.used, + "confirmation_budget": REVIEW_PREFLIGHT_MAX_CONFIRMATIONS, "routes": attempted_routes, "batch_size": REVIEW_PREFLIGHT_BATCH_SIZE, } @@ -848,6 +959,8 @@ def _preflight_review_agent_batches( "rejected_count": attempted_count, "escalations_used": budget.used, "escalation_budget": REVIEW_PREFLIGHT_MAX_ESCALATIONS, + "confirmations_used": confirm_budget.used, + "confirmation_budget": REVIEW_PREFLIGHT_MAX_CONFIRMATIONS, "routes": attempted_routes, "batch_size": REVIEW_PREFLIGHT_BATCH_SIZE, } @@ -863,22 +976,29 @@ def _preflight_with_fallback( The two stages -- each itself run through ``_preflight_review_agent_batches``'s bounded concurrent batching -- share - ADR-0005's one ``REVIEW_PREFLIGHT_MAX_ESCALATIONS`` budget for the whole - preflight run, not one budget each: one ``_EscalationBudget`` is created - here and passed into both stages, so a run that rejects all primary - routes and then probes the fallback catalog still spends at most - ``REVIEW_PREFLIGHT_MAX_ESCALATIONS`` escalations in total across both - stages combined -- otherwise the computed worst-case bound this counter - exists to enforce would silently double. Both stages' reports remain in - the result: the fallback (or sole) stage's report carries the run's - final, cumulative ``escalations_used``, and ``primary_attempt`` nests the - primary stage's own report -- including its own ``escalations_used`` -- - whenever a fallback stage ran at all. + ONE pair of run-wide budgets for the whole preflight run, not one pair + each: one ``_EscalationBudget`` for rescue attempts + (``REVIEW_PREFLIGHT_MAX_ESCALATIONS``) and a separate one for confirmation + attempts (``REVIEW_PREFLIGHT_MAX_CONFIRMATIONS``, + ``ContextualWisdomLab/.github#1415``) are each created here and passed + into both stages, so a run that rejects all primary routes and then + probes the fallback catalog still spends at most each budget's own cap + in total across both stages combined -- otherwise the computed worst-case + bound these counters exist to enforce would silently double. Both + stages' reports remain in the result: the fallback (or sole) stage's + report carries the run's final, cumulative ``escalations_used``/ + ``confirmations_used``, and ``primary_attempt`` nests the primary + stage's own report -- including its own ``escalations_used``/ + ``confirmations_used`` -- whenever a fallback stage ran at all. """ budget = _EscalationBudget(REVIEW_PREFLIGHT_MAX_ESCALATIONS) + confirm_budget = _EscalationBudget(REVIEW_PREFLIGHT_MAX_CONFIRMATIONS) try: viable, report = _preflight_review_agent_batches( - primary_agents, client=client, escalation_budget=budget + primary_agents, + client=client, + escalation_budget=budget, + confirmation_budget=confirm_budget, ) return viable, report, False except ReviewPreflightError as primary_error: @@ -886,7 +1006,10 @@ def _preflight_with_fallback( raise try: viable, report = _preflight_review_agent_batches( - fallback_agents, client=client, escalation_budget=budget + fallback_agents, + client=client, + escalation_budget=budget, + confirmation_budget=confirm_budget, ) except ReviewPreflightError as fallback_error: fallback_error.report["primary_attempt"] = primary_error.report diff --git a/tests/test_contextual_orchestrator_review_runtime_preflight.py b/tests/test_contextual_orchestrator_review_runtime_preflight.py index 4fabee3345..c1af690b00 100644 --- a/tests/test_contextual_orchestrator_review_runtime_preflight.py +++ b/tests/test_contextual_orchestrator_review_runtime_preflight.py @@ -11,6 +11,7 @@ from pathlib import Path import subprocess import sys +import threading from types import SimpleNamespace import pytest @@ -1169,20 +1170,78 @@ def test_base_probe_success_not_admitted_when_serving_budget_probe_raises() -> N assert "confirmed_at_serving_budget" not in row -def test_base_probe_success_confirmation_shares_the_escalation_budget() -> None: - """A base-probe success's mandatory confirmation draws from the SAME - shared ``REVIEW_PREFLIGHT_MAX_ESCALATIONS`` counter a budget-too-small - escalation would -- there is no separate, unbounded allowance for - confirming successes, which would silently reintroduce an unbounded - worst case this fix must not create. +def test_base_probe_success_confirmation_has_its_own_dedicated_budget() -> None: + """Regression for Devin Review's "Later healthy routes cannot start" + finding (`ContextualWisdomLab/.github#1415`): a base-probe success's + mandatory confirmation used to draw from the SAME shared + ``REVIEW_PREFLIGHT_MAX_ESCALATIONS`` counter a budget-too-small + escalation would -- a counter sized (4) for the RARE rescue case, not + the common "confirm every success" case. Confirmation now draws from its + own separate ``REVIEW_PREFLIGHT_MAX_CONFIRMATIONS`` budget, so + exhausting the (still small, still bounded) escalation budget on + genuinely failed candidates must never deny a later, unrelated + candidate's confirmation. """ namespace = _load_launcher() preflight = namespace["_preflight_review_agents"] max_escalations = namespace["REVIEW_PREFLIGHT_MAX_ESCALATIONS"] + max_confirmations = namespace["REVIEW_PREFLIGHT_MAX_CONFIRMATIONS"] + assert max_confirmations > max_escalations, ( + "the confirmation budget must be dedicated and large enough to cover " + "every candidate this preflight run can ever probe -- not merely " + "equal to the small, deliberately scarce rescue budget" + ) + + # Exhaust the ESCALATION (rescue) budget entirely on candidates that + # fail their base probe with a "budget too small" signature and then + # (deliberately, in this test) fail their rescue attempt too, the same + # way every time -- these never touch the confirmation budget at all, + # they only need to fully spend the escalation budget's slots. + length_response = {"choices": [{"finish_reason": "length", "message": {"content": ""}}]} + escalation_budget_users = [ + SimpleNamespace(id=f"escalation_user_{index}", provider_name="openrouter", model="x/free") + for index in range(max_escalations) + ] + # A base-probe SUCCESS needing only confirmation -- must not be blocked + # by the escalation budget above being fully spent. + confirmed = SimpleNamespace( + id="confirmed_despite_escalation_exhaustion", + provider_name="openrouter", + model="x/free", + ) + client = _ProbeClient( + {agent.id: dict(length_response) for agent in escalation_budget_users} + | {confirmed.id: _openai_text("OK")} + ) + + viable, report = preflight([*escalation_budget_users, confirmed], client=client) + + assert viable == [confirmed] + assert report["escalations_used"] == max_escalations + assert report["confirmations_used"] == 1 + for row in report["routes"][:-1]: + assert row["status"] == "rejected" + assert row["error_type"] == "invalid_chat_response" + confirmed_row = report["routes"][-1] + assert confirmed_row["status"] == "ready" + assert confirmed_row["confirmed_at_serving_budget"] is True + + +def test_confirmation_budget_is_bounded_not_unbounded() -> None: + """The confirmation budget is dedicated, not shared -- but it is still a + real, finite cap (``REVIEW_PREFLIGHT_MAX_CONFIRMATIONS``), never an + unbounded allowance that would reintroduce an uncomputed worst case. + Exhausting it is recorded with its own distinct + ``confirmation_budget_exhausted`` classification, never conflated with + the separate ``escalation_budget_exhausted`` outcome. + """ + namespace = _load_launcher() + preflight = namespace["_preflight_review_agents"] + max_confirmations = namespace["REVIEW_PREFLIGHT_MAX_CONFIRMATIONS"] agents = [ SimpleNamespace(id=f"confirmed_{index}", provider_name="openrouter", model="x/free") - for index in range(max_escalations) + for index in range(max_confirmations) ] exhausted = SimpleNamespace( id="confirmation_exhausted", provider_name="openrouter", model="x/free" @@ -1197,10 +1256,11 @@ def test_base_probe_success_confirmation_shares_the_escalation_budget() -> None: assert viable == agents exhausted_row = report["routes"][-1] assert exhausted_row["status"] == "rejected" - assert exhausted_row["error_type"] == "escalation_budget_exhausted" + assert exhausted_row["error_type"] == "confirmation_budget_exhausted" assert exhausted_row["attempts"] == 1 - assert report["escalations_used"] == max_escalations - assert len(client.calls) == max_escalations * 2 + 1 + assert report["confirmations_used"] == max_confirmations + assert report["escalations_used"] == 0 + assert len(client.calls) == max_confirmations * 2 + 1 def test_escalation_budget_is_shared_and_bounded_across_candidates() -> None: @@ -1539,6 +1599,126 @@ def test_preflight_advances_to_next_bounded_batch() -> None: assert client.calls[-1][0] == agents[-1] +class _PerAgentSequencedClient: + """Return each agent's OWN configured attempt sequence, thread-safely. + + Unlike ``_SequencedClient`` (a single global sequence consumed strictly + in call order -- unsuitable once several agents' calls can interleave + unpredictably across concurrent batch threads), this looks up the next + outcome by (agent id, that agent's own call count), tracked per agent id + under a lock, so each candidate's own base-then-second-attempt sequence + stays deterministic regardless of how batch threads happen to interleave. + """ + + def __init__(self, outcomes: dict[str, list[object]]) -> None: + self._outcomes = outcomes + self._counts: dict[str, int] = {} + self._lock = threading.Lock() + self.calls: list[tuple[object, str, dict[str, object]]] = [] + + def proxy_send_once( + self, agent: object, endpoint: str, payload: dict[str, object] + ) -> dict[str, object]: + """Capture one request and return that agent's next configured outcome.""" + agent_id = str(getattr(agent, "id")) + with self._lock: + index = self._counts.get(agent_id, 0) + self._counts[agent_id] = index + 1 + self.calls.append((agent, endpoint, payload)) + outcome = self._outcomes[agent_id][index] + if isinstance(outcome, BaseException): + raise outcome + assert isinstance(outcome, dict) + return outcome + + +def test_batched_preflight_first_batch_confirmations_do_not_starve_a_later_healthy_route() -> None: + """Regression for Devin Review's "Later healthy routes cannot start" + finding (`ContextualWisdomLab/.github#1415`) on the batched preflight + entry point ``_preflight_review_agent_batches`` -- the exact scenario + described in the finding, reproduced end to end. + + The first ``REVIEW_PREFLIGHT_BATCH_SIZE`` candidates (batch 1) each + succeed their cheap base probe -- so each needs a mandatory confirmation + at the real serving budget -- but each then genuinely FAILS that + confirmation (a real "usable at 16 tokens, unusable at 4096" route, + correctly not admitted). A fifth candidate (batch 2) also succeeds its + base probe AND would succeed its confirmation too, if it ever got the + chance. + + Under the pre-fix code, all four batch-1 candidates' confirmations drew + from the SAME shared ``_EscalationBudget`` capped at + ``REVIEW_PREFLIGHT_MAX_ESCALATIONS`` (4) -- exactly enough for four + candidates to each reserve one slot before failing confirmation on + their own merits, permanently exhausting that shared counter. The fifth + candidate's later, unrelated confirmation request was then denied + purely by ``_EscalationBudget.try_reserve()`` returning ``False`` -- + ``escalation_budget_exhausted`` -- never even making its confirmation + call, regardless of the fact that it would have passed. With a budget + dedicated to confirmations specifically (this fix), the fifth candidate + is unaffected by batch 1's unrelated confirmation attempts and is + correctly admitted. + """ + namespace = _load_launcher() + preflight = namespace["_preflight_review_agent_batches"] + batch_size = namespace["REVIEW_PREFLIGHT_BATCH_SIZE"] + max_escalations = namespace["REVIEW_PREFLIGHT_MAX_ESCALATIONS"] + assert batch_size == max_escalations, ( + "this regression specifically needs one batch's worth of candidates " + "to exactly exhaust the (old, shared) escalation budget" + ) + + ok = _openai_text("OK") + # Genuinely fails its confirmation: usable at the base budget, empty (no + # budget-too-small signature) at the real serving budget -- correctly + # never admitted, regardless of which budget backed the attempt. + fails_confirmation = {"choices": [{"message": {"content": ""}}]} + + batch_one_serving_incompatible = [ + SimpleNamespace(id=f"batch1_narrow_{index}", provider_name="openrouter", model="x/free") + for index in range(batch_size) + ] + later_healthy_route = SimpleNamespace( + id="batch2_genuinely_healthy", provider_name="nvidia_nim", model="healthy/free" + ) + client = _PerAgentSequencedClient( + {agent.id: [ok, fails_confirmation] for agent in batch_one_serving_incompatible} + | {later_healthy_route.id: [ok, ok]} + ) + + viable, report = preflight( + [*batch_one_serving_incompatible, later_healthy_route], client=client + ) + + # The fifth candidate -- genuinely healthy at both budgets -- must be + # admitted. It must NOT be recorded as denied by escalation-budget + # exhaustion caused by four entirely different candidates' confirmations. + assert viable == [later_healthy_route] + later_route_row = next( + row for row in report["routes"] if row["agent_id"] == later_healthy_route.id + ) + assert later_route_row["status"] == "ready" + assert later_route_row["confirmed_at_serving_budget"] is True + assert "error_type" not in later_route_row + + # The four batch-1 candidates are correctly NOT admitted -- on their own + # merits (a real confirmation failure), never on budget exhaustion. + batch_one_rows = [ + row for row in report["routes"] if row["agent_id"] != later_healthy_route.id + ] + assert len(batch_one_rows) == batch_size + for row in batch_one_rows: + assert row["status"] == "rejected" + assert row["error_type"] == "invalid_chat_response" + + # The escalation (rescue) budget was never touched at all -- none of + # these candidates ever failed their base probe. + assert report["escalations_used"] == 0 + # Confirmation budget evidence: five candidates each made exactly one + # confirmation attempt. + assert report["confirmations_used"] == batch_size + 1 + + def test_fallback_escalation_budget_is_shared_with_primary_and_bounds_worst_case() -> None: """Regression for Devin Review's fallback-retries-exceed-startup-deadline finding: ``_preflight_review_agents`` used to start ``escalations_used`` From 608755f21c2574d3cea53c69cfb3e0120b61ebf4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 04:15:53 +0000 Subject: [PATCH 31/65] fix(sidecar): count wall-clock seconds, not polls, in the startup watchdog Devin Review flagged a real bug on this PR ("Startup watchdog counts polls, not seconds", ContextualWisdomLab/.github#1415, review comment on scripts/ci/contextual_orchestrator_review_sidecar.sh line 439): the healthz-wait loop incremented a plain poll counter `i` once per iteration and compared *that* to `sidecar_startup_watchdog_seconds`, even though a single iteration's real wall-clock cost is the `curl --max-time 2` health probe's own duration (up to 2s) plus the trailing `sleep 1` (up to 3s total) -- not the 1s the counter implicitly assumed. A health probe that consumes its full timeout on every poll could let the 255s watchdog run for roughly 765s (~3x its documented budget) before firing, directly contradicting both the wall-clock derivation this same PR's earlier fix (b0917a64) established for REVIEW_STARTUP_WATCHDOG_SECONDS and the comment block immediately above this code explaining that derivation. Fix: reset bash's builtin $SECONDS to 0 immediately before the loop and compare $SECONDS -- real, auto-advancing wall-clock elapsed time, immune to curl's own per-call cost -- against the deadline instead of the hand-rolled counter. Updated the two other places the old counter was surfaced (the watchdog's own failure message context and the successful-startup log line) to report $SECONDS too, and updated the digit-overflow guard's own comment that referenced "$i" in prose. The `sleep 1` poll cadence is unchanged; only the deadline comparison and its reporting changed. Regression test: added test_healthz_wait_loop_fires_near_the_wall_clock_deadline_not_a_poll_count plus a message-format companion test to tests/test_contextual_orchestrator_review_runtime_preflight.py, following the existing _run_gateway_retry_loop convention in the same file -- extracting the loop's exact, tracked source (never a hand-copied duplicate) and driving it via subprocess against a fake, always-failing `curl` that sleeps longer than 1s per call, with a small configured watchdog. Asserts the loop fails near the configured wall-clock seconds and strictly below the poll-counting bound the old code needed. Verified red against the pre-fix loop text (reverted locally, confirmed both new tests fail -- one by timing, one by the extraction markers no longer matching) and green after the fix. Also updated test_sidecar_derives_its_watchdog_from_the_launcher_single_source_of_truth, which pinned the exact old `[ "$i" -ge ... ]` comparison string, to pin the new `$SECONDS`-based one and assert the old one is gone. Full verification: coverage run -m pytest tests (2107 passed, 1 skipped, 100% branch coverage on scripts/ci per pyproject.toml's fail_under=100), interrogate (100% docstrings), bash -n on the sidecar script, and git diff --check, all clean. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01KPmJErfkcHer4UVEgrQxUX --- CHANGELOG.md | 23 +++ .../contextual_orchestrator_review_sidecar.sh | 22 ++- ...l_orchestrator_review_runtime_preflight.py | 169 +++++++++++++++++- 3 files changed, 208 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 522ed27616..08fe24fc15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -83,6 +83,29 @@ Semantic Versioning where the repository publishes a release. budget), while a fifth, batch-2 candidate that would succeed both its base probe and its confirmation is wrongly denied under the pre-fix code and correctly admitted after the fix. +- Fix a real bug Devin Review found on this same PR ("Startup watchdog counts + polls, not seconds", `ContextualWisdomLab/.github#1415`): the sidecar's + healthz-wait loop incremented a plain poll counter `i` once per iteration + and compared *that* to `sidecar_startup_watchdog_seconds`, even though a + single iteration's real cost is the `curl --max-time 2` health probe's own + duration plus the trailing `sleep 1` — up to 3s, not the 1s the counter + implicitly assumed. A fully-consumed 2s timeout on every poll could let the + 255s watchdog run for roughly 765s (~3x its documented wall-clock budget) + before firing, directly contradicting the wall-clock derivation this same + PR's earlier fix (`b0917a64`) established `REVIEW_STARTUP_WATCHDOG_SECONDS` + as the single source of truth for. The loop now resets bash's builtin + `SECONDS` to 0 immediately before the loop and compares `$SECONDS` — + real, auto-advancing wall-clock elapsed time immune to curl's own per-call + cost — against the deadline instead, with both places the old poll count + was surfaced (the watchdog's own failure message and the successful-startup + log line) now reporting `$SECONDS` too. Added a regression + (`test_healthz_wait_loop_fires_near_the_wall_clock_deadline_not_a_poll_count` + plus a companion message-format test) that extracts the loop's exact, + tracked source and drives it against a fake, always-failing `curl` that + sleeps longer than 1s per call with a small configured watchdog, asserting + the loop fails near the configured wall-clock seconds and well under the + poll-counting bound the old code needed — verified failing against the + pre-fix loop text and passing after the fix. - Keep startup route probes on a ten-second timeout while giving serving-time model calls the Noema gate's 120-second transport budget; both retain a zero-retry transport policy at the client level (ADR-0005's own, diff --git a/scripts/ci/contextual_orchestrator_review_sidecar.sh b/scripts/ci/contextual_orchestrator_review_sidecar.sh index 7fadb36433..00b470fc4f 100755 --- a/scripts/ci/contextual_orchestrator_review_sidecar.sh +++ b/scripts/ci/contextual_orchestrator_review_sidecar.sh @@ -333,7 +333,7 @@ sidecar_startup_watchdog_seconds="$( 'from scripts.ci.contextual_orchestrator_review_launcher import REVIEW_STARTUP_WATCHDOG_SECONDS; print(REVIEW_STARTUP_WATCHDOG_SECONDS)' )" || fail "could not derive the startup watchdog seconds from the launcher module" # Same digit-count defense as REVIEW_PREFLIGHT_GATEWAY_MAX_ATTEMPTS below: a -# non-numeric value would make the "$i" -ge "$sidecar_startup_watchdog_seconds" +# non-numeric value would make the "$SECONDS" -ge "$sidecar_startup_watchdog_seconds" # comparison itself a bash integer-comparison error rather than a controlled # failure, and an all-digit value can still overflow the shell's integer # range the same way. Six digits (up to 999999s, over eleven days) is already @@ -398,7 +398,7 @@ cleanup_sidecar_on_error() { } trap cleanup_sidecar_on_error EXIT -i=0 +SECONDS=0 until curl -fsSL --max-time 2 "http://${ORCHESTRATOR_HOST}:${ORCHESTRATOR_PORT}/healthz" >/dev/null 2>&1; do if ! kill -0 "$sidecar_pid" 2>/dev/null; then sidecar_status=0 @@ -423,7 +423,6 @@ until curl -fsSL --max-time 2 "http://${ORCHESTRATOR_HOST}:${ORCHESTRATOR_PORT}/ fi fail "sidecar exited before healthz (status ${sidecar_status}); stderr: $(sed -n '1,20p' "$sidecar_stderr")" fi - i=$((i + 1)) # FIXED (ContextualWisdomLab/.github#1455, Devin Review finding "Startup # watchdog preempts valid preflight"): this bound covers the launcher's # ENTIRE startup sequence -- discovery, catalog build, AND preflight @@ -436,7 +435,20 @@ until curl -fsSL --max-time 2 "http://${ORCHESTRATOR_HOST}:${ORCHESTRATOR_PORT}/ # uncoordinated shell constant that only covered probing's own budget by # coincidence -- see that constant's own module-level comment for the full, # numbered derivation this single source of truth keeps in sync. - if [ "$i" -ge "$sidecar_startup_watchdog_seconds" ]; then + # + # FIXED (ContextualWisdomLab/.github#1415, Devin Review finding "Startup + # watchdog counts polls, not seconds"): the comparison below now reads + # bash's builtin $SECONDS -- reset to 0 immediately before this loop -- + # instead of a hand-incremented poll counter. $SECONDS auto-advances with + # real wall-clock time regardless of what runs inside the loop body, so it + # stays accurate even though each iteration's own cost varies (a `curl + # --max-time 2` call can itself take up to 2s before the trailing `sleep 1` + # even runs). A poll counter incremented once per iteration undercounts + # elapsed time by however long curl actually took, so this bound is now a + # true wall-clock deadline, immune to curl's own per-call timeout cost -- + # not an approximation of one via a poll count that silently assumed every + # iteration costs exactly 1s. + if [ "$SECONDS" -ge "$sidecar_startup_watchdog_seconds" ]; then fail "sidecar did not become healthy within ${sidecar_startup_watchdog_seconds}s; stderr: $(sed -n '1,20p' "$sidecar_stderr")" fi sleep 1 @@ -445,7 +457,7 @@ if [ ! -s "$preflight_report" ]; then fail "sidecar became healthy without runtime preflight evidence" fi publish_sidecar_evidence -log "healthz and provider-route preflight confirmed after ${i}s (pid $sidecar_pid)" +log "healthz and provider-route preflight confirmed after ${SECONDS}s (pid $sidecar_pid)" # A successful startup never re-reads $sidecar_stderr otherwise: only the # failure branches above embed it in their ::error:: message. A partial, # non-fatal provider discovery failure (e.g. one bad credential) would diff --git a/tests/test_contextual_orchestrator_review_runtime_preflight.py b/tests/test_contextual_orchestrator_review_runtime_preflight.py index c1af690b00..ffffb9c74a 100644 --- a/tests/test_contextual_orchestrator_review_runtime_preflight.py +++ b/tests/test_contextual_orchestrator_review_runtime_preflight.py @@ -12,6 +12,7 @@ import subprocess import sys import threading +import time from types import SimpleNamespace import pytest @@ -1876,11 +1877,17 @@ def test_sidecar_derives_its_watchdog_from_the_launcher_single_source_of_truth() "import REVIEW_STARTUP_WATCHDOG_SECONDS" in sidecar_text ) assert 'sidecar_startup_watchdog_seconds="$(' in sidecar_text - assert '[ "$i" -ge "$sidecar_startup_watchdog_seconds" ]' in sidecar_text + assert '[ "$SECONDS" -ge "$sidecar_startup_watchdog_seconds" ]' in sidecar_text # The old, uncoordinated hard-coded bound must be gone from the watchdog # comparison -- not just supplemented by the new derived one. assert '[ "$i" -ge 180 ]' not in sidecar_text assert "-ge 180" not in sidecar_text + # Regression for Devin Review's "Startup watchdog counts polls, not + # seconds" finding (ContextualWisdomLab/.github#1415): the comparison + # must read bash's real wall-clock $SECONDS builtin, not a hand-rolled + # poll counter incremented once per loop iteration regardless of how + # long that iteration's own curl call took. + assert '[ "$i" -ge "$sidecar_startup_watchdog_seconds" ]' not in sidecar_text # Exercise the exact derivation command the sidecar script runs, proving # it truly needs no vendored dependency yet at that point in the script @@ -1903,6 +1910,166 @@ def test_sidecar_derives_its_watchdog_from_the_launcher_single_source_of_truth() assert result.stdout.strip() == str(namespace["REVIEW_STARTUP_WATCHDOG_SECONDS"]) +_HEALTHZ_WAIT_BLOCK_START = "SECONDS=0\nuntil curl -fsSL --max-time 2 " +_HEALTHZ_WAIT_BLOCK_END = "\n sleep 1\ndone" + + +def _run_healthz_wait_loop( + tmp_path: Path, + *, + watchdog_seconds: int, + curl_delay_seconds: float, +) -> tuple[subprocess.CompletedProcess[str], float]: + """Execute the sidecar's real healthz-wait loop against a fake, always-failing curl. + + Extracts the exact, current source of the loop from the tracked sidecar + script (the same technique ``_run_gateway_retry_loop`` uses above) so a + future edit to the loop is automatically exercised here instead of + silently drifting from a second, hand-copied duplicate. + + Args: + tmp_path: Pytest's per-test scratch directory. + watchdog_seconds: Value for ``sidecar_startup_watchdog_seconds``. + curl_delay_seconds: How long the fake ``curl`` sleeps before failing, + simulating a slow-but-still-under-its-own-``--max-time`` health + probe. + + Returns: + The completed harness process and the measured real wall-clock time + the loop took to fail, as observed from outside the subprocess. + """ + sidecar_text = _SIDECAR.read_text(encoding="utf-8") + start = sidecar_text.index(_HEALTHZ_WAIT_BLOCK_START) + end = sidecar_text.index(_HEALTHZ_WAIT_BLOCK_END, start) + len(_HEALTHZ_WAIT_BLOCK_END) + loop_block = sidecar_text[start:end] + + fake_bin = tmp_path / "fake-bin" + fake_bin.mkdir() + fake_curl = fake_bin / "curl" + fake_curl.write_text( + f"#!/usr/bin/env bash\nsleep {curl_delay_seconds}\nexit 1\n", + encoding="utf-8", + ) + fake_curl.chmod(0o755) + + sidecar_stderr = tmp_path / "sidecar-stderr.txt" + sidecar_stderr.write_text("", encoding="utf-8") + preflight_report = tmp_path / "preflight.json" + preflight_report.write_text("{}", encoding="utf-8") + + harness = tmp_path / "harness.sh" + harness.write_text( + "set -euo pipefail\n" + "log() { printf '[test-sidecar] %s\\n' \"$*\"; }\n" + 'fail() { log "error: $*" >&2; exit 1; }\n' + # The real loop's "sidecar exited early" branch calls `kill -0 + # "$sidecar_pid"` to tell a dead sidecar apart from one still + # starting; stub it so this harness exercises only the watchdog + # deadline comparison, never that other branch. + "kill() { return 0; }\n" + "wait_for_sidecar_sanitizers() { :; }\n" + "sidecar_pid=$$\n" + 'ORCHESTRATOR_HOST="127.0.0.1"\n' + 'ORCHESTRATOR_PORT="18080"\n' + f"sidecar_startup_watchdog_seconds={watchdog_seconds}\n" + f'sidecar_stderr="{sidecar_stderr}"\n' + f'preflight_report="{preflight_report}"\n' + + loop_block + + "\n", + encoding="utf-8", + ) + + start_time = time.monotonic() + result = subprocess.run( + ["bash", str(harness)], + env={ + **os.environ, + "PATH": f"{fake_bin}{os.pathsep}{os.environ.get('PATH', '')}", + }, + text=True, + capture_output=True, + check=False, + ) + elapsed = time.monotonic() - start_time + return result, elapsed + + +def test_healthz_wait_loop_fires_near_the_wall_clock_deadline_not_a_poll_count( + tmp_path: Path, +) -> None: + """Regression for Devin Review's "Startup watchdog counts polls, not + seconds" finding (ContextualWisdomLab/.github#1415). + + Before the fix, the loop incremented a plain poll counter ``i`` once per + iteration and compared *that* to ``sidecar_startup_watchdog_seconds`` -- + even though a single iteration's real cost is the curl call's own + duration (up to its ``--max-time``) plus the trailing ``sleep 1``. With a + health probe that itself takes close to its full timeout, that made the + watchdog run roughly 3x longer than its configured bound (255s + configured, ~765s observed worst case). + + This drives the sidecar's real, tracked healthz-wait loop (extracted + verbatim, not a hand-copied duplicate) against a fake ``curl`` that + always fails after a deliberately slow ``curl_delay_seconds``, with a + small configured watchdog. It asserts the loop fails close to the + *configured* wall-clock seconds (allowing headroom for the cadence of + one in-flight curl call plus one ``sleep 1``), and, crucially, well + under 3x that bound -- the exact regression class this test guards + against. + """ + watchdog_seconds = 3 + curl_delay_seconds = 2.0 + + result, elapsed = _run_healthz_wait_loop( + tmp_path, + watchdog_seconds=watchdog_seconds, + curl_delay_seconds=curl_delay_seconds, + ) + + assert result.returncode == 1, result.stderr + assert ( + f"sidecar did not become healthy within {watchdog_seconds}s" in result.stderr + ) + # The old, buggy poll-counting comparison would need + # `watchdog_seconds` full iterations -- each costing + # curl_delay_seconds + 1s of sleep -- before firing: roughly + # watchdog_seconds * (curl_delay_seconds + 1) = 9s here. The fixed, + # real-wall-clock comparison fires as soon as accumulated curl time + # alone crosses the deadline: roughly one extra curl call past the + # bound, ~5s here. Assert comfortably between the two, strictly below + # the poll-counting bound -- proving this is not that regression. + poll_counting_bound = watchdog_seconds * (curl_delay_seconds + 1) + assert elapsed < poll_counting_bound - 1, ( + f"loop took {elapsed:.1f}s to fail, at or beyond the poll-counting " + f"bound of {poll_counting_bound:.1f}s that this fix removes -- the " + "watchdog is counting polls again, not real wall-clock seconds" + ) + # A lower bound too: the loop cannot legitimately fail before at least + # one curl call has run (the deadline is only checked after a curl + # attempt), so it must take at least curl_delay_seconds. + assert elapsed >= curl_delay_seconds + + +def test_healthz_wait_loop_reports_wall_clock_seconds_not_a_poll_count( + tmp_path: Path, +) -> None: + """The failure message's own reported bound must not silently change. + + A narrower companion to the timing test above: even independent of how + long the loop actually took, the fixed loop's fail() message must still + name the *configured* ``sidecar_startup_watchdog_seconds`` -- proving + the message-formatting side of the fix (``$SECONDS`` swapped in for + ``$i`` in both the comparison and the two places it is interpolated) + did not regress independently of the timing behavior. + """ + result, _elapsed = _run_healthz_wait_loop( + tmp_path, watchdog_seconds=2, curl_delay_seconds=1.5 + ) + + assert result.returncode == 1, result.stderr + assert "sidecar did not become healthy within 2s" in result.stderr + + def test_preflight_stage_limits_share_one_startup_budget() -> None: """Free-first and priced-fallback probes share one bounded route budget.""" namespace = _load_launcher() From 9aec73a7ecc4f7ee9ca1609f9370a954784a22f6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 06:40:04 +0000 Subject: [PATCH 32/65] fix: sidecar account-cap default bypass and discovery watchdog undercount Two real bugs Devin's automated review found on this same PR (#1415) against the just-landed _catalog_account_cap(DEFAULT_ACCOUNT_CAP) fix and the discovery-budget arithmetic: 1. contextual_orchestrator_review_sidecar.sh still unconditionally exported ORCHESTRATOR_CATALOG_ACCOUNT_CAP=8 whenever no operator override was set -- a leftover from an earlier CATALOG_FAMILY_CAP=24 -> CATALOG_ACCOUNT_CAP=8 rename that fixed the variable's name but kept the wrong default. Because the shell always exported a concrete 8 first, _catalog_account_cap(DEFAULT_ACCOUNT_CAP)'s own env-unset fallback to 4 could never trigger in production. The shell now derives its default from contextual_orchestrator_review_policy's own DEFAULT_ACCOUNT_CAP at runtime (same python3 -c pattern already used for sidecar_startup_watchdog_seconds), honoring an explicit operator override first. Updated the two contract tests pinning the old literal and added executable coverage of the real shell derivation block (unset/override/empty/malformed cases). ADR-0003 amended to record the correction. 2. REVIEW_DISCOVERY_MAX_SEQUENTIAL_HTTP_CALLS = 7 counted only one single-attempt call per registered source plus one unconditional OpenRouter extra. Re-verified against the vendored contextual_orchestrator/model_discovery.py at the pinned ORCHESTRATOR_PIN_SHA: the shared Models.dev fetch retries up to 3x, each of the five credentialed sources' primary fetch gets a base attempt + one retry (2 each), OpenRouter makes two further single-attempt calls plus a concurrent free-model endpoint-feed round (live-verified: 21 free models today, budgeted 5 rounds of headroom), and discover_all_models() makes two trailing global calls the old count missed entirely. New total: 22 sequential-call- equivalents (3 + 5*2 + 2 + 5 + 2), raising REVIEW_DISCOVERY_WORST_CASE_SECONDS to 330s and REVIEW_STARTUP_WATCHDOG_SECONDS to 480s. Decomposed the constant into named, independently-testable sub-budgets and added a reconstruction test that rebuilds the worst case from the enumerated request structure rather than re-asserting the module's own arithmetic. Verification: coverage run -m pytest tests (2129 passed, 1 skipped, 100% coverage on scripts/ci), interrogate (100% docstrings), bash -n on the touched shell script, git diff --check clean. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01KPmJErfkcHer4UVEgrQxUX --- CHANGELOG.md | 66 +++++ ...ntextual-orchestrator-vendored-free-zdr.md | 12 +- ...contextual_orchestrator_review_launcher.py | 122 ++++++++-- .../contextual_orchestrator_review_sidecar.sh | 60 ++++- ...l_orchestrator_review_runtime_preflight.py | 229 ++++++++++++++++-- ...al_orchestrator_review_sidecar_contract.py | 11 +- 6 files changed, 459 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a303318ff..46e1f23b7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,72 @@ this file. The format follows Keep a Changelog, and versioned releases follow Semantic Versioning where the repository publishes a release. ## [Unreleased] +- Fix two real bugs Devin's automated review found on this same PR + (ContextualWisdomLab/.github#1415) against the just-landed + `_catalog_account_cap(DEFAULT_ACCOUNT_CAP)` fix and the discovery-budget + arithmetic: + 1. **Sidecar shell bypassed the policy account-cap default.** + `contextual_orchestrator_review_sidecar.sh` still unconditionally + exported `ORCHESTRATOR_CATALOG_ACCOUNT_CAP=8` whenever no operator + override was set — a leftover from an earlier round's + `CATALOG_FAMILY_CAP=24` → `CATALOG_ACCOUNT_CAP=8` rename that fixed the + variable's name but kept the wrong default value. Because the shell + always exported a concrete `8` before the Python launcher ever ran, + `_catalog_account_cap(DEFAULT_ACCOUNT_CAP)`'s own env-unset fallback to + `4` (`os.environ.get` only falls back when the key is absent) could + never actually trigger in production: every real run got a cap of 8, + not 4, so two NVIDIA credentials could still jointly occupy up to 16 of + the 24 preflight slots between them instead of the intended 8 (4 each). + Fixed by deriving the shell's default the same way + `sidecar_startup_watchdog_seconds` already derives its own default — + reading `contextual_orchestrator_review_policy.DEFAULT_ACCOUNT_CAP` at + runtime via a `python3 -c` one-liner — instead of hard-coding a numeric + literal; an explicit operator-set `ORCHESTRATOR_CATALOG_ACCOUNT_CAP` + still always wins. Updated the contract tests that pinned the old + literal (`test_contextual_orchestrator_review_sidecar_contract.py`, + `test_contextual_orchestrator_review_runtime_preflight.py`) and added + executable coverage that runs the real shell derivation block (not just + the Python helper in isolation) for the unset, overridden, empty, and + malformed-override cases. `docs/adr/0003-contextual-orchestrator-vendored-free-zdr.md` + amended to record the correction so shell, Python, and ADR text agree + on one number (4). + 2. **Startup watchdog's discovery-time budget undercounted known + retries.** `REVIEW_DISCOVERY_MAX_SEQUENTIAL_HTTP_CALLS = 7` (feeding + `REVIEW_DISCOVERY_WORST_CASE_SECONDS` ≈ 105s and, in turn, + `REVIEW_STARTUP_WATCHDOG_SECONDS` ≈ 255s) counted only one + single-attempt call per registered source plus one unconditional + OpenRouter extra — it never accounted for retries or for calls the + pinned `contextual-orchestrator` revision actually makes beyond that. + Re-verified line-by-line against the vendored + `contextual_orchestrator/model_discovery.py` at the pinned + `ORCHESTRATOR_PIN_SHA`: the shared Models.dev fetch retries up to 3 + times (not 1); each of the sidecar's five credentialed sources' primary + listing fetch gets a base attempt plus one transient-failure retry (2 + each, not 1 — 10 total); OpenRouter alone makes two further + single-attempt calls (ZDR endpoints, provider policies) beyond its own + listing call, plus one concurrent (≤8-worker thread pool) endpoint-feed + round per currently free-priced model (live-verified against + OpenRouter's public catalog on 2026-08-31: 21 free models today, i.e. 3 + rounds; budgeted 5 rounds as documented headroom for catalog growth); + and `discover_all_models()` makes two further trailing global calls + once per run (a second, non-cached ZDR-endpoints fetch, plus the + credits check) that the old count missed entirely. New total: 22 + sequential-call-equivalents (3 + 5×2 + 2 + 5 + 2), raising + `REVIEW_DISCOVERY_WORST_CASE_SECONDS` to 330s and + `REVIEW_STARTUP_WATCHDOG_SECONDS` to 480s. The launcher now exposes + each sub-count as its own named constant + (`REVIEW_DISCOVERY_MODELS_DEV_MAX_ATTEMPTS`, + `REVIEW_DISCOVERY_CREDENTIALED_SOURCE_COUNT`, + `REVIEW_DISCOVERY_SOURCE_MAX_ATTEMPTS`, + `REVIEW_DISCOVERY_OPENROUTER_SINGLE_EXTRA_CALLS`, + `REVIEW_DISCOVERY_OPENROUTER_FREE_ENDPOINT_ROUND_CAP`, + `REVIEW_DISCOVERY_TRAILING_GLOBAL_CALLS`) rather than one opaque + literal, so a new + `test_startup_watchdog_covers_a_retry_heavy_discovery_reconstruction` + test can independently reconstruct the worst case from the enumerated + real request structure — not merely re-assert the module's own + arithmetic on its own constants, which would just re-encode the same + kind of undercounted assumption this fix corrects. - Fix a real, live-evidenced bug in the sidecar's per-account catalog cap (flagged in review on this same PR, ContextualWisdomLab/.github#1415#issuecomment-5474321491): the diff --git a/docs/adr/0003-contextual-orchestrator-vendored-free-zdr.md b/docs/adr/0003-contextual-orchestrator-vendored-free-zdr.md index 9b64dc45bb..54fa2c99c8 100644 --- a/docs/adr/0003-contextual-orchestrator-vendored-free-zdr.md +++ b/docs/adr/0003-contextual-orchestrator-vendored-free-zdr.md @@ -86,7 +86,17 @@ all five, and auto-optimize routing by cost. — it silently disabled per-account diversification and let two rate-limited NVIDIA NIM credentials sharing one upstream jointly occupy an entire 12-slot preflight batch. `ORCHESTRATOR_CATALOG_ACCOUNT_CAP` remains - an explicit operator override. + an explicit operator override. (2026-08-31 correction: that "defaults to + 4" claim was true of the Python launcher's own fallback but not, until + this date, of the shell sidecar — `contextual_orchestrator_review_sidecar.sh` + still unconditionally exported a leftover literal `8` default whenever no + operator override was set, which meant the launcher's own env-unset + fallback branch could never actually run in production and every real run + got a cap of 8, not 4. The shell now derives its default the same way the + startup watchdog seconds below are derived: by reading + `contextual_orchestrator_review_policy.DEFAULT_ACCOUNT_CAP` at runtime + instead of hard-coding a numeric literal, so shell, Python, and this ADR + describe one real number.) 4. **Wiring**: `pr-review-autofix.yml` and the Required OpenCode dispatch provision the sidecar with the five secrets before OpenCode runs and point every model/diagnosis candidate at `contextual-orchestrator/orchestrator/free`; diff --git a/scripts/ci/contextual_orchestrator_review_launcher.py b/scripts/ci/contextual_orchestrator_review_launcher.py index 4e1bce55e9..19a384893b 100644 --- a/scripts/ci/contextual_orchestrator_review_launcher.py +++ b/scripts/ci/contextual_orchestrator_review_launcher.py @@ -105,18 +105,16 @@ # alone -- previously the watchdog was a bare, uncoordinated 180s shell # constant that only happened to exceed the probing-only figure above by # coincidence, while the combined real worst case (see -# REVIEW_STARTUP_WATCHDOG_SECONDS below) is larger than that. Verified -# directly against the vendored contextual-orchestrator source at -# ORCHESTRATOR_PIN_SHA (contextual_orchestrator_review_sidecar.sh): -# discover_all_models() makes up to REVIEW_DISCOVERY_MAX_SEQUENTIAL_HTTP_CALLS -# sequential HTTP calls (the shared models.dev fetch; one per -# PROVIDER_MODEL_SOURCES entry with a registered credential -- of the sidecar's -# five bootstrapped secrets, that is openai/openrouter/nvidia_nim/ -# nvidia_nim_sub/bytez, since opencode_zen's OPENCODE_ZEN_API_KEY is never one -# of the five secrets the sidecar registers and so it always short-circuits -# with zero calls; and the OpenRouter ZDR endpoint fetch, unconditional), each -# up to REVIEW_DISCOVERY_TIMEOUT_SECONDS. contextual_orchestrator_review_sidecar.sh -# imports REVIEW_STARTUP_WATCHDOG_SECONDS from this module (a stdlib-only, +# REVIEW_STARTUP_WATCHDOG_SECONDS below) is larger than that. discover_all_models() +# makes up to REVIEW_DISCOVERY_MAX_SEQUENTIAL_HTTP_CALLS sequential-call- +# equivalents against the pinned contextual-orchestrator revision, each up to +# REVIEW_DISCOVERY_TIMEOUT_SECONDS -- see that constant's own comment below +# for the full, itemized enumeration (shared Models.dev fetch, per-source +# retries, OpenRouter's extra calls, and two trailing global calls) verified +# directly against ORCHESTRATOR_PIN_SHA; do not restate the count here, to +# avoid a second "verified" claim silently drifting from the real one below. +# contextual_orchestrator_review_sidecar.sh imports REVIEW_STARTUP_WATCHDOG_SECONDS +# from this module (a stdlib-only, # dependency-free import) as its watchdog loop bound -- a single source of # truth so a future change to either phase's constants cannot silently # desynchronize the two budgets again. #1454 (a base-probe *success* never @@ -179,18 +177,94 @@ # (this module's top-level imports are deliberately stdlib-only). Re-verify # this mirror whenever ORCHESTRATOR_PIN_SHA moves. REVIEW_DISCOVERY_TIMEOUT_SECONDS = 15.0 -# Verified against the vendored contextual_orchestrator.model_discovery source -# at ORCHESTRATOR_PIN_SHA: discover_all_models() calls, strictly sequentially, -# one shared models.dev fetch (triggered once any source with a registered -# credential declares models_dev_provider_id), then discover_provider_models() -# once per PROVIDER_MODEL_SOURCES entry with a registered credential (skipped -# instantly, no HTTP call, for an entry with none), then one unconditional -# OpenRouter ZDR endpoints fetch. With every one of the sidecar's five -# bootstrapped secrets present (openai, openrouter, nvidia_nim, nvidia_nim_sub, -# bytez -- opencode_zen is never among them), that is 1 (models.dev) + 5 -# (providers) + 1 (ZDR) = 7 sequential calls, each independently bounded by -# REVIEW_DISCOVERY_TIMEOUT_SECONDS. -REVIEW_DISCOVERY_MAX_SEQUENTIAL_HTTP_CALLS = 7 +# FIXED (ContextualWisdomLab/.github#1415, Devin Review finding "Discovery- +# time budget undercounts known retries"): the previous count of 7 verified +# only that discover_all_models() makes one call per registered source plus +# one unconditional extra -- it never checked whether any of those calls can +# themselves retry, or whether the pinned revision makes calls beyond that +# simple per-source loop. Re-verified line-by-line against the vendored +# contextual_orchestrator.model_discovery source at ORCHESTRATOR_PIN_SHA +# (fetched and read at that exact commit, not assumed from an older or newer +# revision), counting every sequential HTTP call discover_all_models() can +# make in its real worst case, with every one of the sidecar's five +# bootstrapped credentials present (openai, openrouter, nvidia_nim, +# nvidia_nim_sub, bytez -- opencode_zen's OPENCODE_ZEN_API_KEY is never one of +# the five secrets the sidecar registers, so it always short-circuits with +# zero calls): +# +# Named sub-budgets below (rather than one opaque literal) so a test can +# reconstruct and re-justify each piece of this enumeration independently -- +# see test_contextual_orchestrator_review_runtime_preflight.py's +# test_startup_watchdog_covers_a_retry_heavy_discovery_reconstruction. +# +# (a) Shared Models.dev fetch (_fetch_models_dev_metadata, triggered once +# because openai/nvidia_nim/nvidia_nim_sub declare +# models_dev_provider_id and are credentialed): up to +# _MODELS_DEV_FETCH_ATTEMPTS = 3 sequential attempts, not the 1 the old +# count assumed -- a lone transient failure (this endpoint is known to +# reject urllib's default user agent, see that constant's own +# docstring) is retried up to twice more. +REVIEW_DISCOVERY_MODELS_DEV_MAX_ATTEMPTS = 3 +# (b) Each of the five credentialed sources' own primary model-list fetch +# (discover_provider_models): up to 2 attempts each -- a full +# REVIEW_DISCOVERY_TIMEOUT_SECONDS primary attempt PLUS one +# _DISCOVERY_RETRY_TIMEOUT_SECONDS=5.0s retry on a transient failure +# (is_transient_error), not the unretried single attempt the old count +# assumed. Five sources: openai, openrouter, nvidia_nim, +# nvidia_nim_sub, bytez -- opencode_zen's OPENCODE_ZEN_API_KEY is never +# one of the five secrets the sidecar registers, so it always short- +# circuits with zero calls and is excluded from this count. +REVIEW_DISCOVERY_CREDENTIALED_SOURCE_COUNT = 5 +REVIEW_DISCOVERY_SOURCE_MAX_ATTEMPTS = 2 +# (c) OpenRouter-only extra calls inside discover_provider_models, beyond +# its own primary listing call already counted in (b): one ZDR- +# endpoints fetch (_OPENROUTER_ZDR_ENDPOINTS_URL, no retry -- the old +# count's "unconditional ZDR fetch" line item, kept here) + one +# provider-policies fetch (_OPENROUTER_PROVIDER_POLICIES_URL, no +# retry, entirely missing from the old count). +REVIEW_DISCOVERY_OPENROUTER_SINGLE_EXTRA_CALLS = 2 +# Plus one concurrent (ThreadPoolExecutor, <=8 workers) endpoint-feed +# fetch per currently zero-priced OpenRouter model +# (_openrouter_free_model_endpoints, also entirely missing from the +# old count): wall-clock bounded by ceil(free_model_count / 8) rounds, +# each up to REVIEW_DISCOVERY_TIMEOUT_SECONDS. Verified live against +# OpenRouter's public /api/v1/models catalog (2026-08-31): 21 models +# currently report zero prompt AND completion price (ceil(21/8) = 3 +# rounds today). The pinned code itself does not bound this count, so +# rather than hand-waving it as "1 more call" (the old count's mistake +# for a different item) or leaving it fully unbounded, this budgets 5 +# call-equivalent rounds -- headroom for up to 40 free models, close +# to double today's observed count -- as an explicit, documented +# assumption, not a code-enforced bound; re-verify this headroom if +# OpenRouter's free-tier catalog grows materially past that. +REVIEW_DISCOVERY_OPENROUTER_FREE_ENDPOINT_ROUND_CAP = 5 +# (d) Two trailing global calls discover_all_models() itself makes once +# per run, strictly after every source's loop above, entirely absent +# from the old count: _openrouter_zdr_model_ids() (a SEPARATE fetch of +# the same _OPENROUTER_ZDR_ENDPOINTS_URL as (c) -- not a cache hit; +# this one runs unconditionally, even with no OpenRouter credential +# registered) + openrouter_paid_inference_available() (the credits +# check, gated on an OpenRouter credential being registered, true in +# this worst case). Neither has a retry. +REVIEW_DISCOVERY_TRAILING_GLOBAL_CALLS = 2 +# FIXED (ContextualWisdomLab/.github#1415, Devin Review finding "Discovery- +# time budget undercounts known retries"): the previous count of 7 verified +# only that discover_all_models() makes one call per registered source plus +# one unconditional extra -- it never checked whether any of those calls can +# themselves retry, or whether the pinned revision makes calls beyond that +# simple per-source loop. Re-verified line-by-line against the vendored +# contextual_orchestrator.model_discovery source at ORCHESTRATOR_PIN_SHA +# (fetched and read at that exact commit, not assumed from an older or newer +# revision) -- see (a)-(d) above for the full itemized enumeration. Total: +# 3 + 5*2 + 2 + 5 + 2 = 22 sequential-call-equivalents, each independently +# bounded by REVIEW_DISCOVERY_TIMEOUT_SECONDS. +REVIEW_DISCOVERY_MAX_SEQUENTIAL_HTTP_CALLS = ( + REVIEW_DISCOVERY_MODELS_DEV_MAX_ATTEMPTS + + REVIEW_DISCOVERY_CREDENTIALED_SOURCE_COUNT * REVIEW_DISCOVERY_SOURCE_MAX_ATTEMPTS + + REVIEW_DISCOVERY_OPENROUTER_SINGLE_EXTRA_CALLS + + REVIEW_DISCOVERY_OPENROUTER_FREE_ENDPOINT_ROUND_CAP + + REVIEW_DISCOVERY_TRAILING_GLOBAL_CALLS +) REVIEW_DISCOVERY_WORST_CASE_SECONDS = ( REVIEW_DISCOVERY_MAX_SEQUENTIAL_HTTP_CALLS * REVIEW_DISCOVERY_TIMEOUT_SECONDS ) diff --git a/scripts/ci/contextual_orchestrator_review_sidecar.sh b/scripts/ci/contextual_orchestrator_review_sidecar.sh index 068e7aad84..b16e679778 100755 --- a/scripts/ci/contextual_orchestrator_review_sidecar.sh +++ b/scripts/ci/contextual_orchestrator_review_sidecar.sh @@ -77,8 +77,21 @@ SIDECAR_DISCOVERY_DIAGNOSTICS_SENTINEL="discovery_diagnostics_complete" # leave a raw pre-merge commit unreachable in plain git once the branch is # deleted, while the PR itself (and its full commit history) stays # permanently resolvable on GitHub. +# +# FIXED (ContextualWisdomLab/.github#1415, Devin Review follow-up): "raised +# from 4 to 8" above was itself never reverted when the 24 mistake was fixed +# -- this shell kept unconditionally exporting a literal 8 default, so the +# real, currently-intended default (4, matching +# contextual_orchestrator_review_policy.DEFAULT_ACCOUNT_CAP) never actually +# took effect in production. CATALOG_ACCOUNT_CAP is no longer set here as a +# shell literal; see its derivation further below, right before its export, +# for the single-source-of-truth fix and why it must run after +# $sidecar_python/$ORG_REPO_ROOT/fail() are defined. CATALOG_LIMIT="${ORCHESTRATOR_CATALOG_LIMIT:-24}" -CATALOG_ACCOUNT_CAP="${ORCHESTRATOR_CATALOG_ACCOUNT_CAP:-8}" +# CATALOG_ACCOUNT_CAP itself is derived further below (single source of truth: +# contextual_orchestrator_review_policy.py's own DEFAULT_ACCOUNT_CAP), once +# $sidecar_python, $ORG_REPO_ROOT, and fail() are all available -- see that +# derivation's own comment for the incident this replaces. ORCHESTRATOR_GITHUB_ENV="${GITHUB_ENV:-}" sidecar_python="$(command -v python3)" @@ -347,6 +360,51 @@ case "$sidecar_startup_watchdog_seconds" in esac log "startup watchdog: ${sidecar_startup_watchdog_seconds}s (derived from contextual_orchestrator_review_launcher.py's REVIEW_STARTUP_WATCHDOG_SECONDS)" +# Single source of truth for the per-account catalog cap default, same +# derive-from-Python pattern as the startup watchdog just above: read +# contextual_orchestrator_review_policy.py's own DEFAULT_ACCOUNT_CAP instead +# of hard-coding a numeric default in this shell script. +# +# FIXED (ContextualWisdomLab/.github#1415, Devin Review follow-up on the +# just-landed contextual_orchestrator_review_launcher.py fix that added +# _catalog_account_cap(DEFAULT_ACCOUNT_CAP)): this shell used to +# unconditionally materialize and export a concrete +# ORCHESTRATOR_CATALOG_ACCOUNT_CAP=8 whenever no operator override was set -- +# a leftover from an earlier round's CATALOG_FAMILY_CAP=24 -> +# CATALOG_ACCOUNT_CAP=8 rename that fixed the variable's NAME but kept the +# WRONG default value. Because the shell always exported a concrete value of +# 8 before the Python launcher ever ran, _catalog_account_cap's own +# fallback-to-DEFAULT_ACCOUNT_CAP branch (os.environ.get(..., str(default))) +# could never actually trigger in production: os.environ.get only falls back +# to its default when the key is ABSENT, and this shell always set it. Every +# real run therefore got a cap of 8, not the policy's intended 4, so two +# NVIDIA credentials could still jointly occupy up to 16 of the 24 preflight +# slots between them (4 * 2 = 8 was the actual bound intended) rather than the +# intended 8 (4 each) -- half the diversification the just-landed fix was +# supposed to restore. An explicit operator-set ORCHESTRATOR_CATALOG_ACCOUNT_CAP +# env var still always wins over this derived default. +if [ -n "${ORCHESTRATOR_CATALOG_ACCOUNT_CAP:-}" ]; then + CATALOG_ACCOUNT_CAP="$ORCHESTRATOR_CATALOG_ACCOUNT_CAP" +else + CATALOG_ACCOUNT_CAP="$( + PYTHONPATH="$ORG_REPO_ROOT" "$sidecar_python" -c \ + 'from scripts.ci.contextual_orchestrator_review_policy import DEFAULT_ACCOUNT_CAP; print(DEFAULT_ACCOUNT_CAP)' + )" || fail "could not derive the default catalog account cap from the policy module" +fi +# Same digit-count defense as REVIEW_PREFLIGHT_GATEWAY_MAX_ATTEMPTS and +# REVIEW_STARTUP_WATCHDOG_SECONDS above: a non-numeric value would make the +# downstream `export`+Python `int(...)` parse fail deep inside the launcher +# instead of this script rejecting bad configuration up front, and an +# all-digit value can still overflow shell/Python integer expectations. Four +# digits (up to 9999) is already far beyond any realistic per-account cap. +case "$CATALOG_ACCOUNT_CAP" in + ''|*[!0-9]*|0) + fail "ORCHESTRATOR_CATALOG_ACCOUNT_CAP must be a positive integer, got: ${CATALOG_ACCOUNT_CAP}" ;; + ?????*) + fail "ORCHESTRATOR_CATALOG_ACCOUNT_CAP must be at most 9999" ;; +esac +log "catalog account cap: ${CATALOG_ACCOUNT_CAP} (operator override, or contextual_orchestrator_review_policy.py's DEFAULT_ACCOUNT_CAP when unset)" + log "starting review sidecar on ${ORCHESTRATOR_HOST}:${ORCHESTRATOR_PORT}" cp "$ORCHESTRATOR_LAUNCHER" "$ORCHESTRATOR_WORK/launch_sidecar.py" export ORCHESTRATOR_CATALOG_LIMIT="$CATALOG_LIMIT" diff --git a/tests/test_contextual_orchestrator_review_runtime_preflight.py b/tests/test_contextual_orchestrator_review_runtime_preflight.py index ed33a51c8e..ce62822308 100644 --- a/tests/test_contextual_orchestrator_review_runtime_preflight.py +++ b/tests/test_contextual_orchestrator_review_runtime_preflight.py @@ -1809,9 +1809,10 @@ def test_startup_watchdog_covers_discovery_plus_preflight_with_headroom() -> Non 180s shell constant that only happened to exceed the *probing-only* worst case (120s) by coincidence, while never accounting for discovery's own worst case (which runs first, in the SAME process, before ``/healthz`` can - respond) at all -- a fully correct, on-budget run of ~105s discovery + - ~120s probing = ~225s could be, and was, killed by the 180s watchdog - before it ever reported a result. + respond) at all -- a fully correct, on-budget run of ~330s discovery + + ~120s probing = ~450s could be, and was (at the smaller, undercounted + 105s discovery figure this test used to pin), killed by too small a + watchdog before it ever reported a result. This is a purely static consistency check (no timing simulation, no real sleeps -- CI-safe and non-flaky) that recomputes both worst cases @@ -1819,11 +1820,15 @@ def test_startup_watchdog_covers_discovery_plus_preflight_with_headroom() -> Non ``REVIEW_STARTUP_WATCHDOG_SECONDS`` -- the single source of truth the shell sidecar now imports rather than hard-coding its own number -- actually covers their sum, with non-negative explicit headroom. It also - locks in the real, literal current numbers (not Devin's original rough - ~105s/~100s estimate) as a regression: any future change to a budget - constant that silently desynchronizes the derived watchdog fails this - test immediately, rather than only failing much later in a live CI run - that happens to hit the worst case. + locks in the real, literal current numbers as a regression: any future + change to a budget constant that silently desynchronizes the derived + watchdog fails this test immediately, rather than only failing much later + in a live CI run that happens to hit the worst case. See + ``test_startup_watchdog_covers_a_retry_heavy_discovery_reconstruction`` + below for the companion test that independently reconstructs the 330s + discovery figure from the real, enumerated request structure rather than + trusting this module's own arithmetic -- exactly what Devin Review's + follow-up finding says a verbatim-constant test alone cannot catch. """ namespace = _load_launcher() @@ -1833,9 +1838,9 @@ def test_startup_watchdog_covers_discovery_plus_preflight_with_headroom() -> Non assert recomputed_discovery_worst_case == namespace["REVIEW_DISCOVERY_WORST_CASE_SECONDS"] # Verified directly against the vendored contextual_orchestrator.model_discovery # source at ORCHESTRATOR_PIN_SHA (see the launcher's own module-level - # comment for the full call-by-call derivation): 7 sequential calls at up - # to 15.0s each. - assert recomputed_discovery_worst_case == 105.0 + # comment for the full call-by-call derivation): 22 sequential-call- + # equivalents at up to 15.0s each. + assert recomputed_discovery_worst_case == 330.0 total_routes = namespace["REVIEW_PREFLIGHT_MAX_TOTAL_ROUTES"] batch_size = namespace["REVIEW_PREFLIGHT_BATCH_SIZE"] @@ -1855,10 +1860,91 @@ def test_startup_watchdog_covers_discovery_plus_preflight_with_headroom() -> Non # The core invariant Devin Review's finding is about: the watchdog must # cover the full combined worst case, not just one phase of it. assert watchdog >= combined_worst_case - # Locks in the real current total (225s combined + 30s headroom), not a + # Locks in the real current total (450s combined + 30s headroom), not a # loosely-fitting range, so a future change to any input constant is a # deliberate, visible edit to this test rather than a silent drift. - assert watchdog == 255 + assert watchdog == 480 + + +def test_startup_watchdog_covers_a_retry_heavy_discovery_reconstruction() -> None: + """Independently rebuild the worst-case call count from the real request + structure and assert the derived watchdog still covers its time budget. + + Regression for Devin Review's exact follow-up finding on the discovery + budget ("Recompute the startup watchdog from the actual bounded request + structure ... extend tests with retry-heavy discovery timing rather than + asserting the current constant verbatim"): a test that only pins + ``REVIEW_DISCOVERY_MAX_SEQUENTIAL_HTTP_CALLS == 22`` (as + ``test_startup_watchdog_covers_discovery_plus_preflight_with_headroom`` + above does) would pass just as happily if that constant were still wrong + in the same direction the original ``7`` was -- it re-encodes whatever + the module currently claims, it does not check the claim against the + real, enumerated request structure. This test instead reconstructs the + worst case from first principles (each sub-count independently justified + against the vendored ``contextual_orchestrator.model_discovery`` source + at ``ORCHESTRATOR_PIN_SHA`` in the launcher module's own comment) and + proves the *reconstructed* time budget -- not just the module's own + arithmetic on its own constants -- is what the watchdog actually covers. + """ + namespace = _load_launcher() + discovery_timeout = namespace["REVIEW_DISCOVERY_TIMEOUT_SECONDS"] + + # (a) The shared Models.dev fetch retries transient failures up to + # _MODELS_DEV_FETCH_ATTEMPTS=3 times in the pinned source -- a retry- + # heavy scenario is exactly a run where every one of those attempts is a + # transient failure (timeout/connection reset) before the caller finally + # gives up and returns None (still a valid, non-raising outcome). + models_dev_attempts = 3 + assert models_dev_attempts == namespace["REVIEW_DISCOVERY_MODELS_DEV_MAX_ATTEMPTS"] + + # (b) Every one of the sidecar's five bootstrapped credentials + # (openai, openrouter, nvidia_nim, nvidia_nim_sub, bytez) gets its own + # primary-fetch attempt plus one retry attempt in a retry-heavy run. + credentialed_sources = ("openai", "openrouter", "nvidia_nim", "nvidia_nim_sub", "bytez") + attempts_per_source = 2 # base attempt + one transient-failure retry + assert len(credentialed_sources) == namespace["REVIEW_DISCOVERY_CREDENTIALED_SOURCE_COUNT"] + assert attempts_per_source == namespace["REVIEW_DISCOVERY_SOURCE_MAX_ATTEMPTS"] + + # (c) OpenRouter alone makes two further single-attempt calls (ZDR + # endpoints, provider policies) beyond its own primary fetch already + # counted in (b), plus one concurrent endpoint-feed round per <=8 + # currently free-priced models. A retry-heavy scenario does not add + # retries to these three (none of them retry in the pinned source), but + # it does mean discovery cannot skip them by finishing early. + openrouter_single_extra_calls = 2 + free_endpoint_round_cap = 5 + assert openrouter_single_extra_calls == namespace[ + "REVIEW_DISCOVERY_OPENROUTER_SINGLE_EXTRA_CALLS" + ] + assert free_endpoint_round_cap == namespace[ + "REVIEW_DISCOVERY_OPENROUTER_FREE_ENDPOINT_ROUND_CAP" + ] + + # (d) Two trailing global calls run once, after every source above, with + # an OpenRouter credential registered: the (separate, non-cached) + # _openrouter_zdr_model_ids() fetch and the credits check. + trailing_global_calls = 2 + assert trailing_global_calls == namespace["REVIEW_DISCOVERY_TRAILING_GLOBAL_CALLS"] + + reconstructed_call_count = ( + models_dev_attempts + + len(credentialed_sources) * attempts_per_source + + openrouter_single_extra_calls + + free_endpoint_round_cap + + trailing_global_calls + ) + assert reconstructed_call_count == 22 + assert reconstructed_call_count == namespace["REVIEW_DISCOVERY_MAX_SEQUENTIAL_HTTP_CALLS"] + + reconstructed_discovery_seconds = reconstructed_call_count * discovery_timeout + reconstructed_combined_seconds = ( + reconstructed_discovery_seconds + namespace["REVIEW_PREFLIGHT_WORST_CASE_SECONDS"] + ) + watchdog = namespace["REVIEW_STARTUP_WATCHDOG_SECONDS"] + # The core assertion: the derived watchdog must cover a genuinely + # independently-reconstructed retry-heavy worst case, not merely the + # module's own (possibly still wrong) restatement of it. + assert watchdog >= reconstructed_combined_seconds def test_sidecar_derives_its_watchdog_from_the_launcher_single_source_of_truth() -> None: @@ -2089,7 +2175,18 @@ def test_production_defaults_expose_the_complete_bounded_catalog() -> None: assert 'ORCHESTRATOR_CATALOG_LIMIT", "24"' in launcher assert 'CATALOG_LIMIT="${ORCHESTRATOR_CATALOG_LIMIT:-24}"' in sidecar assert "REVIEW_PREFLIGHT_MAX_TOTAL_ROUTES = 24" in launcher - assert 'CATALOG_ACCOUNT_CAP="${ORCHESTRATOR_CATALOG_ACCOUNT_CAP:-8}"' in sidecar + # Regression for ContextualWisdomLab/.github#1415's Devin follow-up + # finding: the shell used to always export a hard-coded literal `8` + # default for ORCHESTRATOR_CATALOG_ACCOUNT_CAP, which bypassed the + # launcher's own _catalog_account_cap(DEFAULT_ACCOUNT_CAP)=4 fallback in + # every real run (os.environ.get only falls back when the key is + # ABSENT). The shell must no longer materialize that literal and must + # instead derive the same policy.DEFAULT_ACCOUNT_CAP the launcher does. + assert 'CATALOG_ACCOUNT_CAP="${ORCHESTRATOR_CATALOG_ACCOUNT_CAP:-8}"' not in sidecar + assert ( + "from scripts.ci.contextual_orchestrator_review_policy import " + "DEFAULT_ACCOUNT_CAP; print(DEFAULT_ACCOUNT_CAP)" + ) in sidecar def test_catalog_account_cap_defaults_to_the_caller_supplied_policy_default( @@ -2162,6 +2259,110 @@ def test_catalog_account_cap_honors_an_explicit_override( assert namespace["_catalog_account_cap"](policy.DEFAULT_ACCOUNT_CAP) == 6 +_CATALOG_ACCOUNT_CAP_BLOCK_START = ( + 'if [ -n "${ORCHESTRATOR_CATALOG_ACCOUNT_CAP:-}" ]; then' +) + + +def _run_catalog_account_cap_derivation( + *, override: str | None +) -> subprocess.CompletedProcess[str]: + """Execute the sidecar's real per-account-cap derivation block in bash. + + Extracts the exact, current source of the derivation from the tracked + sidecar script (the same technique + ``test_sidecar_derives_its_watchdog_from_the_launcher_single_source_of_truth`` + and ``_run_healthz_wait_loop`` use above) so a future edit to the block + is automatically exercised here instead of silently drifting from a + second, hand-copied duplicate. Regression for + ContextualWisdomLab/.github#1415's Devin follow-up finding: proves the + shell itself -- not just the Python ``_catalog_account_cap`` helper in + isolation -- resolves to ``policy.DEFAULT_ACCOUNT_CAP`` when no operator + override is set, and to the override's exact value when one is. + + Args: + override: Value to set ``ORCHESTRATOR_CATALOG_ACCOUNT_CAP`` to before + running the block, or ``None`` to leave it genuinely unset. + + Returns: + The completed harness process; ``stdout`` carries ``RESULT=`` + on success. + """ + sidecar_text = _SIDECAR.read_text(encoding="utf-8") + start = sidecar_text.index(_CATALOG_ACCOUNT_CAP_BLOCK_START) + end = sidecar_text.index("esac\n", start) + len("esac\n") + block = sidecar_text[start:end] + assert "CATALOG_ACCOUNT_CAP" in block + + harness = ( + "set -euo pipefail\n" + "log() { printf '[test-sidecar] %s\\n' \"$*\"; }\n" + 'fail() { log "error: $*" >&2; exit 1; }\n' + f'ORG_REPO_ROOT="{_REPO_ROOT}"\n' + f'sidecar_python="{sys.executable}"\n' + + block + + '\nprintf "RESULT=%s\\n" "$CATALOG_ACCOUNT_CAP"\n' + ) + env = dict(os.environ) + env["PYTHONPATH"] = str(_REPO_ROOT) + if override is None: + env.pop("ORCHESTRATOR_CATALOG_ACCOUNT_CAP", None) + else: + env["ORCHESTRATOR_CATALOG_ACCOUNT_CAP"] = override + return subprocess.run( + ["bash", "-c", harness], + env=env, + capture_output=True, + text=True, + check=False, + ) + + +def test_sidecar_shell_derives_the_account_cap_default_from_policy_when_unset() -> None: + """With no operator override, the SHELL (not just the Python helper) gets 4. + + This is the exact regression the just-landed + ``_catalog_account_cap(DEFAULT_ACCOUNT_CAP)`` fix could not close on its + own: that helper's env-unset fallback only runs if the shell genuinely + never set the env var. Before this fix the shell always exported a + literal ``8`` first, so this end-to-end path -- not the Python unit + tested above -- is what previously stayed silently broken in production. + """ + result = _run_catalog_account_cap_derivation(override=None) + assert result.returncode == 0, result.stderr + assert f"RESULT={policy.DEFAULT_ACCOUNT_CAP}" in result.stdout + + +def test_sidecar_shell_honors_an_explicit_account_cap_override() -> None: + """An operator-set ``ORCHESTRATOR_CATALOG_ACCOUNT_CAP`` still wins in the shell.""" + result = _run_catalog_account_cap_derivation(override="6") + assert result.returncode == 0, result.stderr + assert "RESULT=6" in result.stdout + + +@pytest.mark.parametrize("bad_value", ["0", "-1", "abc"]) +def test_sidecar_shell_rejects_an_invalid_account_cap_override(bad_value: str) -> None: + """A malformed override must fail closed, matching the file's other digit checks.""" + result = _run_catalog_account_cap_derivation(override=bad_value) + assert result.returncode == 1 + assert "ORCHESTRATOR_CATALOG_ACCOUNT_CAP must be a positive integer" in result.stderr + + +def test_sidecar_shell_treats_an_empty_override_as_unset() -> None: + """``ORCHESTRATOR_CATALOG_ACCOUNT_CAP=""`` matches bash's own ``:-`` semantics. + + An explicitly empty override is indistinguishable from unset under the + ``${VAR:-default}`` expansion this block (and the rest of this script) + already relies on elsewhere -- e.g. the provider-secret presence loop's + ``[ -n "${!secret_name:-}" ]`` -- so it must fall back to the derived + policy default rather than reaching the digit-format check with an empty + string. + """ + result = _run_catalog_account_cap_derivation(override="") + assert result.returncode == 0, result.stderr + assert f"RESULT={policy.DEFAULT_ACCOUNT_CAP}" in result.stdout + + def test_main_sources_the_account_cap_default_from_policy_not_a_magic_number() -> None: """``main()`` must wire the cap default from ``policy.DEFAULT_ACCOUNT_CAP``. diff --git a/tests/test_contextual_orchestrator_review_sidecar_contract.py b/tests/test_contextual_orchestrator_review_sidecar_contract.py index 5331d1b246..ae3a3216ba 100644 --- a/tests/test_contextual_orchestrator_review_sidecar_contract.py +++ b/tests/test_contextual_orchestrator_review_sidecar_contract.py @@ -78,7 +78,16 @@ def test_sidecar_and_adr_pin_the_bounded_preflight_contract() -> None: adr = _read(SIDECAR_ADR) assert 'CATALOG_LIMIT="${ORCHESTRATOR_CATALOG_LIMIT:-24}"' in sidecar - assert 'CATALOG_ACCOUNT_CAP="${ORCHESTRATOR_CATALOG_ACCOUNT_CAP:-8}"' in sidecar + # The per-account cap default is no longer a shell literal (that was the + # ContextualWisdomLab/.github#1415 Devin follow-up bug: a hard-coded `8` + # here silently bypassed the launcher's own DEFAULT_ACCOUNT_CAP=4 + # fallback in every real run). It must now be derived at runtime from + # the same single source of truth the launcher uses. + assert 'CATALOG_ACCOUNT_CAP="${ORCHESTRATOR_CATALOG_ACCOUNT_CAP:-8}"' not in sidecar + assert ( + "from scripts.ci.contextual_orchestrator_review_policy import " + "DEFAULT_ACCOUNT_CAP; print(DEFAULT_ACCOUNT_CAP)" + ) in sidecar assert "REVIEW_PREFLIGHT_MAX_TOTAL_ROUTES = 24" in launcher assert "REVIEW_PREFLIGHT_BATCH_SIZE = 4" in launcher assert "at most 24" in adr From 92319062be52379a8170b2ff2759ef3e8c98fc32 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 10:33:06 +0000 Subject: [PATCH 33/65] fix(test): sidecar direct-route test must not trip substantive-verdict validation Merging origin/main brought in #1497/#1504's validate_substantive_verdict, which now requires parseable changed-line evidence and adversarial-probe data for any formal (non-comment) verdict. This branch's own test_call_llm_selects_direct_route_for_the_process_local_sidecar used a placeholder "diff" string with a fake "approve" verdict -- exactly the shape main's own test_call_llm_rejects_generic_approve_without_changed_line_evidence now asserts must raise. This test's actual subject is the sidecar direct-route orchestration mode (asserted via seen["body"]["orchestration"]), not verdict-schema validation, so switch the fake verdict's decision to "comment", which short-circuits that unrelated validation entirely. Full suite: 2147 passed, 1 skipped, 21 subtests passed. 100% coverage on scripts/ci/. git diff --check clean. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01KPmJErfkcHer4UVEgrQxUX --- tests/test_noema_review_gate.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/test_noema_review_gate.py b/tests/test_noema_review_gate.py index 89a0e5f91a..69bc6c709b 100644 --- a/tests/test_noema_review_gate.py +++ b/tests/test_noema_review_gate.py @@ -396,14 +396,22 @@ def test_call_llm_selects_direct_route_for_the_process_local_sidecar(monkeypatch def fake_urlopen(request, timeout): seen["body"] = json.loads(request.data.decode("utf-8")) - return FakeResponse({"choices": [{"message": {"content": '{"decision":"approve","summary":"ok","findings":[]}'}}]}) + return FakeResponse({"choices": [{"message": {"content": '{"decision":"comment","summary":"ok","findings":[]}'}}]}) class FakeOpener: def open(self, request, timeout=None): return fake_urlopen(request, timeout) monkeypatch.setattr(noema.urllib.request, "build_opener", lambda *args: FakeOpener()) - assert noema.call_llm("owner/repo", 1, pr, "diff", False)["decision"] == "approve" + # decision="comment" short-circuits validate_substantive_verdict's + # changed-line/adversarial-evidence requirements (see + # test_call_llm_rejects_generic_approve_without_changed_line_evidence + # below, which expects the placeholder "diff" fixture used here to + # raise for a formal approve/request_changes decision) -- this test's + # actual subject is the sidecar direct-route orchestration mode, not + # verdict-schema validation, so a real "approve"/"request_changes" + # verdict satisfying that unrelated validation is deliberately avoided. + assert noema.call_llm("owner/repo", 1, pr, "diff", False)["decision"] == "comment" assert seen["body"]["orchestration"] == "route" From 7e07a4e94e6eeda13af7133c0930d5664039ab77 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 12:20:10 +0000 Subject: [PATCH 34/65] fix(noema): eliminate serving-orchestrator retry/judge doubling, fix timeout mismatch Root-caused contextual-orchestrator#946's four consecutive noema-review TimeoutError failures (worst-case enumerated in contextual-orchestrator#974, which found the mismatch but was blocked from making this companion change here due to a permission denial on that session). Two real bugs in the sidecar's serving orchestrator construction (scripts/ci/contextual_orchestrator_review_launcher.py), neither present in the deliberately zero-retry preflight client: 1. TaskOrchestrator's default tool_retry_attempts=1 makes _invoke retry the SAME agent once more on a transient failure before failing over -- doubling worst-case per-agent wall-clock. Set tool_retry_attempts=0. 2. TaskOrchestrator's default policy.realtime_judge=True makes route_once issue a SECOND, independent, fully-bounded provider call per candidate to judge the first call's answer -- doubling worst-case wall-clock again. That judge's quality ledger is meant to steer a long-lived process's future routing; this sidecar is a fresh, ephemeral, one-shot process serving exactly one review request per CI run, so the learning has no opportunity to matter. TaskOrchestrator's constructor has no policy override parameter and OrchestrationPolicy is a frozen dataclass, so replace the instance attribute directly via dataclasses.replace() after construction. Verified live against the actual vendored contextual_orchestrator package (not just read): tool_retry_attempts=0 accepted, realtime_judge flips True->False via dataclasses.replace, and an end-to-end route_once() call confirms the judge path is skipped ("reason": "single route path") and a single attempt is accepted. Also raised noema_review_gate.py's own external client-side read timeout from a plain, margin-free 120 to a new named CALL_LLM_TIMEOUT_SECONDS=3000, derived from the enumerated worst case now that both bugs above are fixed: up to REVIEW_PREFLIGHT_MAX_TOTAL_ROUTES=24 preflight-verified-ready candidates, each bounded to at most one REVIEW_SERVING_TIMEOUT_SECONDS=120s attempt, plus overhead margin -- not guessed. The old 120s external timeout raced the internal 120s per-attempt budget with zero margin and could not survive even one candidate needing cross-candidate failover. contextual_orchestrator_review_launcher.py is excluded from this repo's own coverage gate (imports the vendored package, which this repo's test suite cannot import) -- verified via py_compile syntax check plus a live smoke test against the actual installed contextual_orchestrator package instead. Fixed two existing tests that hardcoded the old timeout=120 literal (test_call_llm_repairs_one_rejected_changed_line_verdict, test_noema_public_dns_result_reaches_valid_model_response) to reference noema.CALL_LLM_TIMEOUT_SECONDS instead, and added a new dedicated regression test asserting the exact value. Full suite: 2148 passed, 1 skipped, 21 subtests passed (baseline 2147 + 1 new test). 100% coverage on scripts/ci/. 100% docstrings (interrogate). git diff --check clean. Refs: contextual-orchestrator#946, contextual-orchestrator#974. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01KPmJErfkcHer4UVEgrQxUX --- CHANGELOG.md | 20 ++++++++++++ ...contextual_orchestrator_review_launcher.py | 24 +++++++++++++- scripts/ci/noema_review_gate.py | 19 +++++++++++- tests/test_noema_review_gate.py | 31 ++++++++++++++++++- ...itory_branch_coverage_review_schedulers.py | 2 +- 5 files changed, 92 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 46e1f23b7f..28e6d84aec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,26 @@ this file. The format follows Keep a Changelog, and versioned releases follow Semantic Versioning where the repository publishes a release. ## [Unreleased] +- Fix the root cause of `noema-review`'s four consecutive `TimeoutError` + failures on `contextual-orchestrator#946` (enumerated in + `contextual-orchestrator#974`): the review sidecar's *serving* + `TaskOrchestrator` left `tool_retry_attempts` at its default (1, doubling + worst-case per-agent wall-clock via a same-agent retry) and + `policy.realtime_judge` at its default (`True`, adding a second, fully + independent provider call per candidate to judge the first one's answer) + — neither tuned to fit inside `noema_review_gate.py`'s external client + timeout, unlike the deliberately zero-retry preflight client. Set + `tool_retry_attempts=0` and replace the (frozen) `OrchestrationPolicy` + with `realtime_judge=False` for the serving orchestrator only — safe here + since this sidecar is a fresh, ephemeral, one-shot process per CI run, so + the judge's quality-ledger learning (meant to steer a long-lived + process's *future* routing) has no opportunity to matter. Also raised + `noema_review_gate.py`'s external read timeout from a plain, margin-free + `120` to a new `CALL_LLM_TIMEOUT_SECONDS=3000`, derived from the + enumerated worst case (up to `REVIEW_PREFLIGHT_MAX_TOTAL_ROUTES`=24 + preflight-verified-ready candidates, each now bounded to at most one + `REVIEW_SERVING_TIMEOUT_SECONDS`=120s attempt) plus overhead margin, not + guessed. - Fix two real bugs Devin's automated review found on this same PR (ContextualWisdomLab/.github#1415) against the just-landed `_catalog_account_cap(DEFAULT_ACCOUNT_CAP)` fix and the discovery-budget diff --git a/scripts/ci/contextual_orchestrator_review_launcher.py b/scripts/ci/contextual_orchestrator_review_launcher.py index dfb2d6eef9..8838611a46 100644 --- a/scripts/ci/contextual_orchestrator_review_launcher.py +++ b/scripts/ci/contextual_orchestrator_review_launcher.py @@ -23,6 +23,7 @@ import argparse from concurrent.futures import ThreadPoolExecutor +import dataclasses import json import os import re @@ -1499,7 +1500,28 @@ def main(argv: list[str] | None = None) -> int: client = _build_model_client( ModelClient, timeout=REVIEW_SERVING_TIMEOUT_SECONDS ) - orchestrator = TaskOrchestrator(agents, client=client) + # tool_retry_attempts=0: TaskOrchestrator's default (1) makes route_once's + # _invoke retry the SAME agent once more on a transient failure before + # failing over to the next candidate -- doubling worst-case per-agent + # wall-clock (2 x REVIEW_SERVING_TIMEOUT_SECONDS) on top of the + # cross-candidate failover this sidecar already relies on for + # reliability. Disabled here so each candidate gets exactly one bounded + # attempt; failover to the next preflight-verified-ready candidate still + # happens (see contextual-orchestrator#946's noema-review TimeoutError + # investigation and contextual-orchestrator#974's worst-case enumeration). + orchestrator = TaskOrchestrator(agents, client=client, tool_retry_attempts=0) + # realtime_judge (on by default via TaskOrchestrator's internal + # OrchestrationPolicy) makes route_once() issue a SECOND, independent, + # fully-bounded provider call per candidate to judge the first call's + # answer before accepting it -- doubling worst-case wall-clock again on + # top of the retry elimination above. That judge feeds a quality ledger + # meant to steer *future* routing decisions inside a long-lived process; + # this sidecar is a fresh, ephemeral process serving exactly one review + # request per CI run, so that learning has no opportunity to matter here. + # TaskOrchestrator's constructor has no policy override parameter and + # OrchestrationPolicy is a frozen dataclass, so replace the instance + # attribute directly (TaskOrchestrator itself is a plain, unfrozen class). + orchestrator.policy = dataclasses.replace(orchestrator.policy, realtime_judge=False) serve( orchestrator, host=args.host, diff --git a/scripts/ci/noema_review_gate.py b/scripts/ci/noema_review_gate.py index 946c2737ac..8ee6ecf441 100644 --- a/scripts/ci/noema_review_gate.py +++ b/scripts/ci/noema_review_gate.py @@ -32,6 +32,23 @@ MAX_FILE_CONTEXT_CHARS = 4000 MAX_REVIEW_CONTEXT_CHARS = 24000 MAX_THREAD_BODY_CHARS = 1200 +# Enumerated, not guessed: the review sidecar's serving ModelClient bounds a +# single provider attempt to REVIEW_SERVING_TIMEOUT_SECONDS=120s (see +# scripts/ci/contextual_orchestrator_review_launcher.py), with retries and +# the real-time judge's own second provider call both disabled specifically +# so the *combined* per-candidate wall-clock never exceeds that 120s. The +# sidecar's serving candidate pool is exactly the set of routes preflight +# verified ready, bounded by REVIEW_PREFLIGHT_MAX_TOTAL_ROUTES=24 (the same +# file) -- so the true worst case, every verified-ready candidate rejecting +# the full-size real payload after preflight accepted it on a tiny probe, is +# 24 x 120s = 2880s. This client-side read timeout must stay comfortably +# above that combined worst case (plus routing/JSON/GC overhead margin) so a +# legitimate multi-candidate failover is never mistaken for a hang -- see +# contextual-orchestrator#946's four consecutive TimeoutError failures and +# contextual-orchestrator#974's worst-case enumeration, which first +# identified this exact mismatch (there against the previous, un-tuned +# defaults; here against this file's own now-matching serving timeout). +CALL_LLM_TIMEOUT_SECONDS = 3000 DIFF_HUNK_RE = re.compile(r"^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@") ORCHESTRATOR_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1"}) @@ -655,7 +672,7 @@ def call_llm( method="POST", ) opener = urllib.request.build_opener(NoRedirectHandler()) - with opener.open(request, timeout=120) as response: # nosec B310 + with opener.open(request, timeout=CALL_LLM_TIMEOUT_SECONDS) as response: # nosec B310 raw = response.read().decode("utf-8") data = json.loads(raw) content = (((data.get("choices") or [{}])[0].get("message") or {}).get("content") or "").strip() diff --git a/tests/test_noema_review_gate.py b/tests/test_noema_review_gate.py index 69bc6c709b..afa34bad6c 100644 --- a/tests/test_noema_review_gate.py +++ b/tests/test_noema_review_gate.py @@ -415,6 +415,35 @@ def open(self, request, timeout=None): assert seen["body"]["orchestration"] == "route" +def test_call_llm_uses_the_enumerated_combined_worst_case_timeout(monkeypatch): + """The client-side read timeout must match the enumerated sidecar worst case. + + Regression test for contextual-orchestrator#946's four consecutive + ``noema-review`` ``TimeoutError`` failures and contextual-orchestrator#974's + worst-case enumeration: a plain ``timeout=120`` here raced the sidecar's + own internal per-candidate budget with zero margin, and could not survive + even one candidate needing a legitimate cross-candidate failover. This + must stay exactly ``CALL_LLM_TIMEOUT_SECONDS`` -- see that constant's own + comment for the enumerated derivation -- so a change to either side of + the mismatch is caught here rather than rediscovered via a live CI outage. + """ + monkeypatch.setenv("NOEMA_LLM_API_URL", "https://llm.example/v1/chat/completions") + monkeypatch.setenv("NOEMA_LLM_API_KEY", "secret") + seen = {} + + def fake_urlopen(request, timeout): + seen["timeout"] = timeout + return FakeResponse({"choices": [{"message": {"content": '{"decision":"comment","summary":"ok","findings":[]}'}}]}) + + class FakeOpener: + def open(self, request, timeout=None): + return fake_urlopen(request, timeout) + + monkeypatch.setattr(noema.urllib.request, "build_opener", lambda *args: FakeOpener()) + noema.call_llm("owner/repo", 1, make_pr(), "diff", False) + assert seen["timeout"] == noema.CALL_LLM_TIMEOUT_SECONDS == 3000 + + def test_noema_redirect_handler_rejects_redirects(): """Noema must not follow redirects after validating the initial URL.""" handler = noema.NoRedirectHandler() @@ -717,7 +746,7 @@ def read(self): class Opener: def open(self, request, timeout): - assert timeout == 120 + assert timeout == noema.CALL_LLM_TIMEOUT_SECONDS payloads.append(json.loads(request.data)) return Response(invalid if len(payloads) == 1 else valid) diff --git a/tests/test_repository_branch_coverage_review_schedulers.py b/tests/test_repository_branch_coverage_review_schedulers.py index 85cdc0b964..ce51821bfc 100644 --- a/tests/test_repository_branch_coverage_review_schedulers.py +++ b/tests/test_repository_branch_coverage_review_schedulers.py @@ -63,7 +63,7 @@ class Opener: """Open one deterministic provider response.""" def open(self, _request: Any, timeout: int) -> Response: - assert timeout == 120 + assert timeout == noema.CALL_LLM_TIMEOUT_SECONDS return Response() monkeypatch.setattr(noema.urllib.request, "build_opener", lambda *_args: Opener()) From d98a310b44e5c8deb91893edda89285745cb899f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 12:47:26 +0000 Subject: [PATCH 35/65] fix(noema): restore serving quality gate/failover, re-derive timeout honestly Devin's review on this PR (ContextualWisdomLab/.github#1415, "Serving answers bypass quality validation") correctly flagged that disabling policy.realtime_judge to cut latency also removed route_once's real-time gate on the CURRENT answer and its failover to the next candidate on rejection -- not just future-routing learning, which was the only aspect the prior fix considered. Investigating further found tool_retry_attempts=0 was independently wrong too: it also drives route_once's own outer cross-candidate loop bound (max_attempts = 1 + min(tool_retry_attempts, MAX_TOOL_RETRY_ATTEMPTS)) down to 1, so reverting realtime_judge alone would still leave a judge-rejected answer with nowhere to fail over to. Restore both TaskOrchestrator defaults untouched (tool_retry_attempts=1, realtime_judge=True) and re-derive CALL_LLM_TIMEOUT_SECONDS honestly against that unmodified configuration (3000 -> 23040s, itemized in the constant's own comment). Add an explicit timeout-minutes: 360 to the noema-review job so the GitHub-hosted-runner ceiling this derivation reasons about is self-documented next to the step it bounds. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01KPmJErfkcHer4UVEgrQxUX --- .github/workflows/noema-review.yml | 7 ++ CHANGELOG.md | 53 ++++++++++----- ...contextual_orchestrator_review_launcher.py | 46 ++++++------- scripts/ci/noema_review_gate.py | 65 ++++++++++++++----- tests/test_noema_review_gate.py | 2 +- 5 files changed, 114 insertions(+), 59 deletions(-) diff --git a/.github/workflows/noema-review.yml b/.github/workflows/noema-review.yml index 064c4e5aee..09f84a8175 100644 --- a/.github/workflows/noema-review.yml +++ b/.github/workflows/noema-review.yml @@ -43,6 +43,13 @@ jobs: noema-review: name: noema-review runs-on: ubuntu-latest + # Explicit, not GitHub's implicit default: self-documents the ceiling + # scripts/ci/noema_review_gate.py's CALL_LLM_TIMEOUT_SECONDS derivation + # cites (GitHub-hosted runners hard-cap a single job at 360 minutes + # regardless of this value). Unchanged from the platform default -- this + # does not alter behavior, only makes the ceiling discoverable next to + # the step whose worst-case timeout is now sized close to it. + timeout-minutes: 360 if: >- github.event_name == 'repository_dispatch' || ( diff --git a/CHANGELOG.md b/CHANGELOG.md index 28e6d84aec..02178ab92a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,24 +7,41 @@ Semantic Versioning where the repository publishes a release. ## [Unreleased] - Fix the root cause of `noema-review`'s four consecutive `TimeoutError` failures on `contextual-orchestrator#946` (enumerated in - `contextual-orchestrator#974`): the review sidecar's *serving* - `TaskOrchestrator` left `tool_retry_attempts` at its default (1, doubling - worst-case per-agent wall-clock via a same-agent retry) and - `policy.realtime_judge` at its default (`True`, adding a second, fully - independent provider call per candidate to judge the first one's answer) - — neither tuned to fit inside `noema_review_gate.py`'s external client - timeout, unlike the deliberately zero-retry preflight client. Set - `tool_retry_attempts=0` and replace the (frozen) `OrchestrationPolicy` - with `realtime_judge=False` for the serving orchestrator only — safe here - since this sidecar is a fresh, ephemeral, one-shot process per CI run, so - the judge's quality-ledger learning (meant to steer a long-lived - process's *future* routing) has no opportunity to matter. Also raised - `noema_review_gate.py`'s external read timeout from a plain, margin-free - `120` to a new `CALL_LLM_TIMEOUT_SECONDS=3000`, derived from the - enumerated worst case (up to `REVIEW_PREFLIGHT_MAX_TOTAL_ROUTES`=24 - preflight-verified-ready candidates, each now bounded to at most one - `REVIEW_SERVING_TIMEOUT_SECONDS`=120s attempt) plus overhead margin, not - guessed. + `contextual-orchestrator#974`), then correct that fix per Devin's follow-up + review on this same PR (ContextualWisdomLab/.github#1415, "Serving answers + bypass quality validation"): an initial version set + `tool_retry_attempts=0` and replaced the serving `TaskOrchestrator`'s + (frozen) `OrchestrationPolicy` with `realtime_judge=False`, reasoning that + the judge's quality-ledger learning (meant to steer a long-lived process's + *future* routing) had no opportunity to matter for this fresh, + one-shot-per-CI-run sidecar. That reasoning was incomplete on two counts: + `realtime_judge` also gates acceptance of the *current* answer and drives + failover to the next candidate on rejection — a real per-request quality + control, not just future-routing learning — and `tool_retry_attempts=0` + independently collapsed `route_once`'s own outer cross-candidate loop to a + single attempt (`max_attempts = 1 + min(tool_retry_attempts, + MAX_TOOL_RETRY_ATTEMPTS)`), so even reverting `realtime_judge` alone would + have left a judge-rejected answer with nowhere to fail over to. Both + defaults are now left untouched (`tool_retry_attempts=1`, + `realtime_judge=True`), fully restoring serving's per-request quality gate + and failover. `noema_review_gate.py`'s external read timeout is + re-derived honestly against that unmodified configuration: from the + previous fix's margin-free `120`, through an intermediate + `CALL_LLM_TIMEOUT_SECONDS=3000` (sized for the now-reverted reduced-retry + config), to `CALL_LLM_TIMEOUT_SECONDS=23040` — one `_invoke()` call (worker + or judge) tries up to `REVIEW_PREFLIGHT_MAX_TOTAL_ROUTES`=24 candidates at + up to `1 + min(tool_retry_attempts=1, MAX_TOOL_RETRY_ATTEMPTS=4)=2` + attempts each, bounded by `REVIEW_SERVING_TIMEOUT_SECONDS`=120s + (24×2×120=5760s); one `route_once()` attempt makes both a worker and a + judge `_invoke()` call (5760+5760=11520s); and `route_once`'s own outer + loop retries up to `max_attempts`=2 top-level candidates on judge + rejection (2×11520=23040s) — not guessed. `noema-review.yml`'s + `noema-review` job now also declares an explicit `timeout-minutes: 360` + (GitHub-hosted runners' own hard ceiling, unchanged from the implicit + default) so that ceiling — smaller than even one 23040s worst case, and + the real backstop for the vanishingly rare compound case where + `call_llm`'s one-shot verdict-repair retry also hits its own full worst + case — is discoverable next to the step it bounds. - Fix two real bugs Devin's automated review found on this same PR (ContextualWisdomLab/.github#1415) against the just-landed `_catalog_account_cap(DEFAULT_ACCOUNT_CAP)` fix and the discovery-budget diff --git a/scripts/ci/contextual_orchestrator_review_launcher.py b/scripts/ci/contextual_orchestrator_review_launcher.py index 8838611a46..7da0bcd13e 100644 --- a/scripts/ci/contextual_orchestrator_review_launcher.py +++ b/scripts/ci/contextual_orchestrator_review_launcher.py @@ -23,7 +23,6 @@ import argparse from concurrent.futures import ThreadPoolExecutor -import dataclasses import json import os import re @@ -1500,28 +1499,29 @@ def main(argv: list[str] | None = None) -> int: client = _build_model_client( ModelClient, timeout=REVIEW_SERVING_TIMEOUT_SECONDS ) - # tool_retry_attempts=0: TaskOrchestrator's default (1) makes route_once's - # _invoke retry the SAME agent once more on a transient failure before - # failing over to the next candidate -- doubling worst-case per-agent - # wall-clock (2 x REVIEW_SERVING_TIMEOUT_SECONDS) on top of the - # cross-candidate failover this sidecar already relies on for - # reliability. Disabled here so each candidate gets exactly one bounded - # attempt; failover to the next preflight-verified-ready candidate still - # happens (see contextual-orchestrator#946's noema-review TimeoutError - # investigation and contextual-orchestrator#974's worst-case enumeration). - orchestrator = TaskOrchestrator(agents, client=client, tool_retry_attempts=0) - # realtime_judge (on by default via TaskOrchestrator's internal - # OrchestrationPolicy) makes route_once() issue a SECOND, independent, - # fully-bounded provider call per candidate to judge the first call's - # answer before accepting it -- doubling worst-case wall-clock again on - # top of the retry elimination above. That judge feeds a quality ledger - # meant to steer *future* routing decisions inside a long-lived process; - # this sidecar is a fresh, ephemeral process serving exactly one review - # request per CI run, so that learning has no opportunity to matter here. - # TaskOrchestrator's constructor has no policy override parameter and - # OrchestrationPolicy is a frozen dataclass, so replace the instance - # attribute directly (TaskOrchestrator itself is a plain, unfrozen class). - orchestrator.policy = dataclasses.replace(orchestrator.policy, realtime_judge=False) + # CORRECTED (ContextualWisdomLab/.github#1415, Devin Review "Serving + # answers bypass quality validation"): an earlier version of this fix + # disabled TaskOrchestrator's tool_retry_attempts (to 0) and + # policy.realtime_judge (to False) to shave worst-case wall-clock. Both + # were wrong to touch. realtime_judge is not just a future-routing + # quality-ledger signal -- route_once() uses it to gate acceptance of the + # *current* answer and to fail over to the next measured candidate on + # rejection (see route_once/_realtime_route_judge in + # contextual_orchestrator/orchestrator.py); disabling it let a + # judge-rejected, low-quality answer reach Noema instead of another ready + # route. Separately, tool_retry_attempts=0 was doubly wrong: besides + # removing _invoke's legitimate same-agent retry-on-transient-failure, it + # also drives route_once's own OWN outer cross-candidate loop bound + # (`max_attempts = 1 + min(tool_retry_attempts, MAX_TOOL_RETRY_ATTEMPTS)` + # in route_once) down to 1 -- so even with realtime_judge alone reverted, + # a judge rejection would still have nowhere to fail over to. Both + # defaults are restored here unmodified (tool_retry_attempts=1, + # realtime_judge=True, both TaskOrchestrator's own tested constructor/ + # OrchestrationPolicy defaults) so serving keeps its full, intended + # per-request quality gate and failover; see CALL_LLM_TIMEOUT_SECONDS in + # noema_review_gate.py for the resulting (larger, honestly re-derived) + # client-side read-timeout this requires. + orchestrator = TaskOrchestrator(agents, client=client) serve( orchestrator, host=args.host, diff --git a/scripts/ci/noema_review_gate.py b/scripts/ci/noema_review_gate.py index 8ee6ecf441..22d1a71a15 100644 --- a/scripts/ci/noema_review_gate.py +++ b/scripts/ci/noema_review_gate.py @@ -32,23 +32,54 @@ MAX_FILE_CONTEXT_CHARS = 4000 MAX_REVIEW_CONTEXT_CHARS = 24000 MAX_THREAD_BODY_CHARS = 1200 -# Enumerated, not guessed: the review sidecar's serving ModelClient bounds a -# single provider attempt to REVIEW_SERVING_TIMEOUT_SECONDS=120s (see -# scripts/ci/contextual_orchestrator_review_launcher.py), with retries and -# the real-time judge's own second provider call both disabled specifically -# so the *combined* per-candidate wall-clock never exceeds that 120s. The -# sidecar's serving candidate pool is exactly the set of routes preflight -# verified ready, bounded by REVIEW_PREFLIGHT_MAX_TOTAL_ROUTES=24 (the same -# file) -- so the true worst case, every verified-ready candidate rejecting -# the full-size real payload after preflight accepted it on a tiny probe, is -# 24 x 120s = 2880s. This client-side read timeout must stay comfortably -# above that combined worst case (plus routing/JSON/GC overhead margin) so a -# legitimate multi-candidate failover is never mistaken for a hang -- see -# contextual-orchestrator#946's four consecutive TimeoutError failures and -# contextual-orchestrator#974's worst-case enumeration, which first -# identified this exact mismatch (there against the previous, un-tuned -# defaults; here against this file's own now-matching serving timeout). -CALL_LLM_TIMEOUT_SECONDS = 3000 +# Enumerated, not guessed, against the sidecar's *unmodified* TaskOrchestrator +# defaults (ContextualWisdomLab/.github#1415, Devin Review "Serving answers +# bypass quality validation" -- an earlier version of this constant was sized +# against a serving config that had disabled tool_retry_attempts and +# policy.realtime_judge to shave latency, which also silently broke +# route_once's per-request quality gate and judge-rejection failover; see +# scripts/ci/contextual_orchestrator_review_launcher.py's orchestrator +# construction comment for that correction). With full defaults restored: +# - One _invoke() call (worker OR judge) tries up to +# REVIEW_PREFLIGHT_MAX_TOTAL_ROUTES=24 candidates (contextual_orchestrator +# _invoke's own _failover_candidates has no smaller bound of its own), each +# up to 1 + min(tool_retry_attempts=1, MAX_TOOL_RETRY_ATTEMPTS=4) = 2 +# attempts, each bounded by REVIEW_SERVING_TIMEOUT_SECONDS=120s (same +# file): 24 x 2 x 120 = 5760s worst case. +# - route_once() issues that worker _invoke() call, then (realtime_judge +# defaulting True) an independent judge _invoke() call of the same shape +# via _model_judge_verification/_FastMLSIJudgeAdapter.complete(): another +# 5760s worst case. One outer route_once attempt: 5760 + 5760 = 11520s. +# - route_once's own outer cross-candidate loop retries a fresh top-level +# candidate on judge rejection, up to +# max_attempts = 1 + min(tool_retry_attempts=1, MAX_TOOL_RETRY_ATTEMPTS=4) +# = 2 total candidates: 2 x 11520 = 23040s worst case for one full +# route_once() call (one /v1/chat/completions request this function +# sends). +# This client-side read timeout is sized to that 23040s per-call worst case +# so a legitimate multi-candidate, judge-gated failover is never mistaken for +# a hang -- see contextual-orchestrator#946's four consecutive TimeoutError +# failures and contextual-orchestrator#974's worst-case enumeration, which +# first identified this class of mismatch (there against the previous, +# un-tuned 120s default). +# +# call_llm() itself can still issue a second such call (the one-shot verdict +# repair retry below, guarded by `repair_error` against further recursion), +# so the absolute theoretical ceiling for this function is double this +# constant. That compound case is not specially bounded here: it would +# require BOTH the original call AND the repair call to independently hit +# their own full 24-candidate, dual-role worst case, and the repair call only +# fires after a FAST successful-but-rejected response, not after a timeout. +# On this repo's noema-review.yml runner (ubuntu-latest), GitHub Actions' +# own hosted-runner job ceiling (360 minutes; see that job's explicit +# `timeout-minutes: 360`) is smaller than even one 23040s (384-minute) worst +# case and is the real backstop in that vanishingly rare compound scenario -- +# this constant is still sized to the honest per-call worst case rather than +# artificially shrunk to fit under that ceiling, because shrinking it would +# reintroduce #946's actual bug (truncating a legitimate single-call +# response) on the common path to guard against an already-separately-bounded +# extreme case. +CALL_LLM_TIMEOUT_SECONDS = 23040 DIFF_HUNK_RE = re.compile(r"^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@") ORCHESTRATOR_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1"}) diff --git a/tests/test_noema_review_gate.py b/tests/test_noema_review_gate.py index afa34bad6c..158ded2f00 100644 --- a/tests/test_noema_review_gate.py +++ b/tests/test_noema_review_gate.py @@ -441,7 +441,7 @@ def open(self, request, timeout=None): monkeypatch.setattr(noema.urllib.request, "build_opener", lambda *args: FakeOpener()) noema.call_llm("owner/repo", 1, make_pr(), "diff", False) - assert seen["timeout"] == noema.CALL_LLM_TIMEOUT_SECONDS == 3000 + assert seen["timeout"] == noema.CALL_LLM_TIMEOUT_SECONDS == 23040 def test_noema_redirect_handler_rejects_redirects(): From dc7b38cec4129ce3c5e6def8c83befc15189c8a6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 13:03:16 +0000 Subject: [PATCH 36/65] fix(noema): cap serving candidates so worst case fits the job deadline Devin's follow-up review on ContextualWisdomLab/.github#1415 ("Valid reviews exceed job deadline") caught that CALL_LLM_TIMEOUT_SECONDS=23040 (384 minutes) already exceeded noema-review.yml's own explicit timeout-minutes: 360 (21600s) ceiling for a single call -- a client-side timeout the enclosing job can never actually honor is a false promise, not a safety margin. Rather than shrink the timeout below a legitimate multi-candidate, judge-gated failover's real needs (reintroducing contextual-orchestrator #946's original bug), cap how many preflight-verified-ready candidates the serving orchestrator draws from to a new REVIEW_SERVING_MAX_CANDIDATES=10 -- smaller than preflight's own 24-route admission-testing depth, but every one of the 10 has already independently proven it can serve a real request. Re-derive CALL_LLM_TIMEOUT_SECONDS backwards from the job's own 360-minute ceiling: 9600s per call (10 candidates x 2 attempts x 2 roles x 2 outer attempts x REVIEW_SERVING_TIMEOUT_SECONDS=120s), so the function's absolute worst case across both possible calls (19200s) now actually fits inside the 21600s job that enforces it, with real margin. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01KPmJErfkcHer4UVEgrQxUX --- CHANGELOG.md | 26 +++++++-- ...contextual_orchestrator_review_launcher.py | 54 +++++++++++++++++- scripts/ci/noema_review_gate.py | 57 +++++++++++-------- tests/test_noema_review_gate.py | 2 +- 4 files changed, 110 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 02178ab92a..6a53079aa8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,10 +38,28 @@ Semantic Versioning where the repository publishes a release. rejection (2×11520=23040s) — not guessed. `noema-review.yml`'s `noema-review` job now also declares an explicit `timeout-minutes: 360` (GitHub-hosted runners' own hard ceiling, unchanged from the implicit - default) so that ceiling — smaller than even one 23040s worst case, and - the real backstop for the vanishingly rare compound case where - `call_llm`'s one-shot verdict-repair retry also hits its own full worst - case — is discoverable next to the step it bounds. + default) so that ceiling is discoverable next to the step it bounds. + + Devin's next review round on this same PR ("Valid reviews exceed job + deadline") then caught that this was still wrong: `23040`s (384 minutes) + already exceeds that same `timeout-minutes: 360` (21600s) ceiling for a + *single* call, before even considering the second, one-shot repair call — + a client-side timeout the job can never actually honor is not a safety + margin, it is a false promise. Rather than shrink the timeout below what a + legitimate multi-candidate, judge-gated failover can need (reintroducing + #946's original bug), `scripts/ci/contextual_orchestrator_review_launcher.py` + now caps how many preflight-verified-ready candidates the *serving* + orchestrator draws from to a new `REVIEW_SERVING_MAX_CANDIDATES=10`, a + smaller number than preflight's own 24-route admission-testing depth + (every one of the 10 has already independently passed preflight's base + probe and serving-budget confirmation). Solved backwards from the job's + own ceiling (360 minutes, minus ~15 minutes of generously-rounded headroom + for the job's other steps, halved so a second repair call independently + fits too): `CALL_LLM_TIMEOUT_SECONDS` is now `9600` (10 candidates × 2 + attempts × 2 roles × 2 outer attempts × `REVIEW_SERVING_TIMEOUT_SECONDS`= + 120s), so the function's absolute worst case (two calls, 19200s) now + actually fits inside the 21600s job that enforces it, with real margin, + instead of relying on that job's own kill as an unacknowledged backstop. - Fix two real bugs Devin's automated review found on this same PR (ContextualWisdomLab/.github#1415) against the just-landed `_catalog_account_cap(DEFAULT_ACCOUNT_CAP)` fix and the discovery-budget diff --git a/scripts/ci/contextual_orchestrator_review_launcher.py b/scripts/ci/contextual_orchestrator_review_launcher.py index 7da0bcd13e..6aa24166ae 100644 --- a/scripts/ci/contextual_orchestrator_review_launcher.py +++ b/scripts/ci/contextual_orchestrator_review_launcher.py @@ -53,6 +53,49 @@ REVIEW_PREFLIGHT_BATCH_SIZE = 4 REVIEW_PREFLIGHT_MAX_TOTAL_ROUTES = 24 REVIEW_PREFLIGHT_PRIMARY_ROUTE_LIMIT = 8 +# FIXED (ContextualWisdomLab/.github#1415, Devin Review "Valid reviews exceed +# job deadline"): the serving TaskOrchestrator previously received the FULL +# preflight-admitted pool (up to REVIEW_PREFLIGHT_MAX_TOTAL_ROUTES=24), and +# noema_review_gate.py's CALL_LLM_TIMEOUT_SECONDS was sized to that pool's +# honest worst case (23040s = 384 minutes) -- which already exceeds +# noema-review.yml's own explicit `timeout-minutes: 360` (21600s) job +# ceiling for a SINGLE call, before even considering that call_llm can issue +# a second, one-shot verdict-repair call in the same job. A client-side +# timeout the enclosing job can never actually honor is not a safety margin, +# it is a false promise. Rather than shrink the promised worst case below +# what a real multi-candidate, judge-gated failover can legitimately need +# (reintroducing contextual-orchestrator#946's original bug), this caps how +# many preflight-verified-ready candidates the SERVING orchestrator draws +# from -- a separate, smaller number than preflight's own admission-testing +# depth above, which exists to find *some* ready route, not to bound serving +# wall-clock. +# +# Solved backwards from the job's own ceiling: noema-review.yml's +# `timeout-minutes: 360` (21600s) minus this job's other, non-serving steps +# (materialize the trusted archive, credential/token resolution, visibility +# lookup, and this file's own REVIEW_STARTUP_WATCHDOG_SECONDS-bounded sidecar +# provisioning -- generously rounded to 900s/15min of headroom) leaves 20700s +# for the "Run Noema LLM review" step. That step can invoke call_llm up to +# twice (the original request plus one one-shot verdict-repair retry; +# see noema_review_gate.py's call_llm), so each call's own worst case must +# independently fit in half of that, 10350s, for the pair to always fit. +# One route_once() call's orchestrator-level worst case is +# outer_route_once_attempts(2, from route_once's own +# `max_attempts = 1 + min(tool_retry_attempts=1, MAX_TOOL_RETRY_ATTEMPTS=4)`) +# x roles(2: a worker _invoke() call, then an independent judge _invoke() +# call via _realtime_route_judge/_model_judge_verification) x +# per-agent-attempts(2, the same `1 + min(1,4)` expression bounding +# _invoke's own same-agent retry) x REVIEW_SERVING_TIMEOUT_SECONDS(120) x +# this candidate cap: 2 x 2 x 2 x 120 x N = 960N seconds. Solving +# 960N <= 10350 gives N <= 10.78, so N=10 -- see +# CALL_LLM_TIMEOUT_SECONDS in noema_review_gate.py for the resulting exact +# 960 x 10 = 9600s per-call value this produces. Every one of these 10 +# candidates has already independently PASSED preflight's own base probe and +# serving-budget confirmation (see REVIEW_PREFLIGHT_MAX_ESCALATIONS above), +# so this is a reduction in serving-time failover depth among +# already-proven-healthy routes, not a reduction in how thoroughly preflight +# searches for a usable one. +REVIEW_SERVING_MAX_CANDIDATES = 10 # ADR-0005: a single fixed max_tokens cannot fit every model in a heterogeneous # pool -- some spend internal reasoning tokens before visible content and need # more, others have a real completion ceiling a large budget would exceed. The @@ -1521,7 +1564,16 @@ def main(argv: list[str] | None = None) -> int: # per-request quality gate and failover; see CALL_LLM_TIMEOUT_SECONDS in # noema_review_gate.py for the resulting (larger, honestly re-derived) # client-side read-timeout this requires. - orchestrator = TaskOrchestrator(agents, client=client) + # + # Sliced to REVIEW_SERVING_MAX_CANDIDATES (see that constant's own + # comment): serving the full preflight-admitted pool made the honest + # worst case exceed this job's own timeout-minutes ceiling. `agents` is + # already preflight's own ranked, verified-ready ordering, so this keeps + # the top-ranked candidates and only trims serving-time failover depth + # among routes preflight already proved could serve a real request. + orchestrator = TaskOrchestrator( + agents[:REVIEW_SERVING_MAX_CANDIDATES], client=client + ) serve( orchestrator, host=args.host, diff --git a/scripts/ci/noema_review_gate.py b/scripts/ci/noema_review_gate.py index 22d1a71a15..703823bb7b 100644 --- a/scripts/ci/noema_review_gate.py +++ b/scripts/ci/noema_review_gate.py @@ -39,47 +39,58 @@ # policy.realtime_judge to shave latency, which also silently broke # route_once's per-request quality gate and judge-rejection failover; see # scripts/ci/contextual_orchestrator_review_launcher.py's orchestrator -# construction comment for that correction). With full defaults restored: +# construction comment for that correction). +# +# FIXED (ContextualWisdomLab/.github#1415, Devin Review "Valid reviews exceed +# job deadline"): a next-earlier version of this constant (23040s = 384 +# minutes) was derived against the FULL preflight-admitted candidate pool +# (REVIEW_PREFLIGHT_MAX_TOTAL_ROUTES=24) and already exceeded +# noema-review.yml's own `timeout-minutes: 360` (21600s) job ceiling for a +# SINGLE call -- a client-side timeout the enclosing job could never actually +# honor is not a safety margin, it is a false promise. Rather than shrink +# this constant below what a real multi-candidate, judge-gated failover can +# legitimately need (reintroducing contextual-orchestrator#946's original +# bug), scripts/ci/contextual_orchestrator_review_launcher.py now caps how +# many preflight-verified-ready candidates the SERVING orchestrator draws +# from to REVIEW_SERVING_MAX_CANDIDATES=10 (see that constant's own comment +# for the full backward derivation from the job's 360-minute ceiling) instead +# of the full 24-route preflight-admission pool. With that cap and full +# TaskOrchestrator defaults (tool_retry_attempts=1, realtime_judge=True): # - One _invoke() call (worker OR judge) tries up to -# REVIEW_PREFLIGHT_MAX_TOTAL_ROUTES=24 candidates (contextual_orchestrator +# REVIEW_SERVING_MAX_CANDIDATES=10 candidates (contextual_orchestrator # _invoke's own _failover_candidates has no smaller bound of its own), each # up to 1 + min(tool_retry_attempts=1, MAX_TOOL_RETRY_ATTEMPTS=4) = 2 # attempts, each bounded by REVIEW_SERVING_TIMEOUT_SECONDS=120s (same -# file): 24 x 2 x 120 = 5760s worst case. +# file): 10 x 2 x 120 = 2400s worst case. # - route_once() issues that worker _invoke() call, then (realtime_judge # defaulting True) an independent judge _invoke() call of the same shape # via _model_judge_verification/_FastMLSIJudgeAdapter.complete(): another -# 5760s worst case. One outer route_once attempt: 5760 + 5760 = 11520s. +# 2400s worst case. One outer route_once attempt: 2400 + 2400 = 4800s. # - route_once's own outer cross-candidate loop retries a fresh top-level # candidate on judge rejection, up to # max_attempts = 1 + min(tool_retry_attempts=1, MAX_TOOL_RETRY_ATTEMPTS=4) -# = 2 total candidates: 2 x 11520 = 23040s worst case for one full +# = 2 total candidates: 2 x 4800 = 9600s worst case for one full # route_once() call (one /v1/chat/completions request this function # sends). -# This client-side read timeout is sized to that 23040s per-call worst case -# so a legitimate multi-candidate, judge-gated failover is never mistaken for -# a hang -- see contextual-orchestrator#946's four consecutive TimeoutError +# This client-side read timeout is sized to that 9600s per-call worst case so +# a legitimate multi-candidate, judge-gated failover is never mistaken for a +# hang -- see contextual-orchestrator#946's four consecutive TimeoutError # failures and contextual-orchestrator#974's worst-case enumeration, which # first identified this class of mismatch (there against the previous, # un-tuned 120s default). # # call_llm() itself can still issue a second such call (the one-shot verdict # repair retry below, guarded by `repair_error` against further recursion), -# so the absolute theoretical ceiling for this function is double this -# constant. That compound case is not specially bounded here: it would -# require BOTH the original call AND the repair call to independently hit -# their own full 24-candidate, dual-role worst case, and the repair call only -# fires after a FAST successful-but-rejected response, not after a timeout. -# On this repo's noema-review.yml runner (ubuntu-latest), GitHub Actions' -# own hosted-runner job ceiling (360 minutes; see that job's explicit -# `timeout-minutes: 360`) is smaller than even one 23040s (384-minute) worst -# case and is the real backstop in that vanishingly rare compound scenario -- -# this constant is still sized to the honest per-call worst case rather than -# artificially shrunk to fit under that ceiling, because shrinking it would -# reintroduce #946's actual bug (truncating a legitimate single-call -# response) on the common path to guard against an already-separately-bounded -# extreme case. -CALL_LLM_TIMEOUT_SECONDS = 23040 +# for an absolute worst case of 2 x 9600 = 19200s across the whole function. +# noema-review.yml's `timeout-minutes: 360` (21600s) job leaves that full +# 19200s pair comfortable, generously-rounded headroom (900s/15min) for the +# job's other, non-serving steps (materialize the trusted archive, +# credential/token resolution, visibility lookup, and this repo's own +# REVIEW_STARTUP_WATCHDOG_SECONDS-bounded sidecar provisioning) -- unlike the +# prior 23040s constant, this one's absolute worst case now actually fits +# inside the job that enforces it, with margin, instead of relying on that +# job's own kill as an unacknowledged backstop. +CALL_LLM_TIMEOUT_SECONDS = 9600 DIFF_HUNK_RE = re.compile(r"^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@") ORCHESTRATOR_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1"}) diff --git a/tests/test_noema_review_gate.py b/tests/test_noema_review_gate.py index 158ded2f00..d6f45a3ee6 100644 --- a/tests/test_noema_review_gate.py +++ b/tests/test_noema_review_gate.py @@ -441,7 +441,7 @@ def open(self, request, timeout=None): monkeypatch.setattr(noema.urllib.request, "build_opener", lambda *args: FakeOpener()) noema.call_llm("owner/repo", 1, make_pr(), "diff", False) - assert seen["timeout"] == noema.CALL_LLM_TIMEOUT_SECONDS == 23040 + assert seen["timeout"] == noema.CALL_LLM_TIMEOUT_SECONDS == 9600 def test_noema_redirect_handler_rejects_redirects(): From 098df100cd7ca9efabb9e05ccb6db8b9c4c775ba Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 13:34:40 +0000 Subject: [PATCH 37/65] docs(noema): explain why REVIEW_SERVING_MAX_CANDIDATES exceeds today's reachable pool Devin's review on #1415 correctly noted that _preflight_review_agent_batches returns on the first batch with any viable candidate, so agents is never larger than REVIEW_PREFLIGHT_BATCH_SIZE=4 in practice -- REVIEW_SERVING_MAX_ CANDIDATES=10 doesn't currently bind. That's deliberate: this cap and CALL_LLM_TIMEOUT_SECONDS are a job-deadline safety ceiling derived from the job's own time budget, independent of preflight's own batching strategy. Coupling them to today's incidental batch size would let a future change to that early-return behavior silently regrow the real worst case past the job's 360-minute ceiling again. No behavior change; comment only. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01KPmJErfkcHer4UVEgrQxUX --- .../ci/contextual_orchestrator_review_launcher.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/scripts/ci/contextual_orchestrator_review_launcher.py b/scripts/ci/contextual_orchestrator_review_launcher.py index 6aa24166ae..f2d3f26a65 100644 --- a/scripts/ci/contextual_orchestrator_review_launcher.py +++ b/scripts/ci/contextual_orchestrator_review_launcher.py @@ -95,6 +95,21 @@ # so this is a reduction in serving-time failover depth among # already-proven-healthy routes, not a reduction in how thoroughly preflight # searches for a usable one. +# +# NOTE (Devin Review, "Serving cap exceeds reachable pool"): today, +# `_preflight_review_agent_batches` returns on the FIRST batch with any +# viable candidate (`if viable: return viable, {...}` below), so `agents` +# here is never larger than REVIEW_PREFLIGHT_BATCH_SIZE=4 in practice -- +# this cap of 10 does not currently bind. That is deliberate, not an +# oversight: this cap and CALL_LLM_TIMEOUT_SECONDS are a job-deadline safety +# ceiling derived independently of preflight's own batching strategy, not a +# number chosen to match it. Coupling them would mean any future change to +# preflight's early-return behavior (e.g. accumulating viable candidates +# across batches instead of stopping at the first) could silently regrow the +# real worst case past what the job's 360-minute ceiling allows again, +# exactly the bug this constant exists to prevent. Sizing this cap from the +# job's own budget, independent of today's incidental batch size, means it +# stays correct even if that batching strategy changes. REVIEW_SERVING_MAX_CANDIDATES = 10 # ADR-0005: a single fixed max_tokens cannot fit every model in a heterogeneous # pool -- some spend internal reasoning tokens before visible content and need From 0509639dcf10f718edb4d14ea08f1bd5c703f312 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 13:43:56 +0000 Subject: [PATCH 38/65] docs(gap-baseline): record opencode-review structural deadlock finding Root-caused why .github#1500's opencode-review required check exhausted its full 90-minute active-dispatch-and-poll window with no verdict: pr_review_merge_scheduler.py's decide() blocks on ANY unresolved review thread before it can ever reach dispatch_opencode_review(), and Devin/ CodeRabbit post threads faster than most PRs get them resolved -- making "at least one unresolved thread" close to the default state for any actively-reviewed PR. Not fixed in this pass (a safe informational-vs- actionable classification needs careful, separately-scoped work); tracked here with a concrete next-increment plan, plus two smaller, related timeout-robustness gaps stood down on in #1415's own review. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01KPmJErfkcHer4UVEgrQxUX --- docs/product-technical-gap-baseline.md | 57 ++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 758ef2961a..778bfdb0d3 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1715,6 +1715,63 @@ string, a bare number) confirmed to fail against the pre-fix script (`KeyError: signature as the original round-4 bug) before passing after the fix. 1930 tests pass; 100% coverage and 100% docstring coverage on `scripts/ci/`. +## 2026-08-31 opencode-review structural deadlock: unresolved threads starve the dispatch that would resolve them + +**Root cause found while investigating why `.github#1500`'s `opencode-review` required check exhausted +its full 90-minute active-dispatch-and-poll window (added by #1497) with no `opencode-agent` verdict.** +`pr_review_merge_scheduler.py`'s `decide()` has an unconditional early return +(`scripts/ci/pr_review_merge_scheduler.py:3462-3474`): `if unresolved_thread_count(pr): return +decide("block", ...)`, before any code path can reach `dispatch_opencode_review()`. That gate long +predates automated review bots (traced to `ea2b2cc8`, "Queue auto-merge for approved conflicts") and was +reasonable when it existed: don't auto-merge or re-review while a real conversation is open. It has since +become a structural deadlock, because `unresolved_thread_count()` counts *every* active, non-outdated +thread uniformly, regardless of source or severity — including a purely informational, no-action-needed +Devin/CodeRabbit note on a file `opencode-review`'s own verdict has nothing to do with. Since dispatching +a fresh `opencode-review` event is the *only* path to a verdict for the required check, and Devin/ +CodeRabbit post threads faster than most PRs get them resolved, "≥1 unresolved thread" is close to the +default state for any actively-reviewed PR in this repo — confirmed independently on `#1415` (3 open +threads at the time of check), `#1507` (1), `#1508` (2), `#1509` (4), vs. `#1491` (0, and its +`opencode-review` check passed normally). `#1504`'s successful `opencode-review` run the same day is a +positive control proving the #1497 dispatch-and-poll mechanism itself works correctly whenever no +unresolved thread blocks the scheduler at evaluation time. + +**Not fixed in this pass — the classification problem is genuinely hard to get safely right, not a quick +patch.** The obvious fix (skip the block for "informational-only" threads) needs a way to tell +"informational" from "actionable" that doesn't rely on fragile text-pattern matching across multiple +different bots' own emoji/formatting conventions (Devin's 🔴/🟡/🔍/📝, CodeRabbit's own separate severity +scheme, human reviewers who follow no pattern at all) — exactly the kind of heuristic this org's own +conventions warn against, and a wrong classification in either direction is dangerous: too permissive +silently defeats the safety gate for a real unaddressed finding; too conservative changes nothing. A more +robust design likely needs to key off GitHub's own formal review *state* (only a thread tied to an actual +`CHANGES_REQUESTED` review should block) rather than any inline comment thread, but confirming +`reviewThreads` carries that linkage needs its own careful investigation and test coverage before landing +a change to this security/trust-boundary-relevant scheduler. Tactically unblocked `#1500` and `#1415` by +resolving their own already-addressed/informational threads (see those PRs' own threads for the specific +acknowledgments) rather than touching the gate itself. **Next development increment**: a dedicated, +narrowly-scoped PR against `pr_review_merge_scheduler.py`'s `unresolved_thread_count()` (or a new, +separate predicate for review-dispatch eligibility distinct from merge eligibility), with regression +coverage across informational-only, actionable-bot, and human-reviewer-`CHANGES_REQUESTED` thread shapes, +before any PR is unblocked by classification logic rather than manual resolution. + +**Two related, smaller gaps found and stood down on in `.github#1415`'s own review, tracked here rather +than dropped:** +- *Streaming responses can defeat a bare socket timeout.* Both `.github#1415`'s preflight ModelClient + (`REVIEW_PREFLIGHT_TIMEOUT_SECONDS`) and — independently found on `.github#1509` — `noema_review_gate.py`'s + `call_llm` bound each HTTP attempt with a plain per-operation socket `timeout=`, which only bounds time + *between* reads, not total attempt duration: a provider trickling data slowly enough (each chunk just + under the timeout) could keep one attempt alive far past its nominal budget. `#1509` already built a + real deadline-watchdog wrapper (arm a timer, forcibly close the connection past total budget) for the + serving side; the preflight side needs the same treatment once that mechanism lands somewhere mergeable, + rather than a second, possibly-inconsistent implementation. +- *Discovery time is unbounded in catalog size.* `.github#1415`'s + `REVIEW_DISCOVERY_OPENROUTER_FREE_ENDPOINT_ROUND_CAP` assumes a bounded number of pagination rounds; if + OpenRouter's free-model catalog grows past that assumption, discovery can exceed + `REVIEW_STARTUP_WATCHDOG_SECONDS` and abort an otherwise-healthy sidecar. Needs either a deadline-based + (not round-count-based) bound in the launcher's discovery wrapper, or the vendored contextual-orchestrator + package's own OpenRouter discovery client exposing elapsed-time enforcement directly — a real redesign + in either case, not a constant tweak, and this exact family of finding has already been through several + rounds of patch → new finding on `#1415` without converging. + ## 5. 실행 루프와 고객의 다음 행동 각 hourly pass는 아래 순서를 유지한다. From 0160515fbac6e21ccbadd0776fb0ade2c8d16335 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 13:50:26 +0000 Subject: [PATCH 39/65] docs(gap-baseline): fix malformed cross-repo issue reference format Devin Review correctly flagged .github#1500-style references as neither a valid bare #1500 nor fully-qualified ContextualWisdomLab/.github#1500 reference. Normalized to the doc's own established convention (full owner/repo#num on first mention, bare #num afterward). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01KPmJErfkcHer4UVEgrQxUX --- docs/product-technical-gap-baseline.md | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 778bfdb0d3..ed606686bf 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1717,8 +1717,9 @@ signature as the original round-4 bug) before passing after the fix. 1930 tests ## 2026-08-31 opencode-review structural deadlock: unresolved threads starve the dispatch that would resolve them -**Root cause found while investigating why `.github#1500`'s `opencode-review` required check exhausted -its full 90-minute active-dispatch-and-poll window (added by #1497) with no `opencode-agent` verdict.** +**Root cause found while investigating why `ContextualWisdomLab/.github#1500`'s `opencode-review` +required check exhausted its full 90-minute active-dispatch-and-poll window (added by #1497) with no +`opencode-agent` verdict.** `pr_review_merge_scheduler.py`'s `decide()` has an unconditional early return (`scripts/ci/pr_review_merge_scheduler.py:3462-3474`): `if unresolved_thread_count(pr): return decide("block", ...)`, before any code path can reach `dispatch_opencode_review()`. That gate long @@ -1753,17 +1754,17 @@ separate predicate for review-dispatch eligibility distinct from merge eligibili coverage across informational-only, actionable-bot, and human-reviewer-`CHANGES_REQUESTED` thread shapes, before any PR is unblocked by classification logic rather than manual resolution. -**Two related, smaller gaps found and stood down on in `.github#1415`'s own review, tracked here rather -than dropped:** -- *Streaming responses can defeat a bare socket timeout.* Both `.github#1415`'s preflight ModelClient - (`REVIEW_PREFLIGHT_TIMEOUT_SECONDS`) and — independently found on `.github#1509` — `noema_review_gate.py`'s +**Two related, smaller gaps found and stood down on in `ContextualWisdomLab/.github#1415`'s own review, +tracked here rather than dropped:** +- *Streaming responses can defeat a bare socket timeout.* Both `#1415`'s preflight ModelClient + (`REVIEW_PREFLIGHT_TIMEOUT_SECONDS`) and — independently found on `ContextualWisdomLab/.github#1509` — `noema_review_gate.py`'s `call_llm` bound each HTTP attempt with a plain per-operation socket `timeout=`, which only bounds time *between* reads, not total attempt duration: a provider trickling data slowly enough (each chunk just under the timeout) could keep one attempt alive far past its nominal budget. `#1509` already built a real deadline-watchdog wrapper (arm a timer, forcibly close the connection past total budget) for the serving side; the preflight side needs the same treatment once that mechanism lands somewhere mergeable, rather than a second, possibly-inconsistent implementation. -- *Discovery time is unbounded in catalog size.* `.github#1415`'s +- *Discovery time is unbounded in catalog size.* `#1415`'s `REVIEW_DISCOVERY_OPENROUTER_FREE_ENDPOINT_ROUND_CAP` assumes a bounded number of pagination rounds; if OpenRouter's free-model catalog grows past that assumption, discovery can exceed `REVIEW_STARTUP_WATCHDOG_SECONDS` and abort an otherwise-healthy sidecar. Needs either a deadline-based From 961f37921993a6adc18373273fbc845f4e5d3e71 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 15:44:38 +0000 Subject: [PATCH 40/65] docs(gap-baseline): record required-check dispatch starvation pattern noema-review (#1415), opencode-review (#1500/#1502/#1503), and strix (#1503) all independently timed out today with the identical shape: a required check dispatches a repository_dispatch run against main, then polls for evidence; the dispatched run sat queued (never picked up by a runner) for well over an hour, so the poller gave up and reported failure. Documented as an infrastructure capacity question, not a per-PR code defect. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01KPmJErfkcHer4UVEgrQxUX --- docs/product-technical-gap-baseline.md | 35 ++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index ed606686bf..1c3b6784ac 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1773,6 +1773,41 @@ tracked here rather than dropped:** in either case, not a constant tweak, and this exact family of finding has already been through several rounds of patch → new finding on `#1415` without converging. +## 2026-08-31 (later) required-check dispatch runs starved by GitHub Actions runner-queue contention + +**Observed independently on three different required checks across three different PRs within the same +window, all with the identical shape:** a required check dispatches a `repository_dispatch` run against +`main` to produce evidence, then polls for that evidence within a bounded window; the dispatched run +itself sits in GitHub's own `queued` state for well over an hour without a runner ever picking it up, so +the polling step times out and reports failure — not because anything in the reviewed diff is wrong, but +because the evidence-producing run never got to execute at all. + +- `noema-review` on `ContextualWisdomLab/.github#1415` (job `99521003275`): failed with the exact + pre-existing `contextual-orchestrator#946` `TimeoutError` this repo's own fix targets — confirmed via + the traceback showing `noema_review_gate.py:656`'s *unfixed* `timeout=120` literal, proving this ran + `main`'s trusted copy of the script (the `pull_request_target` trust boundary), not the PR's own fix. + Separate from the congestion finding below, but interacts with it: even after the fix reaches `main`, + a dispatch-model required check is still exposed to this same starvation class. +- `opencode-review` on `#1500`/`#1502`/`#1503` (see the structural-deadlock entry above): the + `pr-review-merge-scheduler.yml`-triggered dispatch chain (`coverage-source-tree` → `coverage-evidence` + → `opencode-review-target`) sat queued for over an hour with zero progress. +- `strix` on `#1503` (run `33400353198`, `repository_dispatch` against `main`): created `14:02:56Z`, + still `queued` with `run_started_at == created_at` (never picked up by a runner) when the "strix" + commit status finally posted `failure` (`"Default-branch repository_dispatch Strix evidence failed"`) + at `15:39:43Z` — 96 minutes of pure queue time, zero execution time. + +**Not fixed in this pass — this is an infrastructure capacity/scheduling question, not a code defect any +one PR's diff can address.** All three central required checks (`noema-review`, `opencode-review`, +`strix`) share the same async dispatch-and-poll architecture (`docs/pr-review-and-merge-procedure.md`), +so all three inherit the same exposure to GitHub Actions concurrent-job-limit contention when the org's +overall Actions usage spikes (plausibly from this same autonomous loop running many concurrent sessions +across many repos and PRs). **Next development increment**: quantify the org's actual concurrent-runner +ceiling against typical in-flight job count at peak (via the Actions usage API), and evaluate whether a +dedicated larger runner pool, a queuing/backpressure mechanism in the dispatch step itself (fail fast +with a clear "queue congested" status instead of waiting the full poll window then reporting an opaque +failure), or self-hosted runners for the dispatch-target jobs specifically would relieve it — a +budget/infrastructure decision, not something to guess at in a single PR's scope. + ## 5. 실행 루프와 고객의 다음 행동 각 hourly pass는 아래 순서를 유지한다. From 9c150a29144de20d682e3cc7a40e6b7040a4c0b9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 15:51:23 +0000 Subject: [PATCH 41/65] docs(gap-baseline): correct conflated failure-mode claim per Devin review Devin Review correctly flagged that noema-review's failure (job ran, hit the actual pre-existing #946 timeout bug) was wrongly grouped with opencode-review's and strix's genuine queue-starvation (dispatched run never got a runner at all). Split into two distinct, clearly labeled failure modes rather than one unsupported shared cause. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01KPmJErfkcHer4UVEgrQxUX --- docs/product-technical-gap-baseline.md | 55 +++++++++++++++----------- 1 file changed, 31 insertions(+), 24 deletions(-) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 1c3b6784ac..5153e85ebe 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1773,21 +1773,23 @@ tracked here rather than dropped:** in either case, not a constant tweak, and this exact family of finding has already been through several rounds of patch → new finding on `#1415` without converging. -## 2026-08-31 (later) required-check dispatch runs starved by GitHub Actions runner-queue contention - -**Observed independently on three different required checks across three different PRs within the same -window, all with the identical shape:** a required check dispatches a `repository_dispatch` run against -`main` to produce evidence, then polls for that evidence within a bounded window; the dispatched run -itself sits in GitHub's own `queued` state for well over an hour without a runner ever picking it up, so -the polling step times out and reports failure — not because anything in the reviewed diff is wrong, but -because the evidence-producing run never got to execute at all. - -- `noema-review` on `ContextualWisdomLab/.github#1415` (job `99521003275`): failed with the exact - pre-existing `contextual-orchestrator#946` `TimeoutError` this repo's own fix targets — confirmed via - the traceback showing `noema_review_gate.py:656`'s *unfixed* `timeout=120` literal, proving this ran - `main`'s trusted copy of the script (the `pull_request_target` trust boundary), not the PR's own fix. - Separate from the congestion finding below, but interacts with it: even after the fix reaches `main`, - a dispatch-model required check is still exposed to this same starvation class. +## 2026-08-31 (later) two distinct required-check failure modes, initially conflated + +**Correction (Devin Review on `#1415`): an earlier version of this entry grouped `noema-review`'s +failure together with `opencode-review`'s and `strix`'s under one shared "runner-queue contention" +cause. That was wrong for the `noema-review` case — its job actually ran; it never sat queued waiting +for a runner. The two failure modes are distinct and should not be conflated:** + +**Mode 1 — the job executes but hits a real timeout bug (not a queue problem).** +`noema-review` on `ContextualWisdomLab/.github#1415` (job `99521003275`) got a runner, started, and its +`python3 -m scripts.ci.noema_review_gate` step ran for two minutes before failing — the traceback shows +it reached `noema_review_gate.py:656`'s `opener.open(request, timeout=120)` and got a real +`TimeoutError` from an in-flight HTTP call. This is the exact pre-existing `contextual-orchestrator#946` +bug `#1415`'s own fix targets, confirmed by the `timeout=120` literal being the *unfixed* value — proof +this ran `main`'s trusted copy of the script (the `pull_request_target` trust boundary), not the PR's +own fix. This has nothing to do with runner availability. + +**Mode 2 — the dispatched run never gets a runner at all (genuine queue starvation).** - `opencode-review` on `#1500`/`#1502`/`#1503` (see the structural-deadlock entry above): the `pr-review-merge-scheduler.yml`-triggered dispatch chain (`coverage-source-tree` → `coverage-evidence` → `opencode-review-target`) sat queued for over an hour with zero progress. @@ -1796,15 +1798,20 @@ because the evidence-producing run never got to execute at all. commit status finally posted `failure` (`"Default-branch repository_dispatch Strix evidence failed"`) at `15:39:43Z` — 96 minutes of pure queue time, zero execution time. -**Not fixed in this pass — this is an infrastructure capacity/scheduling question, not a code defect any -one PR's diff can address.** All three central required checks (`noema-review`, `opencode-review`, -`strix`) share the same async dispatch-and-poll architecture (`docs/pr-review-and-merge-procedure.md`), -so all three inherit the same exposure to GitHub Actions concurrent-job-limit contention when the org's -overall Actions usage spikes (plausibly from this same autonomous loop running many concurrent sessions -across many repos and PRs). **Next development increment**: quantify the org's actual concurrent-runner -ceiling against typical in-flight job count at peak (via the Actions usage API), and evaluate whether a -dedicated larger runner pool, a queuing/backpressure mechanism in the dispatch step itself (fail fast -with a clear "queue congested" status instead of waiting the full poll window then reporting an opaque +The two modes *do* interact once `#1415` merges: `noema-review`'s own dispatch-and-poll architecture +(`docs/pr-review-and-merge-procedure.md`) is the same shape `opencode-review`'s and `strix`'s use, so a +future `noema-review` run could independently suffer Mode 2 even after Mode 1 (the timeout-value bug) +is fixed. But that is a shared *exposure*, not a shared *observed cause* for these three specific +failures — only `opencode-review` and `strix` actually exhibited Mode 2 today. + +**Not fixed in this pass for Mode 2 — that is an infrastructure capacity/scheduling question, not a code +defect any one PR's diff can address.** `opencode-review` and `strix` inherit the same exposure to +GitHub Actions concurrent-job-limit contention when the org's overall Actions usage spikes (plausibly +from this same autonomous loop running many concurrent sessions across many repos and PRs). **Next +development increment**: quantify the org's actual concurrent-runner ceiling against typical in-flight +job count at peak (via the Actions usage API), and evaluate whether a dedicated larger runner pool, a +queuing/backpressure mechanism in the dispatch step itself (fail fast with a clear "queue congested" +status instead of waiting the full poll window then reporting an opaque failure), or self-hosted runners for the dispatch-target jobs specifically would relieve it — a budget/infrastructure decision, not something to guess at in a single PR's scope. From bf729bdcbab51f9f7b0e824d41680c63e5a16f15 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 17:03:34 +0000 Subject: [PATCH 42/65] docs(nvidia-nim): mark the OpenCode NIM-priority hotfix note rolled back opencode-review-dispatch.yml's OPENCODE_MODEL_CANDIDATES has held the single value "contextual-orchestrator/orchestrator/free" since f8823a54 (#1364), with no nvidia-nim/* prefixes anywhere in the workflow or its embedded opencode.jsonc. docs/nvidia-nim-opencode-hotfix.md still described the six-model NIM-prefix hotfix as active and was never updated per its own "delete this note once restored" instruction, leaving it factually stale for over a month. Marked the note historical rather than deleting it, per this repo's "append a dated note, don't rewrite history" convention, and left the dormant nvidia-nim provider block in opencode.jsonc and its tested fallback path in run_opencode_review_model_pool.sh untouched -- those are a deliberate, still-exercised resilience capability (tests/test_opencode_model_pool_runner.py), not orphaned code, and removing them is a separate resilience-tradeoff decision. This closes out the "worth a follow-up doc cleanup" item recorded in docs/product-technical-gap-baseline.md's 2026-08-31 direct-NIM audit. No code changed. Full suite: 2148 passed, 1 skipped, 21 subtests; coverage 100%; interrogate 100%. --- CHANGELOG.md | 8 ++++++++ docs/nvidia-nim-opencode-hotfix.md | 30 ++++++++++++++++++++++++++++-- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a53079aa8..b76b9b7b6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ this file. The format follows Keep a Changelog, and versioned releases follow Semantic Versioning where the repository publishes a release. ## [Unreleased] +- Mark `docs/nvidia-nim-opencode-hotfix.md` rolled back and historical: the + six-model NIM prefix it described was removed from + `opencode-review-dispatch.yml`'s `OPENCODE_MODEL_CANDIDATES` by `f8823a54` + (#1364) over a month ago, but the note itself was never updated per its own + "delete this note once restored" instruction. No code changed; this closes + out the "worth a follow-up doc cleanup" item recorded in + `docs/product-technical-gap-baseline.md`'s 2026-08-31 direct-NIM-communication + audit entry. - Fix the root cause of `noema-review`'s four consecutive `TimeoutError` failures on `contextual-orchestrator#946` (enumerated in `contextual-orchestrator#974`), then correct that fix per Devin's follow-up diff --git a/docs/nvidia-nim-opencode-hotfix.md b/docs/nvidia-nim-opencode-hotfix.md index df8c193b28..46939fe3b9 100644 --- a/docs/nvidia-nim-opencode-hotfix.md +++ b/docs/nvidia-nim-opencode-hotfix.md @@ -1,6 +1,32 @@ -# NVIDIA NIM OpenCode model priority (hotfix) +# NVIDIA NIM OpenCode model priority (hotfix) — ROLLED BACK, HISTORICAL -## Why +**Status (2026-08-31): this hotfix is no longer active.** The six-model NIM +prefix this note describes was removed from +`.github/workflows/opencode-review-dispatch.yml`'s `OPENCODE_MODEL_CANDIDATES` +by `f8823a54` (#1364, "route Noema review through vendored +contextual-orchestrator"); that variable has held the single value +`"contextual-orchestrator/orchestrator/free"` (contract-pinned by +`tests/test_opencode_agent_contract.py`) ever since, and `opencode.jsonc`'s +embedded config for the CI dispatch path likewise renders +`enabled_providers: ["contextual-orchestrator"]` with no NIM entry. Per this +note's own "Rollback" section below, it should have been deleted once +catalog reliability was restored; it was not, and stayed factually stale for +over a month (last touched at `c7a4bad6`, #682, 2026-07-31) before this +correction. Left in place as a historical record rather than deleted, per +this repo's "append a dated note, don't rewrite history" documentation +convention (see `docs/doctoring/direct-nvidia-nim-communication-removal.md` +for the sibling record of the *code* that implemented an unrelated, +already-dead direct-NIM resolver). The `nvidia-nim` provider block still +declared in root `opencode.jsonc` (unused by the CI dispatch path, which +generates its own provider list) is a deliberate, still-tested fallback +capability for `scripts/ci/run_opencode_review_model_pool.sh` +(`is_nvidia_nim_candidate`, exercised by `tests/test_opencode_model_pool_runner.py`), +not orphaned code — removing it is a separate resilience-tradeoff decision, +not a documentation fix, and is out of scope here. See +`docs/product-technical-gap-baseline.md`'s "Direct-NIM-communication audit" +entry (2026-08-31) for the full investigation this correction closes out. + +## Why (historical — describes the hotfix as it was, not current state) OpenCode Agent failed to produce a usable review on the PR thread starting at ContextualWisdomLab/fast-mlsirm#290 (`opencode-review` check **skipped**, no From 41ce1e94d3ef4e25a01e5e4fb6b5b5e5ea58d691 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 17:43:19 +0000 Subject: [PATCH 43/65] docs(nvidia-nim): correct two accuracy findings on the hotfix-rollback note Devin Review on #1415 caught two real errors in bf729bdc: 1. CHANGELOG.md attached "over a month ago" to f8823a54 itself (2026-08-27, only 4 days before this entry), not to the doc's own staleness window (last touched 2026-07-31, which IS about a month). Corrected to state each date explicitly instead of a single ambiguous relative phrase. 2. The doc called the dormant nvidia-nim opencode.jsonc provider block "a deliberate, still-tested fallback capability" -- overstated, since every enabled_providers list this repo renders excludes it and the cited run_opencode_review_model_pool.sh tests fake the opencode invocation itself, proving only the script's own candidate-handling logic, not that the real OpenCode binary would still reach NVIDIA's API with this block's current model ids. Reworded to state precisely what is and is not verified. No code changed. Full suite: 2148 passed, 1 skipped, 21 subtests. --- CHANGELOG.md | 11 ++++++----- docs/nvidia-nim-opencode-hotfix.md | 19 +++++++++++++------ 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b76b9b7b6f..acd94f56b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,11 +8,12 @@ Semantic Versioning where the repository publishes a release. - Mark `docs/nvidia-nim-opencode-hotfix.md` rolled back and historical: the six-model NIM prefix it described was removed from `opencode-review-dispatch.yml`'s `OPENCODE_MODEL_CANDIDATES` by `f8823a54` - (#1364) over a month ago, but the note itself was never updated per its own - "delete this note once restored" instruction. No code changed; this closes - out the "worth a follow-up doc cleanup" item recorded in - `docs/product-technical-gap-baseline.md`'s 2026-08-31 direct-NIM-communication - audit entry. + (#1364, 2026-08-27), but the note itself was never updated per its own + "delete this note once restored" instruction and stayed factually stale + for about a month (last touched 2026-07-31, per #682) until this + correction. No code changed; this closes out the "worth a follow-up doc + cleanup" item recorded in `docs/product-technical-gap-baseline.md`'s + 2026-08-31 direct-NIM-communication audit entry. - Fix the root cause of `noema-review`'s four consecutive `TimeoutError` failures on `contextual-orchestrator#946` (enumerated in `contextual-orchestrator#974`), then correct that fix per Devin's follow-up diff --git a/docs/nvidia-nim-opencode-hotfix.md b/docs/nvidia-nim-opencode-hotfix.md index 46939fe3b9..dee7604a67 100644 --- a/docs/nvidia-nim-opencode-hotfix.md +++ b/docs/nvidia-nim-opencode-hotfix.md @@ -17,12 +17,19 @@ this repo's "append a dated note, don't rewrite history" documentation convention (see `docs/doctoring/direct-nvidia-nim-communication-removal.md` for the sibling record of the *code* that implemented an unrelated, already-dead direct-NIM resolver). The `nvidia-nim` provider block still -declared in root `opencode.jsonc` (unused by the CI dispatch path, which -generates its own provider list) is a deliberate, still-tested fallback -capability for `scripts/ci/run_opencode_review_model_pool.sh` -(`is_nvidia_nim_candidate`, exercised by `tests/test_opencode_model_pool_runner.py`), -not orphaned code — removing it is a separate resilience-tradeoff decision, -not a documentation fix, and is out of scope here. See +declared in root `opencode.jsonc` is excluded from every `enabled_providers` +list this repo currently renders (both the root config and the CI dispatch +path's own embedded config), so it is not a currently usable fallback -- +nothing in production ever supplies a `nvidia-nim/*` candidate today. +`scripts/ci/run_opencode_review_model_pool.sh`'s own candidate-handling logic +for that prefix (`is_nvidia_nim_candidate`, skip-if-no-key, timeout capping) +is exercised by `tests/test_opencode_model_pool_runner.py`, but those tests +fake the `opencode` invocation itself, so they prove the script's own +handling of such a candidate, not that the real OpenCode binary would still +successfully reach NVIDIA's API with this block's current model ids if one +were ever supplied. Not orphaned code -- re-enabling and re-verifying it, or +removing it outright, is a separate resilience-tradeoff decision, not a +documentation fix, and is out of scope here. See `docs/product-technical-gap-baseline.md`'s "Direct-NIM-communication audit" entry (2026-08-31) for the full investigation this correction closes out. From 2e31dce2aae9aaa73309533a96151f010c59e1b0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 05:47:15 +0900 Subject: [PATCH 44/65] fix(review): remove serving 120-second timeout --- ...ntextual-orchestrator-vendored-free-zdr.md | 2 +- ...ontextual-orchestrator-vendored-sidecar.md | 2 +- ...contextual_orchestrator_review_launcher.py | 67 ++----------------- scripts/ci/noema_review_gate.py | 61 +---------------- ...l_orchestrator_review_runtime_preflight.py | 4 +- 5 files changed, 14 insertions(+), 122 deletions(-) diff --git a/docs/adr/0003-contextual-orchestrator-vendored-free-zdr.md b/docs/adr/0003-contextual-orchestrator-vendored-free-zdr.md index 5fd54c89fd..7bfdd2efc5 100644 --- a/docs/adr/0003-contextual-orchestrator-vendored-free-zdr.md +++ b/docs/adr/0003-contextual-orchestrator-vendored-free-zdr.md @@ -143,7 +143,7 @@ all five, and auto-optimize routing by cost. - **Separate startup and serving budgets (2026-08-30):** route admission keeps the ten-second timeout so unavailable providers cannot delay healthz, while - the serving `ModelClient` uses the Noema gate's 120-second transport budget. + the serving `ModelClient` uses the Noema gate's 9,600-second review budget. Both phases keep zero retries and the same bounded request policy; the launcher test verifies the two constructed client configurations separately. diff --git a/docs/doctoring/contextual-orchestrator-vendored-sidecar.md b/docs/doctoring/contextual-orchestrator-vendored-sidecar.md index e613639112..327973e83f 100644 --- a/docs/doctoring/contextual-orchestrator-vendored-sidecar.md +++ b/docs/doctoring/contextual-orchestrator-vendored-sidecar.md @@ -84,7 +84,7 @@ independent family-cap default. The ten-second route timeout is a startup-admission budget: a route that does not answer the bounded readiness probe quickly enough is excluded before healthz. It must not also bound the real review request. The serving -`ModelClient` now uses the 120-second transport budget already used by the +`ModelClient` now uses the 9,600-second review budget used by the Noema review gate, while retaining zero retries and the same output-token and temperature policy. The launcher test constructs both client policies and asserts their distinct timeouts; it does not infer the contract from duplicate diff --git a/scripts/ci/contextual_orchestrator_review_launcher.py b/scripts/ci/contextual_orchestrator_review_launcher.py index f2d3f26a65..18247bd03d 100644 --- a/scripts/ci/contextual_orchestrator_review_launcher.py +++ b/scripts/ci/contextual_orchestrator_review_launcher.py @@ -47,69 +47,16 @@ # still finite catalog while keeping the worst-case provider wait below the # sidecar's three-minute readiness deadline. REVIEW_PREFLIGHT_TIMEOUT_SECONDS = 10 -# The serving call has the same 120-second transport budget as the Noema review -# gate; startup admission stays short so an unavailable route cannot delay healthz. -REVIEW_SERVING_TIMEOUT_SECONDS = 120 +# A real review may legitimately run far beyond two minutes. Keep the short +# timeout confined to startup admission; the outer Noema request/job deadline +# remains the serving safety boundary. +REVIEW_SERVING_TIMEOUT_SECONDS = 9600 REVIEW_PREFLIGHT_BATCH_SIZE = 4 REVIEW_PREFLIGHT_MAX_TOTAL_ROUTES = 24 REVIEW_PREFLIGHT_PRIMARY_ROUTE_LIMIT = 8 -# FIXED (ContextualWisdomLab/.github#1415, Devin Review "Valid reviews exceed -# job deadline"): the serving TaskOrchestrator previously received the FULL -# preflight-admitted pool (up to REVIEW_PREFLIGHT_MAX_TOTAL_ROUTES=24), and -# noema_review_gate.py's CALL_LLM_TIMEOUT_SECONDS was sized to that pool's -# honest worst case (23040s = 384 minutes) -- which already exceeds -# noema-review.yml's own explicit `timeout-minutes: 360` (21600s) job -# ceiling for a SINGLE call, before even considering that call_llm can issue -# a second, one-shot verdict-repair call in the same job. A client-side -# timeout the enclosing job can never actually honor is not a safety margin, -# it is a false promise. Rather than shrink the promised worst case below -# what a real multi-candidate, judge-gated failover can legitimately need -# (reintroducing contextual-orchestrator#946's original bug), this caps how -# many preflight-verified-ready candidates the SERVING orchestrator draws -# from -- a separate, smaller number than preflight's own admission-testing -# depth above, which exists to find *some* ready route, not to bound serving -# wall-clock. -# -# Solved backwards from the job's own ceiling: noema-review.yml's -# `timeout-minutes: 360` (21600s) minus this job's other, non-serving steps -# (materialize the trusted archive, credential/token resolution, visibility -# lookup, and this file's own REVIEW_STARTUP_WATCHDOG_SECONDS-bounded sidecar -# provisioning -- generously rounded to 900s/15min of headroom) leaves 20700s -# for the "Run Noema LLM review" step. That step can invoke call_llm up to -# twice (the original request plus one one-shot verdict-repair retry; -# see noema_review_gate.py's call_llm), so each call's own worst case must -# independently fit in half of that, 10350s, for the pair to always fit. -# One route_once() call's orchestrator-level worst case is -# outer_route_once_attempts(2, from route_once's own -# `max_attempts = 1 + min(tool_retry_attempts=1, MAX_TOOL_RETRY_ATTEMPTS=4)`) -# x roles(2: a worker _invoke() call, then an independent judge _invoke() -# call via _realtime_route_judge/_model_judge_verification) x -# per-agent-attempts(2, the same `1 + min(1,4)` expression bounding -# _invoke's own same-agent retry) x REVIEW_SERVING_TIMEOUT_SECONDS(120) x -# this candidate cap: 2 x 2 x 2 x 120 x N = 960N seconds. Solving -# 960N <= 10350 gives N <= 10.78, so N=10 -- see -# CALL_LLM_TIMEOUT_SECONDS in noema_review_gate.py for the resulting exact -# 960 x 10 = 9600s per-call value this produces. Every one of these 10 -# candidates has already independently PASSED preflight's own base probe and -# serving-budget confirmation (see REVIEW_PREFLIGHT_MAX_ESCALATIONS above), -# so this is a reduction in serving-time failover depth among -# already-proven-healthy routes, not a reduction in how thoroughly preflight -# searches for a usable one. -# -# NOTE (Devin Review, "Serving cap exceeds reachable pool"): today, -# `_preflight_review_agent_batches` returns on the FIRST batch with any -# viable candidate (`if viable: return viable, {...}` below), so `agents` -# here is never larger than REVIEW_PREFLIGHT_BATCH_SIZE=4 in practice -- -# this cap of 10 does not currently bind. That is deliberate, not an -# oversight: this cap and CALL_LLM_TIMEOUT_SECONDS are a job-deadline safety -# ceiling derived independently of preflight's own batching strategy, not a -# number chosen to match it. Coupling them would mean any future change to -# preflight's early-return behavior (e.g. accumulating viable candidates -# across batches instead of stopping at the first) could silently regrow the -# real worst case past what the job's 360-minute ceiling allows again, -# exactly the bug this constant exists to prevent. Sizing this cap from the -# job's own budget, independent of today's incidental batch size, means it -# stays correct even if that batching strategy changes. +# Bound the already-admitted serving catalog so immediate-error failover work +# cannot grow with discovery. The outer Noema request and workflow job remain +# the wall-clock safety boundaries. REVIEW_SERVING_MAX_CANDIDATES = 10 # ADR-0005: a single fixed max_tokens cannot fit every model in a heterogeneous # pool -- some spend internal reasoning tokens before visible content and need diff --git a/scripts/ci/noema_review_gate.py b/scripts/ci/noema_review_gate.py index 703823bb7b..8eb36fd2c0 100644 --- a/scripts/ci/noema_review_gate.py +++ b/scripts/ci/noema_review_gate.py @@ -32,64 +32,9 @@ MAX_FILE_CONTEXT_CHARS = 4000 MAX_REVIEW_CONTEXT_CHARS = 24000 MAX_THREAD_BODY_CHARS = 1200 -# Enumerated, not guessed, against the sidecar's *unmodified* TaskOrchestrator -# defaults (ContextualWisdomLab/.github#1415, Devin Review "Serving answers -# bypass quality validation" -- an earlier version of this constant was sized -# against a serving config that had disabled tool_retry_attempts and -# policy.realtime_judge to shave latency, which also silently broke -# route_once's per-request quality gate and judge-rejection failover; see -# scripts/ci/contextual_orchestrator_review_launcher.py's orchestrator -# construction comment for that correction). -# -# FIXED (ContextualWisdomLab/.github#1415, Devin Review "Valid reviews exceed -# job deadline"): a next-earlier version of this constant (23040s = 384 -# minutes) was derived against the FULL preflight-admitted candidate pool -# (REVIEW_PREFLIGHT_MAX_TOTAL_ROUTES=24) and already exceeded -# noema-review.yml's own `timeout-minutes: 360` (21600s) job ceiling for a -# SINGLE call -- a client-side timeout the enclosing job could never actually -# honor is not a safety margin, it is a false promise. Rather than shrink -# this constant below what a real multi-candidate, judge-gated failover can -# legitimately need (reintroducing contextual-orchestrator#946's original -# bug), scripts/ci/contextual_orchestrator_review_launcher.py now caps how -# many preflight-verified-ready candidates the SERVING orchestrator draws -# from to REVIEW_SERVING_MAX_CANDIDATES=10 (see that constant's own comment -# for the full backward derivation from the job's 360-minute ceiling) instead -# of the full 24-route preflight-admission pool. With that cap and full -# TaskOrchestrator defaults (tool_retry_attempts=1, realtime_judge=True): -# - One _invoke() call (worker OR judge) tries up to -# REVIEW_SERVING_MAX_CANDIDATES=10 candidates (contextual_orchestrator -# _invoke's own _failover_candidates has no smaller bound of its own), each -# up to 1 + min(tool_retry_attempts=1, MAX_TOOL_RETRY_ATTEMPTS=4) = 2 -# attempts, each bounded by REVIEW_SERVING_TIMEOUT_SECONDS=120s (same -# file): 10 x 2 x 120 = 2400s worst case. -# - route_once() issues that worker _invoke() call, then (realtime_judge -# defaulting True) an independent judge _invoke() call of the same shape -# via _model_judge_verification/_FastMLSIJudgeAdapter.complete(): another -# 2400s worst case. One outer route_once attempt: 2400 + 2400 = 4800s. -# - route_once's own outer cross-candidate loop retries a fresh top-level -# candidate on judge rejection, up to -# max_attempts = 1 + min(tool_retry_attempts=1, MAX_TOOL_RETRY_ATTEMPTS=4) -# = 2 total candidates: 2 x 4800 = 9600s worst case for one full -# route_once() call (one /v1/chat/completions request this function -# sends). -# This client-side read timeout is sized to that 9600s per-call worst case so -# a legitimate multi-candidate, judge-gated failover is never mistaken for a -# hang -- see contextual-orchestrator#946's four consecutive TimeoutError -# failures and contextual-orchestrator#974's worst-case enumeration, which -# first identified this class of mismatch (there against the previous, -# un-tuned 120s default). -# -# call_llm() itself can still issue a second such call (the one-shot verdict -# repair retry below, guarded by `repair_error` against further recursion), -# for an absolute worst case of 2 x 9600 = 19200s across the whole function. -# noema-review.yml's `timeout-minutes: 360` (21600s) job leaves that full -# 19200s pair comfortable, generously-rounded headroom (900s/15min) for the -# job's other, non-serving steps (materialize the trusted archive, -# credential/token resolution, visibility lookup, and this repo's own -# REVIEW_STARTUP_WATCHDOG_SECONDS-bounded sidecar provisioning) -- unlike the -# prior 23040s constant, this one's absolute worst case now actually fits -# inside the job that enforces it, with margin, instead of relying on that -# job's own kill as an unacknowledged backstop. +# A real judge-gated review may legitimately exceed two minutes. This outer +# request deadline is shared with the serving transport; the workflow job is +# the final safety boundary. CALL_LLM_TIMEOUT_SECONDS = 9600 DIFF_HUNK_RE = re.compile(r"^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@") diff --git a/tests/test_contextual_orchestrator_review_runtime_preflight.py b/tests/test_contextual_orchestrator_review_runtime_preflight.py index 2fdaf76ae4..8af6b358ae 100644 --- a/tests/test_contextual_orchestrator_review_runtime_preflight.py +++ b/tests/test_contextual_orchestrator_review_runtime_preflight.py @@ -2488,7 +2488,7 @@ def __init__(self, **kwargs: object) -> None: preflight, serving = CaptureClient.instances assert preflight["timeout"] == 10 - assert serving["timeout"] == 120 + assert serving["timeout"] == 9600 assert preflight["timeout"] != serving["timeout"] assert preflight["max_output_tokens"] == serving["max_output_tokens"] == 4096 assert preflight["max_retries"] == serving["max_retries"] == 0 @@ -2503,7 +2503,7 @@ def test_sidecar_preserves_diagnostics_and_probes_the_real_gateway() -> None: assert "_preflight_with_fallback(" in launcher assert "preflight-out" in launcher assert "max_output_tokens=REVIEW_MAX_OUTPUT_TOKENS" in launcher - assert "REVIEW_SERVING_TIMEOUT_SECONDS = 120" in launcher + assert "REVIEW_SERVING_TIMEOUT_SECONDS = 9600" in launcher assert "timeout=REVIEW_PREFLIGHT_TIMEOUT_SECONDS" in launcher assert "timeout=REVIEW_SERVING_TIMEOUT_SECONDS" in launcher assert launcher.count("max_retries=0") == 1 From ad6243913863f294056337f11649aa31fa762dee Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 05:51:09 +0900 Subject: [PATCH 45/65] Revert "fix(review): remove serving 120-second timeout" This reverts commit 2e31dce2aae9aaa73309533a96151f010c59e1b0. --- ...ntextual-orchestrator-vendored-free-zdr.md | 2 +- ...ontextual-orchestrator-vendored-sidecar.md | 2 +- ...contextual_orchestrator_review_launcher.py | 67 +++++++++++++++++-- scripts/ci/noema_review_gate.py | 61 ++++++++++++++++- ...l_orchestrator_review_runtime_preflight.py | 4 +- 5 files changed, 122 insertions(+), 14 deletions(-) diff --git a/docs/adr/0003-contextual-orchestrator-vendored-free-zdr.md b/docs/adr/0003-contextual-orchestrator-vendored-free-zdr.md index 7bfdd2efc5..5fd54c89fd 100644 --- a/docs/adr/0003-contextual-orchestrator-vendored-free-zdr.md +++ b/docs/adr/0003-contextual-orchestrator-vendored-free-zdr.md @@ -143,7 +143,7 @@ all five, and auto-optimize routing by cost. - **Separate startup and serving budgets (2026-08-30):** route admission keeps the ten-second timeout so unavailable providers cannot delay healthz, while - the serving `ModelClient` uses the Noema gate's 9,600-second review budget. + the serving `ModelClient` uses the Noema gate's 120-second transport budget. Both phases keep zero retries and the same bounded request policy; the launcher test verifies the two constructed client configurations separately. diff --git a/docs/doctoring/contextual-orchestrator-vendored-sidecar.md b/docs/doctoring/contextual-orchestrator-vendored-sidecar.md index 327973e83f..e613639112 100644 --- a/docs/doctoring/contextual-orchestrator-vendored-sidecar.md +++ b/docs/doctoring/contextual-orchestrator-vendored-sidecar.md @@ -84,7 +84,7 @@ independent family-cap default. The ten-second route timeout is a startup-admission budget: a route that does not answer the bounded readiness probe quickly enough is excluded before healthz. It must not also bound the real review request. The serving -`ModelClient` now uses the 9,600-second review budget used by the +`ModelClient` now uses the 120-second transport budget already used by the Noema review gate, while retaining zero retries and the same output-token and temperature policy. The launcher test constructs both client policies and asserts their distinct timeouts; it does not infer the contract from duplicate diff --git a/scripts/ci/contextual_orchestrator_review_launcher.py b/scripts/ci/contextual_orchestrator_review_launcher.py index 18247bd03d..f2d3f26a65 100644 --- a/scripts/ci/contextual_orchestrator_review_launcher.py +++ b/scripts/ci/contextual_orchestrator_review_launcher.py @@ -47,16 +47,69 @@ # still finite catalog while keeping the worst-case provider wait below the # sidecar's three-minute readiness deadline. REVIEW_PREFLIGHT_TIMEOUT_SECONDS = 10 -# A real review may legitimately run far beyond two minutes. Keep the short -# timeout confined to startup admission; the outer Noema request/job deadline -# remains the serving safety boundary. -REVIEW_SERVING_TIMEOUT_SECONDS = 9600 +# The serving call has the same 120-second transport budget as the Noema review +# gate; startup admission stays short so an unavailable route cannot delay healthz. +REVIEW_SERVING_TIMEOUT_SECONDS = 120 REVIEW_PREFLIGHT_BATCH_SIZE = 4 REVIEW_PREFLIGHT_MAX_TOTAL_ROUTES = 24 REVIEW_PREFLIGHT_PRIMARY_ROUTE_LIMIT = 8 -# Bound the already-admitted serving catalog so immediate-error failover work -# cannot grow with discovery. The outer Noema request and workflow job remain -# the wall-clock safety boundaries. +# FIXED (ContextualWisdomLab/.github#1415, Devin Review "Valid reviews exceed +# job deadline"): the serving TaskOrchestrator previously received the FULL +# preflight-admitted pool (up to REVIEW_PREFLIGHT_MAX_TOTAL_ROUTES=24), and +# noema_review_gate.py's CALL_LLM_TIMEOUT_SECONDS was sized to that pool's +# honest worst case (23040s = 384 minutes) -- which already exceeds +# noema-review.yml's own explicit `timeout-minutes: 360` (21600s) job +# ceiling for a SINGLE call, before even considering that call_llm can issue +# a second, one-shot verdict-repair call in the same job. A client-side +# timeout the enclosing job can never actually honor is not a safety margin, +# it is a false promise. Rather than shrink the promised worst case below +# what a real multi-candidate, judge-gated failover can legitimately need +# (reintroducing contextual-orchestrator#946's original bug), this caps how +# many preflight-verified-ready candidates the SERVING orchestrator draws +# from -- a separate, smaller number than preflight's own admission-testing +# depth above, which exists to find *some* ready route, not to bound serving +# wall-clock. +# +# Solved backwards from the job's own ceiling: noema-review.yml's +# `timeout-minutes: 360` (21600s) minus this job's other, non-serving steps +# (materialize the trusted archive, credential/token resolution, visibility +# lookup, and this file's own REVIEW_STARTUP_WATCHDOG_SECONDS-bounded sidecar +# provisioning -- generously rounded to 900s/15min of headroom) leaves 20700s +# for the "Run Noema LLM review" step. That step can invoke call_llm up to +# twice (the original request plus one one-shot verdict-repair retry; +# see noema_review_gate.py's call_llm), so each call's own worst case must +# independently fit in half of that, 10350s, for the pair to always fit. +# One route_once() call's orchestrator-level worst case is +# outer_route_once_attempts(2, from route_once's own +# `max_attempts = 1 + min(tool_retry_attempts=1, MAX_TOOL_RETRY_ATTEMPTS=4)`) +# x roles(2: a worker _invoke() call, then an independent judge _invoke() +# call via _realtime_route_judge/_model_judge_verification) x +# per-agent-attempts(2, the same `1 + min(1,4)` expression bounding +# _invoke's own same-agent retry) x REVIEW_SERVING_TIMEOUT_SECONDS(120) x +# this candidate cap: 2 x 2 x 2 x 120 x N = 960N seconds. Solving +# 960N <= 10350 gives N <= 10.78, so N=10 -- see +# CALL_LLM_TIMEOUT_SECONDS in noema_review_gate.py for the resulting exact +# 960 x 10 = 9600s per-call value this produces. Every one of these 10 +# candidates has already independently PASSED preflight's own base probe and +# serving-budget confirmation (see REVIEW_PREFLIGHT_MAX_ESCALATIONS above), +# so this is a reduction in serving-time failover depth among +# already-proven-healthy routes, not a reduction in how thoroughly preflight +# searches for a usable one. +# +# NOTE (Devin Review, "Serving cap exceeds reachable pool"): today, +# `_preflight_review_agent_batches` returns on the FIRST batch with any +# viable candidate (`if viable: return viable, {...}` below), so `agents` +# here is never larger than REVIEW_PREFLIGHT_BATCH_SIZE=4 in practice -- +# this cap of 10 does not currently bind. That is deliberate, not an +# oversight: this cap and CALL_LLM_TIMEOUT_SECONDS are a job-deadline safety +# ceiling derived independently of preflight's own batching strategy, not a +# number chosen to match it. Coupling them would mean any future change to +# preflight's early-return behavior (e.g. accumulating viable candidates +# across batches instead of stopping at the first) could silently regrow the +# real worst case past what the job's 360-minute ceiling allows again, +# exactly the bug this constant exists to prevent. Sizing this cap from the +# job's own budget, independent of today's incidental batch size, means it +# stays correct even if that batching strategy changes. REVIEW_SERVING_MAX_CANDIDATES = 10 # ADR-0005: a single fixed max_tokens cannot fit every model in a heterogeneous # pool -- some spend internal reasoning tokens before visible content and need diff --git a/scripts/ci/noema_review_gate.py b/scripts/ci/noema_review_gate.py index 8eb36fd2c0..703823bb7b 100644 --- a/scripts/ci/noema_review_gate.py +++ b/scripts/ci/noema_review_gate.py @@ -32,9 +32,64 @@ MAX_FILE_CONTEXT_CHARS = 4000 MAX_REVIEW_CONTEXT_CHARS = 24000 MAX_THREAD_BODY_CHARS = 1200 -# A real judge-gated review may legitimately exceed two minutes. This outer -# request deadline is shared with the serving transport; the workflow job is -# the final safety boundary. +# Enumerated, not guessed, against the sidecar's *unmodified* TaskOrchestrator +# defaults (ContextualWisdomLab/.github#1415, Devin Review "Serving answers +# bypass quality validation" -- an earlier version of this constant was sized +# against a serving config that had disabled tool_retry_attempts and +# policy.realtime_judge to shave latency, which also silently broke +# route_once's per-request quality gate and judge-rejection failover; see +# scripts/ci/contextual_orchestrator_review_launcher.py's orchestrator +# construction comment for that correction). +# +# FIXED (ContextualWisdomLab/.github#1415, Devin Review "Valid reviews exceed +# job deadline"): a next-earlier version of this constant (23040s = 384 +# minutes) was derived against the FULL preflight-admitted candidate pool +# (REVIEW_PREFLIGHT_MAX_TOTAL_ROUTES=24) and already exceeded +# noema-review.yml's own `timeout-minutes: 360` (21600s) job ceiling for a +# SINGLE call -- a client-side timeout the enclosing job could never actually +# honor is not a safety margin, it is a false promise. Rather than shrink +# this constant below what a real multi-candidate, judge-gated failover can +# legitimately need (reintroducing contextual-orchestrator#946's original +# bug), scripts/ci/contextual_orchestrator_review_launcher.py now caps how +# many preflight-verified-ready candidates the SERVING orchestrator draws +# from to REVIEW_SERVING_MAX_CANDIDATES=10 (see that constant's own comment +# for the full backward derivation from the job's 360-minute ceiling) instead +# of the full 24-route preflight-admission pool. With that cap and full +# TaskOrchestrator defaults (tool_retry_attempts=1, realtime_judge=True): +# - One _invoke() call (worker OR judge) tries up to +# REVIEW_SERVING_MAX_CANDIDATES=10 candidates (contextual_orchestrator +# _invoke's own _failover_candidates has no smaller bound of its own), each +# up to 1 + min(tool_retry_attempts=1, MAX_TOOL_RETRY_ATTEMPTS=4) = 2 +# attempts, each bounded by REVIEW_SERVING_TIMEOUT_SECONDS=120s (same +# file): 10 x 2 x 120 = 2400s worst case. +# - route_once() issues that worker _invoke() call, then (realtime_judge +# defaulting True) an independent judge _invoke() call of the same shape +# via _model_judge_verification/_FastMLSIJudgeAdapter.complete(): another +# 2400s worst case. One outer route_once attempt: 2400 + 2400 = 4800s. +# - route_once's own outer cross-candidate loop retries a fresh top-level +# candidate on judge rejection, up to +# max_attempts = 1 + min(tool_retry_attempts=1, MAX_TOOL_RETRY_ATTEMPTS=4) +# = 2 total candidates: 2 x 4800 = 9600s worst case for one full +# route_once() call (one /v1/chat/completions request this function +# sends). +# This client-side read timeout is sized to that 9600s per-call worst case so +# a legitimate multi-candidate, judge-gated failover is never mistaken for a +# hang -- see contextual-orchestrator#946's four consecutive TimeoutError +# failures and contextual-orchestrator#974's worst-case enumeration, which +# first identified this class of mismatch (there against the previous, +# un-tuned 120s default). +# +# call_llm() itself can still issue a second such call (the one-shot verdict +# repair retry below, guarded by `repair_error` against further recursion), +# for an absolute worst case of 2 x 9600 = 19200s across the whole function. +# noema-review.yml's `timeout-minutes: 360` (21600s) job leaves that full +# 19200s pair comfortable, generously-rounded headroom (900s/15min) for the +# job's other, non-serving steps (materialize the trusted archive, +# credential/token resolution, visibility lookup, and this repo's own +# REVIEW_STARTUP_WATCHDOG_SECONDS-bounded sidecar provisioning) -- unlike the +# prior 23040s constant, this one's absolute worst case now actually fits +# inside the job that enforces it, with margin, instead of relying on that +# job's own kill as an unacknowledged backstop. CALL_LLM_TIMEOUT_SECONDS = 9600 DIFF_HUNK_RE = re.compile(r"^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@") diff --git a/tests/test_contextual_orchestrator_review_runtime_preflight.py b/tests/test_contextual_orchestrator_review_runtime_preflight.py index 8af6b358ae..2fdaf76ae4 100644 --- a/tests/test_contextual_orchestrator_review_runtime_preflight.py +++ b/tests/test_contextual_orchestrator_review_runtime_preflight.py @@ -2488,7 +2488,7 @@ def __init__(self, **kwargs: object) -> None: preflight, serving = CaptureClient.instances assert preflight["timeout"] == 10 - assert serving["timeout"] == 9600 + assert serving["timeout"] == 120 assert preflight["timeout"] != serving["timeout"] assert preflight["max_output_tokens"] == serving["max_output_tokens"] == 4096 assert preflight["max_retries"] == serving["max_retries"] == 0 @@ -2503,7 +2503,7 @@ def test_sidecar_preserves_diagnostics_and_probes_the_real_gateway() -> None: assert "_preflight_with_fallback(" in launcher assert "preflight-out" in launcher assert "max_output_tokens=REVIEW_MAX_OUTPUT_TOKENS" in launcher - assert "REVIEW_SERVING_TIMEOUT_SECONDS = 9600" in launcher + assert "REVIEW_SERVING_TIMEOUT_SECONDS = 120" in launcher assert "timeout=REVIEW_PREFLIGHT_TIMEOUT_SECONDS" in launcher assert "timeout=REVIEW_SERVING_TIMEOUT_SECONDS" in launcher assert launcher.count("max_retries=0") == 1 From c7904c87d8c4f343772486f55abe62e4f7afbeec Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 06:38:25 +0900 Subject: [PATCH 46/65] Reapply "fix(review): remove serving 120-second timeout" This reverts commit ad6243913863f294056337f11649aa31fa762dee. --- ...ntextual-orchestrator-vendored-free-zdr.md | 2 +- ...ontextual-orchestrator-vendored-sidecar.md | 2 +- ...contextual_orchestrator_review_launcher.py | 67 ++----------------- scripts/ci/noema_review_gate.py | 61 +---------------- ...l_orchestrator_review_runtime_preflight.py | 4 +- 5 files changed, 14 insertions(+), 122 deletions(-) diff --git a/docs/adr/0003-contextual-orchestrator-vendored-free-zdr.md b/docs/adr/0003-contextual-orchestrator-vendored-free-zdr.md index 5fd54c89fd..7bfdd2efc5 100644 --- a/docs/adr/0003-contextual-orchestrator-vendored-free-zdr.md +++ b/docs/adr/0003-contextual-orchestrator-vendored-free-zdr.md @@ -143,7 +143,7 @@ all five, and auto-optimize routing by cost. - **Separate startup and serving budgets (2026-08-30):** route admission keeps the ten-second timeout so unavailable providers cannot delay healthz, while - the serving `ModelClient` uses the Noema gate's 120-second transport budget. + the serving `ModelClient` uses the Noema gate's 9,600-second review budget. Both phases keep zero retries and the same bounded request policy; the launcher test verifies the two constructed client configurations separately. diff --git a/docs/doctoring/contextual-orchestrator-vendored-sidecar.md b/docs/doctoring/contextual-orchestrator-vendored-sidecar.md index e613639112..327973e83f 100644 --- a/docs/doctoring/contextual-orchestrator-vendored-sidecar.md +++ b/docs/doctoring/contextual-orchestrator-vendored-sidecar.md @@ -84,7 +84,7 @@ independent family-cap default. The ten-second route timeout is a startup-admission budget: a route that does not answer the bounded readiness probe quickly enough is excluded before healthz. It must not also bound the real review request. The serving -`ModelClient` now uses the 120-second transport budget already used by the +`ModelClient` now uses the 9,600-second review budget used by the Noema review gate, while retaining zero retries and the same output-token and temperature policy. The launcher test constructs both client policies and asserts their distinct timeouts; it does not infer the contract from duplicate diff --git a/scripts/ci/contextual_orchestrator_review_launcher.py b/scripts/ci/contextual_orchestrator_review_launcher.py index f2d3f26a65..18247bd03d 100644 --- a/scripts/ci/contextual_orchestrator_review_launcher.py +++ b/scripts/ci/contextual_orchestrator_review_launcher.py @@ -47,69 +47,16 @@ # still finite catalog while keeping the worst-case provider wait below the # sidecar's three-minute readiness deadline. REVIEW_PREFLIGHT_TIMEOUT_SECONDS = 10 -# The serving call has the same 120-second transport budget as the Noema review -# gate; startup admission stays short so an unavailable route cannot delay healthz. -REVIEW_SERVING_TIMEOUT_SECONDS = 120 +# A real review may legitimately run far beyond two minutes. Keep the short +# timeout confined to startup admission; the outer Noema request/job deadline +# remains the serving safety boundary. +REVIEW_SERVING_TIMEOUT_SECONDS = 9600 REVIEW_PREFLIGHT_BATCH_SIZE = 4 REVIEW_PREFLIGHT_MAX_TOTAL_ROUTES = 24 REVIEW_PREFLIGHT_PRIMARY_ROUTE_LIMIT = 8 -# FIXED (ContextualWisdomLab/.github#1415, Devin Review "Valid reviews exceed -# job deadline"): the serving TaskOrchestrator previously received the FULL -# preflight-admitted pool (up to REVIEW_PREFLIGHT_MAX_TOTAL_ROUTES=24), and -# noema_review_gate.py's CALL_LLM_TIMEOUT_SECONDS was sized to that pool's -# honest worst case (23040s = 384 minutes) -- which already exceeds -# noema-review.yml's own explicit `timeout-minutes: 360` (21600s) job -# ceiling for a SINGLE call, before even considering that call_llm can issue -# a second, one-shot verdict-repair call in the same job. A client-side -# timeout the enclosing job can never actually honor is not a safety margin, -# it is a false promise. Rather than shrink the promised worst case below -# what a real multi-candidate, judge-gated failover can legitimately need -# (reintroducing contextual-orchestrator#946's original bug), this caps how -# many preflight-verified-ready candidates the SERVING orchestrator draws -# from -- a separate, smaller number than preflight's own admission-testing -# depth above, which exists to find *some* ready route, not to bound serving -# wall-clock. -# -# Solved backwards from the job's own ceiling: noema-review.yml's -# `timeout-minutes: 360` (21600s) minus this job's other, non-serving steps -# (materialize the trusted archive, credential/token resolution, visibility -# lookup, and this file's own REVIEW_STARTUP_WATCHDOG_SECONDS-bounded sidecar -# provisioning -- generously rounded to 900s/15min of headroom) leaves 20700s -# for the "Run Noema LLM review" step. That step can invoke call_llm up to -# twice (the original request plus one one-shot verdict-repair retry; -# see noema_review_gate.py's call_llm), so each call's own worst case must -# independently fit in half of that, 10350s, for the pair to always fit. -# One route_once() call's orchestrator-level worst case is -# outer_route_once_attempts(2, from route_once's own -# `max_attempts = 1 + min(tool_retry_attempts=1, MAX_TOOL_RETRY_ATTEMPTS=4)`) -# x roles(2: a worker _invoke() call, then an independent judge _invoke() -# call via _realtime_route_judge/_model_judge_verification) x -# per-agent-attempts(2, the same `1 + min(1,4)` expression bounding -# _invoke's own same-agent retry) x REVIEW_SERVING_TIMEOUT_SECONDS(120) x -# this candidate cap: 2 x 2 x 2 x 120 x N = 960N seconds. Solving -# 960N <= 10350 gives N <= 10.78, so N=10 -- see -# CALL_LLM_TIMEOUT_SECONDS in noema_review_gate.py for the resulting exact -# 960 x 10 = 9600s per-call value this produces. Every one of these 10 -# candidates has already independently PASSED preflight's own base probe and -# serving-budget confirmation (see REVIEW_PREFLIGHT_MAX_ESCALATIONS above), -# so this is a reduction in serving-time failover depth among -# already-proven-healthy routes, not a reduction in how thoroughly preflight -# searches for a usable one. -# -# NOTE (Devin Review, "Serving cap exceeds reachable pool"): today, -# `_preflight_review_agent_batches` returns on the FIRST batch with any -# viable candidate (`if viable: return viable, {...}` below), so `agents` -# here is never larger than REVIEW_PREFLIGHT_BATCH_SIZE=4 in practice -- -# this cap of 10 does not currently bind. That is deliberate, not an -# oversight: this cap and CALL_LLM_TIMEOUT_SECONDS are a job-deadline safety -# ceiling derived independently of preflight's own batching strategy, not a -# number chosen to match it. Coupling them would mean any future change to -# preflight's early-return behavior (e.g. accumulating viable candidates -# across batches instead of stopping at the first) could silently regrow the -# real worst case past what the job's 360-minute ceiling allows again, -# exactly the bug this constant exists to prevent. Sizing this cap from the -# job's own budget, independent of today's incidental batch size, means it -# stays correct even if that batching strategy changes. +# Bound the already-admitted serving catalog so immediate-error failover work +# cannot grow with discovery. The outer Noema request and workflow job remain +# the wall-clock safety boundaries. REVIEW_SERVING_MAX_CANDIDATES = 10 # ADR-0005: a single fixed max_tokens cannot fit every model in a heterogeneous # pool -- some spend internal reasoning tokens before visible content and need diff --git a/scripts/ci/noema_review_gate.py b/scripts/ci/noema_review_gate.py index 703823bb7b..8eb36fd2c0 100644 --- a/scripts/ci/noema_review_gate.py +++ b/scripts/ci/noema_review_gate.py @@ -32,64 +32,9 @@ MAX_FILE_CONTEXT_CHARS = 4000 MAX_REVIEW_CONTEXT_CHARS = 24000 MAX_THREAD_BODY_CHARS = 1200 -# Enumerated, not guessed, against the sidecar's *unmodified* TaskOrchestrator -# defaults (ContextualWisdomLab/.github#1415, Devin Review "Serving answers -# bypass quality validation" -- an earlier version of this constant was sized -# against a serving config that had disabled tool_retry_attempts and -# policy.realtime_judge to shave latency, which also silently broke -# route_once's per-request quality gate and judge-rejection failover; see -# scripts/ci/contextual_orchestrator_review_launcher.py's orchestrator -# construction comment for that correction). -# -# FIXED (ContextualWisdomLab/.github#1415, Devin Review "Valid reviews exceed -# job deadline"): a next-earlier version of this constant (23040s = 384 -# minutes) was derived against the FULL preflight-admitted candidate pool -# (REVIEW_PREFLIGHT_MAX_TOTAL_ROUTES=24) and already exceeded -# noema-review.yml's own `timeout-minutes: 360` (21600s) job ceiling for a -# SINGLE call -- a client-side timeout the enclosing job could never actually -# honor is not a safety margin, it is a false promise. Rather than shrink -# this constant below what a real multi-candidate, judge-gated failover can -# legitimately need (reintroducing contextual-orchestrator#946's original -# bug), scripts/ci/contextual_orchestrator_review_launcher.py now caps how -# many preflight-verified-ready candidates the SERVING orchestrator draws -# from to REVIEW_SERVING_MAX_CANDIDATES=10 (see that constant's own comment -# for the full backward derivation from the job's 360-minute ceiling) instead -# of the full 24-route preflight-admission pool. With that cap and full -# TaskOrchestrator defaults (tool_retry_attempts=1, realtime_judge=True): -# - One _invoke() call (worker OR judge) tries up to -# REVIEW_SERVING_MAX_CANDIDATES=10 candidates (contextual_orchestrator -# _invoke's own _failover_candidates has no smaller bound of its own), each -# up to 1 + min(tool_retry_attempts=1, MAX_TOOL_RETRY_ATTEMPTS=4) = 2 -# attempts, each bounded by REVIEW_SERVING_TIMEOUT_SECONDS=120s (same -# file): 10 x 2 x 120 = 2400s worst case. -# - route_once() issues that worker _invoke() call, then (realtime_judge -# defaulting True) an independent judge _invoke() call of the same shape -# via _model_judge_verification/_FastMLSIJudgeAdapter.complete(): another -# 2400s worst case. One outer route_once attempt: 2400 + 2400 = 4800s. -# - route_once's own outer cross-candidate loop retries a fresh top-level -# candidate on judge rejection, up to -# max_attempts = 1 + min(tool_retry_attempts=1, MAX_TOOL_RETRY_ATTEMPTS=4) -# = 2 total candidates: 2 x 4800 = 9600s worst case for one full -# route_once() call (one /v1/chat/completions request this function -# sends). -# This client-side read timeout is sized to that 9600s per-call worst case so -# a legitimate multi-candidate, judge-gated failover is never mistaken for a -# hang -- see contextual-orchestrator#946's four consecutive TimeoutError -# failures and contextual-orchestrator#974's worst-case enumeration, which -# first identified this class of mismatch (there against the previous, -# un-tuned 120s default). -# -# call_llm() itself can still issue a second such call (the one-shot verdict -# repair retry below, guarded by `repair_error` against further recursion), -# for an absolute worst case of 2 x 9600 = 19200s across the whole function. -# noema-review.yml's `timeout-minutes: 360` (21600s) job leaves that full -# 19200s pair comfortable, generously-rounded headroom (900s/15min) for the -# job's other, non-serving steps (materialize the trusted archive, -# credential/token resolution, visibility lookup, and this repo's own -# REVIEW_STARTUP_WATCHDOG_SECONDS-bounded sidecar provisioning) -- unlike the -# prior 23040s constant, this one's absolute worst case now actually fits -# inside the job that enforces it, with margin, instead of relying on that -# job's own kill as an unacknowledged backstop. +# A real judge-gated review may legitimately exceed two minutes. This outer +# request deadline is shared with the serving transport; the workflow job is +# the final safety boundary. CALL_LLM_TIMEOUT_SECONDS = 9600 DIFF_HUNK_RE = re.compile(r"^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@") diff --git a/tests/test_contextual_orchestrator_review_runtime_preflight.py b/tests/test_contextual_orchestrator_review_runtime_preflight.py index 2fdaf76ae4..8af6b358ae 100644 --- a/tests/test_contextual_orchestrator_review_runtime_preflight.py +++ b/tests/test_contextual_orchestrator_review_runtime_preflight.py @@ -2488,7 +2488,7 @@ def __init__(self, **kwargs: object) -> None: preflight, serving = CaptureClient.instances assert preflight["timeout"] == 10 - assert serving["timeout"] == 120 + assert serving["timeout"] == 9600 assert preflight["timeout"] != serving["timeout"] assert preflight["max_output_tokens"] == serving["max_output_tokens"] == 4096 assert preflight["max_retries"] == serving["max_retries"] == 0 @@ -2503,7 +2503,7 @@ def test_sidecar_preserves_diagnostics_and_probes_the_real_gateway() -> None: assert "_preflight_with_fallback(" in launcher assert "preflight-out" in launcher assert "max_output_tokens=REVIEW_MAX_OUTPUT_TOKENS" in launcher - assert "REVIEW_SERVING_TIMEOUT_SECONDS = 120" in launcher + assert "REVIEW_SERVING_TIMEOUT_SECONDS = 9600" in launcher assert "timeout=REVIEW_PREFLIGHT_TIMEOUT_SECONDS" in launcher assert "timeout=REVIEW_SERVING_TIMEOUT_SECONDS" in launcher assert launcher.count("max_retries=0") == 1 From ebeca2b4af3f7af4cbf017155b2e0a17607a4f70 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 07:04:13 +0900 Subject: [PATCH 47/65] fix(noema): preserve long reviews across peer checks --- .github/workflows/noema-review.yml | 6 +++++- tests/test_noema_orchestrator_workflow_contract.py | 10 ++++++++++ tests/test_required_workflow_queue_contract.py | 8 +++++++- 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/.github/workflows/noema-review.yml b/.github/workflows/noema-review.yml index 09f84a8175..47bad2847a 100644 --- a/.github/workflows/noema-review.yml +++ b/.github/workflows/noema-review.yml @@ -25,7 +25,11 @@ concurrency: github.event.pull_request.number || github.event.workflow_run.pull_requests[0].number || github.event.client_payload.pr_number || github.run_id }} - cancel-in-progress: true + # A new PR head or an explicit retry supersedes older work. Peer-review + # workflow completions for the same head must not cancel a model review that + # can legitimately run for hours; they queue and the exact-head handoff + # remains idempotent. + cancel-in-progress: ${{ github.event_name == 'pull_request_target' || github.event_name == 'repository_dispatch' }} permissions: contents: read diff --git a/tests/test_noema_orchestrator_workflow_contract.py b/tests/test_noema_orchestrator_workflow_contract.py index 75ad5242c0..a6e5604348 100644 --- a/tests/test_noema_orchestrator_workflow_contract.py +++ b/tests/test_noema_orchestrator_workflow_contract.py @@ -56,6 +56,16 @@ def test_noema_review_credentials_and_llm_use_orchestrator_free() -> None: assert "secrets: inherit" not in workflow +def test_peer_workflow_completion_does_not_cancel_long_noema_review() -> None: + """Only a new PR head or explicit retry may supersede a running review.""" + workflow = workflow_text("noema-review.yml") + + assert ( + "cancel-in-progress: ${{ github.event_name == 'pull_request_target' || " + "github.event_name == 'repository_dispatch' }}" + ) in workflow + + def test_noema_visibility_lookup_retries_transient_api_failures() -> None: """Bound transient GitHub API failures without weakening visibility validation.""" workflow = workflow_text("noema-review.yml") diff --git a/tests/test_required_workflow_queue_contract.py b/tests/test_required_workflow_queue_contract.py index 12b6414a3f..340d8efe2c 100644 --- a/tests/test_required_workflow_queue_contract.py +++ b/tests/test_required_workflow_queue_contract.py @@ -241,7 +241,13 @@ def test_required_pull_request_workflows_cancel_superseded_runs() -> None: assert "github.event.pull_request.base.repo.full_name" in concurrency_contract assert "github.repository" in concurrency_contract assert "github.event.pull_request.number" in workflow - assert "cancel-in-progress: true" in workflow + if filename == "noema-review.yml": + assert ( + "cancel-in-progress: ${{ github.event_name == 'pull_request_target' || " + "github.event_name == 'repository_dispatch' }}" + ) in concurrency_contract + else: + assert "cancel-in-progress: true" in workflow if filename in { "close-empty-pr.yml", "security-scan.yml", From 0a38150b0584e4a809b3f072c04a9d895365f4f3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 07:44:30 +0900 Subject: [PATCH 48/65] fix(noema): isolate long candidate attempts --- .github/workflows/noema-review.yml | 270 +++++++++++++++--- ...ntextual-orchestrator-vendored-free-zdr.md | 2 +- ...contextual_orchestrator_review_launcher.py | 32 +-- .../contextual_orchestrator_review_sidecar.sh | 20 +- scripts/ci/noema_review_gate.py | 112 +++++++- ...l_orchestrator_review_runtime_preflight.py | 4 +- ...al_orchestrator_review_sidecar_contract.py | 20 +- ...st_noema_orchestrator_workflow_contract.py | 37 ++- tests/test_noema_review_gate.py | 18 +- .../test_required_workflow_queue_contract.py | 22 +- 10 files changed, 446 insertions(+), 91 deletions(-) diff --git a/.github/workflows/noema-review.yml b/.github/workflows/noema-review.yml index 47bad2847a..2f5a68506b 100644 --- a/.github/workflows/noema-review.yml +++ b/.github/workflows/noema-review.yml @@ -44,16 +44,14 @@ jobs: steps: - run: echo "PR closed; this run only cancels older runs through workflow concurrency." - noema-review: - name: noema-review + prepare: + name: noema-review / prepare runs-on: ubuntu-latest - # Explicit, not GitHub's implicit default: self-documents the ceiling - # scripts/ci/noema_review_gate.py's CALL_LLM_TIMEOUT_SECONDS derivation - # cites (GitHub-hosted runners hard-cap a single job at 360 minutes - # regardless of this value). Unchanged from the platform default -- this - # does not alter behavior, only makes the ceiling discoverable next to - # the step whose worst-case timeout is now sized close to it. - timeout-minutes: 360 + # Preparation is network/API work only; model serving is isolated in the + # two bounded candidate jobs below. + timeout-minutes: 30 + outputs: + require_zdr: ${{ steps.target_visibility.outputs.require_zdr }} if: >- github.event_name == 'repository_dispatch' || ( @@ -297,45 +295,251 @@ jobs: ;; esac - - name: Provision contextual-orchestrator review sidecar + - name: Seal exact-head Noema review input if: env.PR_NUMBER != '' env: + GH_TOKEN: ${{ secrets.NOEMA_REVIEW_TOKEN || steps.noema_github_app_token.outputs.token || steps.noema_oidc_token.outputs.token }} + run: | + set -euo pipefail + python3 -m scripts.ci.noema_review_gate \ + --repo "$TARGET_REPOSITORY" \ + --pr-number "$PR_NUMBER" \ + --mode prepare \ + --output "${RUNNER_TEMP}/noema-input.json" + + - name: Upload sealed Noema review input + if: env.PR_NUMBER != '' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: noema-review-input + path: | + ${{ runner.temp }}/noema-input.json + ${{ runner.temp }}/noema-input.json.sha256 + if-no-files-found: error + retention-days: 1 + + candidate-1: + name: noema-review / candidate-1 + needs: prepare + runs-on: ubuntu-latest + timeout-minutes: 350 + permissions: + actions: read + contents: read + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + steps: + - name: Materialize trusted Noema source + env: + GH_TOKEN: ${{ github.token }} + TRUSTED_SOURCE_REF: ${{ github.workflow_sha }} + run: | + set -euo pipefail + [[ "$TRUSTED_SOURCE_REF" =~ ^[0-9a-fA-F]{40}$ ]] + curl -fsSL -H "Authorization: Bearer ${GH_TOKEN}" -H "Accept: application/vnd.github+json" \ + -o "${RUNNER_TEMP}/trusted.tar.gz" "${GITHUB_API_URL}/repos/ContextualWisdomLab/.github/tarball/${TRUSTED_SOURCE_REF}" + tar -xzf "${RUNNER_TEMP}/trusted.tar.gz" -C "$GITHUB_WORKSPACE" --strip-components=1 + - name: Download sealed Noema review input + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: noema-review-input + path: ${{ runner.temp }}/noema-input + - name: Provision candidate pool + env: &provider_credentials BYTEZ_API_KEY: ${{ secrets.BYTEZ_API_KEY }} NVIDIA_NIM_API_KEY: ${{ secrets.NVIDIA_NIM_API_KEY }} NVIDIA_NIM_API_KEY_SUB: ${{ secrets.NVIDIA_NIM_API_KEY_SUB }} OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} - CONTEXTUAL_ORCHESTRATOR_REQUIRE_ZDR: ${{ steps.target_visibility.outputs.require_zdr }} + CONTEXTUAL_ORCHESTRATOR_REQUIRE_ZDR: ${{ needs.prepare.outputs.require_zdr }} + run: bash "$GITHUB_WORKSPACE/scripts/ci/contextual_orchestrator_review_sidecar.sh" --single-candidate-attempt + - name: Run first candidate + id: review + timeout-minutes: 330 + run: | + set -euo pipefail + if [ -z "${CONTEXTUAL_ORCHESTRATOR_BASE_URL:-}" ] || [ -z "${CONTEXTUAL_ORCHESTRATOR_TOKEN_FILE:-}" ]; then + echo "::error::contextual-orchestrator review sidecar must be provisioned before Noema LLM review." + exit 1 + fi + source "$GITHUB_WORKSPACE/scripts/ci/load_contextual_orchestrator_token.sh" + candidate_id="$(jq -er '.routes[] | select(.status == "ready") | .agent_id' "$CONTEXTUAL_ORCHESTRATOR_PREFLIGHT_EVIDENCE" | head -1)" + printf '%s\n' "$candidate_id" >"${RUNNER_TEMP}/candidate-1.id" + export NOEMA_LLM_API_URL="${CONTEXTUAL_ORCHESTRATOR_BASE_URL%/}/v1/chat/completions" + export NOEMA_LLM_MODEL="orchestrator/free" + export NOEMA_LLM_API_KEY="$CONTEXTUAL_ORCHESTRATOR_TOKEN" + export NOEMA_LLM_VIA_ORCHESTRATOR=1 + export NOEMA_LLM_CANDIDATE_ID="$candidate_id" + python3 -m scripts.ci.noema_review_gate --repo placeholder/repo --pr-number 1 \ + --mode evaluate --input "${RUNNER_TEMP}/noema-input/noema-input.json" \ + --output "${RUNNER_TEMP}/noema-verdict.json" + - name: Guarantee first candidate status handoff + if: always() run: | set -euo pipefail - bash "$GITHUB_WORKSPACE/scripts/ci/contextual_orchestrator_review_sidecar.sh" + if [ ! -e "${RUNNER_TEMP}/candidate-1.id" ]; then + : >"${RUNNER_TEMP}/candidate-1.id" + fi + - name: Upload first candidate handoff + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: noema-candidate-1 + path: | + ${{ runner.temp }}/candidate-1.id + ${{ runner.temp }}/noema-verdict.json + ${{ runner.temp }}/noema-verdict.json.sha256 + if-no-files-found: error + retention-days: 1 - - name: Run Noema LLM review and submit verdict - if: env.PR_NUMBER != '' + candidate-2: + name: noema-review / candidate-2 + needs: [prepare, candidate-1] + if: always() && needs.prepare.result == 'success' + runs-on: ubuntu-latest + timeout-minutes: 350 + permissions: + actions: read + contents: read + steps: + - name: Materialize trusted Noema source env: - GH_TOKEN: ${{ secrets.NOEMA_REVIEW_TOKEN || steps.noema_github_app_token.outputs.token || steps.noema_oidc_token.outputs.token }} - NOEMA_REVIEW_TOKEN_SOURCE: ${{ steps.noema_credential.outputs.source == 'pat' && 'noema-review-pat' || steps.noema_credential.outputs.source == 'github-app' && 'noema-review-github-app' || 'noema-review-app-oidc' }} - NOEMA_REVIEW_ACTOR: ${{ steps.noema_github_app_token.outputs['app-slug'] && format('{0}[bot]', steps.noema_github_app_token.outputs['app-slug']) || '' }} - NOEMA_REVIEW_INSTALLATION_ID: ${{ steps.noema_github_app_token.outputs['installation-id'] }} + GH_TOKEN: ${{ github.token }} + TRUSTED_SOURCE_REF: ${{ github.workflow_sha }} run: | set -euo pipefail - if [ -z "${PR_NUMBER:-}" ]; then - echo "No pull request number was available for this event; skipping." - exit 0 - fi - if [ -z "${GH_TOKEN:-}" ]; then - echo "::error::Noema reviewer credential selection succeeded but no token was minted; review cannot submit a verdict." - exit 1 - fi - if [ -z "${CONTEXTUAL_ORCHESTRATOR_BASE_URL:-}" ] || [ -z "${CONTEXTUAL_ORCHESTRATOR_TOKEN_FILE:-}" ]; then - echo "::error::contextual-orchestrator review sidecar must be provisioned before Noema LLM review." - exit 1 + [[ "$TRUSTED_SOURCE_REF" =~ ^[0-9a-fA-F]{40}$ ]] + curl -fsSL -H "Authorization: Bearer ${GH_TOKEN}" -H "Accept: application/vnd.github+json" \ + -o "${RUNNER_TEMP}/trusted.tar.gz" "${GITHUB_API_URL}/repos/ContextualWisdomLab/.github/tarball/${TRUSTED_SOURCE_REF}" + tar -xzf "${RUNNER_TEMP}/trusted.tar.gz" -C "$GITHUB_WORKSPACE" --strip-components=1 + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: noema-review-input + path: ${{ runner.temp }}/noema-input + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: noema-candidate-1 + path: ${{ runner.temp }}/candidate-1 + - name: Reuse successful first verdict + id: reuse + run: | + if [ -s "${RUNNER_TEMP}/candidate-1/noema-verdict.json" ] && [ -s "${RUNNER_TEMP}/candidate-1/noema-verdict.json.sha256" ]; then + cp "${RUNNER_TEMP}/candidate-1/noema-verdict.json" "${RUNNER_TEMP}/noema-verdict.json" + cp "${RUNNER_TEMP}/candidate-1/noema-verdict.json.sha256" "${RUNNER_TEMP}/noema-verdict.json.sha256" + echo "reused=true" >>"$GITHUB_OUTPUT" + else + echo "reused=false" >>"$GITHUB_OUTPUT" fi + - name: Provision fallback candidate pool + if: steps.reuse.outputs.reused != 'true' + env: *provider_credentials + run: bash "$GITHUB_WORKSPACE/scripts/ci/contextual_orchestrator_review_sidecar.sh" --single-candidate-attempt + - name: Run second candidate + if: steps.reuse.outputs.reused != 'true' + timeout-minutes: 330 + run: | + set -euo pipefail source "$GITHUB_WORKSPACE/scripts/ci/load_contextual_orchestrator_token.sh" + first_id="$(cat "${RUNNER_TEMP}/candidate-1/candidate-1.id" 2>/dev/null || true)" + candidate_id="$(jq -er --arg excluded "$first_id" '.routes[] | select(.status == "ready" and .agent_id != $excluded) | .agent_id' "$CONTEXTUAL_ORCHESTRATOR_PREFLIGHT_EVIDENCE" | head -1)" export NOEMA_LLM_API_URL="${CONTEXTUAL_ORCHESTRATOR_BASE_URL%/}/v1/chat/completions" export NOEMA_LLM_MODEL="orchestrator/free" - export NOEMA_LLM_API_KEY="${CONTEXTUAL_ORCHESTRATOR_TOKEN}" + export NOEMA_LLM_API_KEY="$CONTEXTUAL_ORCHESTRATOR_TOKEN" export NOEMA_LLM_VIA_ORCHESTRATOR=1 - python3 -m scripts.ci.noema_review_gate \ - --repo "$TARGET_REPOSITORY" \ - --pr-number "$PR_NUMBER" + export NOEMA_LLM_CANDIDATE_ID="$candidate_id" + export NOEMA_LLM_EXCLUDE_CANDIDATE_IDS="$first_id" + python3 -m scripts.ci.noema_review_gate --repo placeholder/repo --pr-number 1 \ + --mode evaluate --input "${RUNNER_TEMP}/noema-input/noema-input.json" \ + --output "${RUNNER_TEMP}/noema-verdict.json" + - name: Upload final candidate handoff + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: noema-candidate-final + path: | + ${{ runner.temp }}/noema-verdict.json + ${{ runner.temp }}/noema-verdict.json.sha256 + if-no-files-found: error + retention-days: 1 + + finalize: + name: noema-review + needs: [prepare, candidate-2] + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + actions: read + contents: read + id-token: write + pull-requests: write + env: + TARGET_REPOSITORY: ${{ github.event.pull_request.base.repo.full_name || github.event.client_payload.target_repository || github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number || github.event.workflow_run.pull_requests[0].number || github.event.client_payload.pr_number || '' }} + steps: + - name: Materialize trusted Noema source + env: + GH_TOKEN: ${{ github.token }} + TRUSTED_SOURCE_REF: ${{ github.workflow_sha }} + run: | + set -euo pipefail + [[ "$TRUSTED_SOURCE_REF" =~ ^[0-9a-fA-F]{40}$ ]] + curl -fsSL -H "Authorization: Bearer ${GH_TOKEN}" -H "Accept: application/vnd.github+json" \ + -o "${RUNNER_TEMP}/trusted.tar.gz" "${GITHUB_API_URL}/repos/ContextualWisdomLab/.github/tarball/${TRUSTED_SOURCE_REF}" + tar -xzf "${RUNNER_TEMP}/trusted.tar.gz" -C "$GITHUB_WORKSPACE" --strip-components=1 + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: noema-review-input + path: ${{ runner.temp }}/noema-input + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: noema-candidate-final + path: ${{ runner.temp }}/noema-verdict + - name: Select finalizer credential + id: credential + env: + APP_CLIENT_ID: ${{ vars.NOEMA_GITHUB_APP_CLIENT_ID || '' }} + APP_PRIVATE_KEY: ${{ secrets.NOEMA_GITHUB_APP_PRIVATE_KEY || '' }} + REVIEW_TOKEN: ${{ secrets.NOEMA_REVIEW_TOKEN || '' }} + EXCHANGE_URL: ${{ vars.NOEMA_TOKEN_EXCHANGE_URL || vars.NOEMA_EXCHANGE_URL || '' }} + run: | + set -euo pipefail + echo "repository=${TARGET_REPOSITORY#*/}" >>"$GITHUB_OUTPUT" + if [ -n "$REVIEW_TOKEN" ]; then echo "source=pat" >>"$GITHUB_OUTPUT" + elif [ -n "$APP_CLIENT_ID" ] && [ -n "$APP_PRIVATE_KEY" ]; then echo "source=github-app" >>"$GITHUB_OUTPUT" + elif [ -n "$EXCHANGE_URL" ]; then echo "source=oidc" >>"$GITHUB_OUTPUT" + else echo "::error::Noema reviewer credential is unavailable."; exit 1; fi + - name: Mint repository-scoped Noema GitHub App token + id: app_token + if: steps.credential.outputs.source == 'github-app' + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.NOEMA_GITHUB_APP_CLIENT_ID }} + private-key: ${{ secrets.NOEMA_GITHUB_APP_PRIVATE_KEY }} + owner: ContextualWisdomLab + repositories: ${{ steps.credential.outputs.repository }} + permission-contents: read + permission-pull-requests: write + - name: Exchange finalizer token through OIDC + if: steps.credential.outputs.source == 'oidc' + id: oidc_token + env: + EXCHANGE_URL: ${{ vars.NOEMA_TOKEN_EXCHANGE_URL || vars.NOEMA_EXCHANGE_URL || '' }} + OIDC_AUDIENCE: ${{ vars.NOEMA_OIDC_AUDIENCE || 'cwl-noema-review' }} + run: | + set -euo pipefail + separator='?'; [[ "$ACTIONS_ID_TOKEN_REQUEST_URL" == *\?* ]] && separator='&' + oidc="$(curl -fsS -H "Authorization: Bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" "${ACTIONS_ID_TOKEN_REQUEST_URL}${separator}audience=${OIDC_AUDIENCE}" | jq -er .value)" + token="$(curl -fsS -X POST -H 'Content-Type: application/json' -H "Authorization: Bearer ${oidc}" --data "$(jq -cn --arg target_repository "$TARGET_REPOSITORY" '{target_repository:$target_repository}')" "$EXCHANGE_URL" | jq -er .token)" + echo "::add-mask::$token" + echo "token=$token" >>"$GITHUB_OUTPUT" + - name: Submit sealed exact-head verdict + env: + GH_TOKEN: ${{ secrets.NOEMA_REVIEW_TOKEN || steps.app_token.outputs.token || steps.oidc_token.outputs.token }} + NOEMA_REVIEW_TOKEN_SOURCE: ${{ steps.credential.outputs.source }} + NOEMA_REVIEW_ACTOR: ${{ steps.app_token.outputs['app-slug'] && format('{0}[bot]', steps.app_token.outputs['app-slug']) || '' }} + NOEMA_REVIEW_INSTALLATION_ID: ${{ steps.app_token.outputs['installation-id'] }} + run: | + set -euo pipefail + test -n "${GH_TOKEN:-}" || { echo "::error::Noema reviewer credential is unavailable."; exit 1; } + python3 -m scripts.ci.noema_review_gate --repo "$TARGET_REPOSITORY" --pr-number "$PR_NUMBER" \ + --mode finalize --input "${RUNNER_TEMP}/noema-input/noema-input.json" \ + --verdict "${RUNNER_TEMP}/noema-verdict/noema-verdict.json" diff --git a/docs/adr/0003-contextual-orchestrator-vendored-free-zdr.md b/docs/adr/0003-contextual-orchestrator-vendored-free-zdr.md index 7bfdd2efc5..83029eda38 100644 --- a/docs/adr/0003-contextual-orchestrator-vendored-free-zdr.md +++ b/docs/adr/0003-contextual-orchestrator-vendored-free-zdr.md @@ -24,7 +24,7 @@ all five, and auto-optimize routing by cost. 1. **Vendoring, pinned**: `scripts/ci/contextual_orchestrator_review_sidecar.sh` clones `ContextualWisdomLab/contextual-orchestrator` at an exact SHA - (`8cd99f139915131ba0239bce12a5d6a5fd85394e` today) into `RUNNER_TEMP`. The + (`9942b620bed03ca4f414338bf82a08cff4f267ed` today) into `RUNNER_TEMP`. The source's `requirements.lock` is installed with `--require-hashes` and `--no-deps`, so dependency resolution cannot silently move the reviewed runtime. diff --git a/scripts/ci/contextual_orchestrator_review_launcher.py b/scripts/ci/contextual_orchestrator_review_launcher.py index 18247bd03d..83d9b49047 100644 --- a/scripts/ci/contextual_orchestrator_review_launcher.py +++ b/scripts/ci/contextual_orchestrator_review_launcher.py @@ -50,7 +50,7 @@ # A real review may legitimately run far beyond two minutes. Keep the short # timeout confined to startup admission; the outer Noema request/job deadline # remains the serving safety boundary. -REVIEW_SERVING_TIMEOUT_SECONDS = 9600 +REVIEW_SERVING_TIMEOUT_SECONDS = 9000 REVIEW_PREFLIGHT_BATCH_SIZE = 4 REVIEW_PREFLIGHT_MAX_TOTAL_ROUTES = 24 REVIEW_PREFLIGHT_PRIMARY_ROUTE_LIMIT = 8 @@ -1332,6 +1332,11 @@ def main(argv: list[str] | None = None) -> int: ) parser.add_argument("--require-zdr", action="store_true") parser.add_argument("--pool", choices=("free", "auto"), default="free") + parser.add_argument( + "--single-candidate-attempt", + action="store_true", + help="Disable the redundant same-agent retry when job-level failover is active", + ) args = parser.parse_args(argv) from contextual_orchestrator.credentials import get_credential @@ -1504,28 +1509,16 @@ def main(argv: list[str] | None = None) -> int: client = _build_model_client( ModelClient, timeout=REVIEW_SERVING_TIMEOUT_SECONDS ) - # CORRECTED (ContextualWisdomLab/.github#1415, Devin Review "Serving - # answers bypass quality validation"): an earlier version of this fix - # disabled TaskOrchestrator's tool_retry_attempts (to 0) and - # policy.realtime_judge (to False) to shave worst-case wall-clock. Both - # were wrong to touch. realtime_judge is not just a future-routing + # realtime_judge is not just a future-routing # quality-ledger signal -- route_once() uses it to gate acceptance of the # *current* answer and to fail over to the next measured candidate on # rejection (see route_once/_realtime_route_judge in # contextual_orchestrator/orchestrator.py); disabling it let a # judge-rejected, low-quality answer reach Noema instead of another ready - # route. Separately, tool_retry_attempts=0 was doubly wrong: besides - # removing _invoke's legitimate same-agent retry-on-transient-failure, it - # also drives route_once's own OWN outer cross-candidate loop bound - # (`max_attempts = 1 + min(tool_retry_attempts, MAX_TOOL_RETRY_ATTEMPTS)` - # in route_once) down to 1 -- so even with realtime_judge alone reverted, - # a judge rejection would still have nowhere to fail over to. Both - # defaults are restored here unmodified (tool_retry_attempts=1, - # realtime_judge=True, both TaskOrchestrator's own tested constructor/ - # OrchestrationPolicy defaults) so serving keeps its full, intended - # per-request quality gate and failover; see CALL_LLM_TIMEOUT_SECONDS in - # noema_review_gate.py for the resulting (larger, honestly re-derived) - # client-side read-timeout this requires. + # route. Normal callers retain TaskOrchestrator's defaults. The explicit + # single-candidate job mode removes only its redundant same-agent retry; + # cross-candidate failover happens in the next workflow job, while the + # realtime judge remains enabled by its unchanged default. # # Sliced to REVIEW_SERVING_MAX_CANDIDATES (see that constant's own # comment): serving the full preflight-admitted pool made the honest @@ -1533,8 +1526,9 @@ def main(argv: list[str] | None = None) -> int: # already preflight's own ranked, verified-ready ordering, so this keeps # the top-ranked candidates and only trims serving-time failover depth # among routes preflight already proved could serve a real request. + attempt_options = {"tool_retry_attempts": 0} if args.single_candidate_attempt else {} orchestrator = TaskOrchestrator( - agents[:REVIEW_SERVING_MAX_CANDIDATES], client=client + agents[:REVIEW_SERVING_MAX_CANDIDATES], client=client, **attempt_options ) serve( orchestrator, diff --git a/scripts/ci/contextual_orchestrator_review_sidecar.sh b/scripts/ci/contextual_orchestrator_review_sidecar.sh index b16e679778..1221b9cf8e 100755 --- a/scripts/ci/contextual_orchestrator_review_sidecar.sh +++ b/scripts/ci/contextual_orchestrator_review_sidecar.sh @@ -14,7 +14,24 @@ # (fail-closed zero-cost) pool. set -euo pipefail -ORCHESTRATOR_PIN_SHA="${ORCHESTRATOR_PIN_SHA:-8cd99f139915131ba0239bce12a5d6a5fd85394e}" +launcher_attempt_args=() +case "${1:-}" in + "") ;; + --single-candidate-attempt) + launcher_attempt_args=(--single-candidate-attempt) + shift + ;; + *) + printf '[contextual-orchestrator-sidecar] error: unsupported argument: %s\n' "$1" >&2 + exit 1 + ;; +esac +if [ "$#" -ne 0 ]; then + printf '[contextual-orchestrator-sidecar] error: unexpected extra arguments\n' >&2 + exit 1 +fi + +ORCHESTRATOR_PIN_SHA="${ORCHESTRATOR_PIN_SHA:-9942b620bed03ca4f414338bf82a08cff4f267ed}" ORCHESTRATOR_GIT_URL="${ORCHESTRATOR_GIT_URL:-https://github.com/ContextualWisdomLab/contextual-orchestrator.git}" # The Strix gate and Noema SSRF guard accept this one process-local origin. # Keep it fixed so an environment override cannot create an unvalidated sidecar. @@ -434,6 +451,7 @@ PYTHONPATH="$ORCHESTRATOR_SOURCE:$ORG_REPO_ROOT" \ --catalog-out "$catalog_file" \ --report-out "$policy_report" \ --preflight-out "$preflight_report" \ + "${launcher_attempt_args[@]}" \ "${zdr_args[@]}" \ "${privacy_args[@]}" \ "${pool_args[@]}" \ diff --git a/scripts/ci/noema_review_gate.py b/scripts/ci/noema_review_gate.py index 8eb36fd2c0..34df6d5084 100644 --- a/scripts/ci/noema_review_gate.py +++ b/scripts/ci/noema_review_gate.py @@ -6,6 +6,7 @@ import argparse import ast import base64 +import hashlib import ipaddress import json import os @@ -17,6 +18,7 @@ import urllib.parse import urllib.request from collections.abc import Sequence +from pathlib import Path from typing import Any from scripts.ci.opencode_review_normalize_output import changed_file_is_material @@ -32,10 +34,10 @@ MAX_FILE_CONTEXT_CHARS = 4000 MAX_REVIEW_CONTEXT_CHARS = 24000 MAX_THREAD_BODY_CHARS = 1200 -# A real judge-gated review may legitimately exceed two minutes. This outer -# request deadline is shared with the serving transport; the workflow job is -# the final safety boundary. -CALL_LLM_TIMEOUT_SECONDS = 9600 +# The sidecar may spend 150 minutes on the selected reviewer and another 150 +# minutes on its preserved realtime judge. Keep 30 minutes for handoff and +# transport overhead while remaining below GitHub's 360-minute job ceiling. +CALL_LLM_TIMEOUT_SECONDS = 19800 DIFF_HUNK_RE = re.compile(r"^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@") ORCHESTRATOR_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1"}) @@ -649,6 +651,18 @@ def call_llm( } if is_allowed_orchestrator_sidecar_url(api_url): payload["orchestration"] = "route" + candidate_id = os.environ.get("NOEMA_LLM_CANDIDATE_ID", "").strip() + excluded = [ + value.strip() + for value in os.environ.get("NOEMA_LLM_EXCLUDE_CANDIDATE_IDS", "").split(",") + if value.strip() + ] + if candidate_id or excluded: + payload["routing"] = {} + if candidate_id: + payload["routing"]["candidate_id"] = candidate_id + if excluded: + payload["routing"]["exclude_candidate_ids"] = excluded request = urllib.request.Request( api_url, data=json.dumps(payload).encode("utf-8"), @@ -810,11 +824,93 @@ def inspect_and_review(repo: str, number: int) -> int: return 0 +def _write_sealed(path: str, payload: dict[str, Any]) -> None: + encoded = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode() + with open(path, "wb") as stream: + stream.write(encoded) + with open(f"{path}.sha256", "w", encoding="ascii") as stream: + stream.write(f"{hashlib.sha256(encoded).hexdigest()}\n") + + +def _read_sealed(path: str) -> dict[str, Any]: + with open(path, "rb") as stream: + encoded = stream.read() + with open(f"{path}.sha256", encoding="ascii") as stream: + expected = stream.read().strip() + if not re.fullmatch(r"[0-9a-f]{64}", expected) or hashlib.sha256(encoded).hexdigest() != expected: + raise RuntimeError("Noema handoff artifact digest mismatch") + payload = json.loads(encoded) + if not isinstance(payload, dict): + raise RuntimeError("Noema handoff artifact must contain a JSON object") + return payload + + +def prepare_review(repo: str, number: int, output: str) -> int: + """Seal immutable review input without calling a model or writing GitHub.""" + pr = fetch_pr(repo, number) + if pr.get("isDraft"): + raise RuntimeError("Noema review preparation does not accept draft pull requests") + diff, truncated = fetch_diff(repo, number) + changed_paths = fetch_changed_file_paths(repo, number) + _write_sealed(output, { + "repo": repo, + "number": number, + "head_sha": pr.get("headRefOid"), + "pr": pr, + "diff": diff, + "truncated": truncated, + "changed_paths": changed_paths, + "review_context": build_review_context(repo, number, pr), + }) + return 0 + + +def evaluate_review(input_path: str, output: str) -> int: + """Evaluate one sealed current-head input and seal the model verdict.""" + prepared = _read_sealed(input_path) + repo, number = str(prepared["repo"]), int(prepared["number"]) + verdict = call_llm( + repo, number, prepared["pr"], prepared["diff"], bool(prepared["truncated"]), + str(prepared.get("review_context") or ""), prepared.get("changed_paths") or (), + ) + _write_sealed(output, { + "repo": repo, + "number": number, + "head_sha": prepared["head_sha"], + "input_sha256": hashlib.sha256(Path(input_path).read_bytes()).hexdigest(), + "candidate_id": os.environ.get("NOEMA_LLM_CANDIDATE_ID", "").strip(), + "verdict": verdict, + }) + return 0 + + +def finalize_review(input_path: str, verdict_path: str) -> int: + """Submit only a sealed verdict bound to the sealed input and live head.""" + prepared, result = _read_sealed(input_path), _read_sealed(verdict_path) + input_digest = hashlib.sha256(Path(input_path).read_bytes()).hexdigest() + if result.get("input_sha256") != input_digest or result.get("head_sha") != prepared.get("head_sha"): + raise RuntimeError("Noema verdict artifact is not bound to the prepared input") + repo, number = str(prepared["repo"]), int(prepared["number"]) + pr = fetch_pr(repo, number) + if pr.get("headRefOid") != prepared.get("head_sha"): + raise RuntimeError("Noema verdict artifact is stale for the current pull request head") + actor = current_actor() + if not actor or actor in PRIMARY_REVIEW_AUTHORS: + raise RuntimeError("Noema requires a verified independent reviewer credential") + if not existing_noema_review(pr, actor): + submit_review(repo, number, pr, actor, result["verdict"]) + return 0 + + def parse_args(argv: list[str]) -> argparse.Namespace: """Parse Noema review gate command-line arguments.""" parser = argparse.ArgumentParser() parser.add_argument("--repo", required=True) parser.add_argument("--pr-number", required=True, type=int) + parser.add_argument("--mode", choices=("review", "prepare", "evaluate", "finalize"), default="review") + parser.add_argument("--input") + parser.add_argument("--verdict") + parser.add_argument("--output") return parser.parse_args(argv) @@ -823,6 +919,14 @@ def main(argv: list[str]) -> int: args = parse_args(argv) if args.pr_number <= 0: raise SystemExit("--pr-number must be positive") + if args.mode == "prepare" and args.output: + return prepare_review(args.repo, args.pr_number, args.output) + if args.mode == "evaluate" and args.input and args.output: + return evaluate_review(args.input, args.output) + if args.mode == "finalize" and args.input and args.verdict: + return finalize_review(args.input, args.verdict) + if args.mode != "review": + raise SystemExit("selected mode requires its artifact path arguments") return inspect_and_review(args.repo, args.pr_number) diff --git a/tests/test_contextual_orchestrator_review_runtime_preflight.py b/tests/test_contextual_orchestrator_review_runtime_preflight.py index 8af6b358ae..63ba2c43a6 100644 --- a/tests/test_contextual_orchestrator_review_runtime_preflight.py +++ b/tests/test_contextual_orchestrator_review_runtime_preflight.py @@ -2488,7 +2488,7 @@ def __init__(self, **kwargs: object) -> None: preflight, serving = CaptureClient.instances assert preflight["timeout"] == 10 - assert serving["timeout"] == 9600 + assert serving["timeout"] == 9000 assert preflight["timeout"] != serving["timeout"] assert preflight["max_output_tokens"] == serving["max_output_tokens"] == 4096 assert preflight["max_retries"] == serving["max_retries"] == 0 @@ -2503,7 +2503,7 @@ def test_sidecar_preserves_diagnostics_and_probes_the_real_gateway() -> None: assert "_preflight_with_fallback(" in launcher assert "preflight-out" in launcher assert "max_output_tokens=REVIEW_MAX_OUTPUT_TOKENS" in launcher - assert "REVIEW_SERVING_TIMEOUT_SECONDS = 9600" in launcher + assert "REVIEW_SERVING_TIMEOUT_SECONDS = 9000" in launcher assert "timeout=REVIEW_PREFLIGHT_TIMEOUT_SECONDS" in launcher assert "timeout=REVIEW_SERVING_TIMEOUT_SECONDS" in launcher assert launcher.count("max_retries=0") == 1 diff --git a/tests/test_contextual_orchestrator_review_sidecar_contract.py b/tests/test_contextual_orchestrator_review_sidecar_contract.py index ae3a3216ba..fbf86693d6 100644 --- a/tests/test_contextual_orchestrator_review_sidecar_contract.py +++ b/tests/test_contextual_orchestrator_review_sidecar_contract.py @@ -40,7 +40,7 @@ ) GATEWAY_MODEL = "contextual-orchestrator/orchestrator/free" -ORCH_PIN_SHA = "8cd99f139915131ba0239bce12a5d6a5fd85394e" +ORCH_PIN_SHA = "9942b620bed03ca4f414338bf82a08cff4f267ed" def _read(path: Path) -> str: @@ -66,6 +66,19 @@ def test_sidecar_pins_the_vendored_orchestrator_revision() -> None: assert 'ORCHESTRATOR_HOST="127.0.0.1"' in text +def test_single_candidate_attempt_is_explicit_and_preserves_normal_defaults() -> None: + """Only pinned workflow jobs remove the redundant in-process retry.""" + sidecar = _read(SIDECAR) + launcher = _read(LAUNCHER) + + assert "--single-candidate-attempt" in sidecar + assert 'launcher_attempt_args=(--single-candidate-attempt)' in sidecar + assert 'if args.single_candidate_attempt else {}' in launcher + assert '{"tool_retry_attempts": 0}' in launcher + assert "realtime_judge=False" not in launcher + assert "realtime_judge = False" not in launcher + + def test_sidecar_adr_names_the_current_vendored_revision() -> None: """The accepted decision record must not advertise a stale runtime SHA.""" assert ORCH_PIN_SHA in _read(SIDECAR_ADR) @@ -510,7 +523,7 @@ def test_noema_review_workflow_provisions_sidecar_with_all_five_secrets() -> Non assert 'export NOEMA_LLM_MODEL="orchestrator/free"' in workflow assert "NOEMA_LLM_VIA_ORCHESTRATOR=1" in workflow assert "${CONTEXTUAL_ORCHESTRATOR_BASE_URL%/}/v1/chat/completions" in workflow - assert "${CONTEXTUAL_ORCHESTRATOR_TOKEN}" in workflow + assert 'NOEMA_LLM_API_KEY="$CONTEXTUAL_ORCHESTRATOR_TOKEN"' in workflow assert "https://integrate.api.nvidia.com" not in workflow assert "nvidia/nemotron-3-ultra-550b-a55b" not in workflow assert "COPILOT_GITHUB_TOKEN" not in workflow @@ -525,7 +538,8 @@ def test_noema_private_targets_require_zdr_only_sidecar_routing() -> None: launcher = _read(LAUNCHER) assert "Resolve Noema target repository visibility" in workflow - assert "target_visibility.outputs.require_zdr" in workflow + assert "steps.target_visibility.outputs.require_zdr" in workflow + assert "needs.prepare.outputs.require_zdr" in workflow assert "CONTEXTUAL_ORCHESTRATOR_REQUIRE_ZDR" in workflow assert "CONTEXTUAL_ORCHESTRATOR_REQUIRE_ZDR" in sidecar assert "--require-zdr" in sidecar diff --git a/tests/test_noema_orchestrator_workflow_contract.py b/tests/test_noema_orchestrator_workflow_contract.py index a6e5604348..854ebe43fe 100644 --- a/tests/test_noema_orchestrator_workflow_contract.py +++ b/tests/test_noema_orchestrator_workflow_contract.py @@ -23,14 +23,11 @@ def test_noema_review_credentials_and_llm_use_orchestrator_free() -> None: "NOEMA_GITHUB_APP_PRIVATE_KEY, NOEMA_REVIEW_TOKEN, or NOEMA_TOKEN_EXCHANGE_URL. " "Review cannot be skipped." ) in workflow - assert ( - "Noema reviewer credential selection succeeded but no token was minted" - in workflow - ) assert "https://integrate.api.nvidia.com/v1/chat/completions" not in workflow assert "nvidia/nemotron-3-ultra-550b-a55b" not in workflow assert "Resolve Noema target repository visibility" in workflow - assert "target_visibility.outputs.require_zdr" in workflow + assert "steps.target_visibility.outputs.require_zdr" in workflow + assert "needs.prepare.outputs.require_zdr" in workflow assert "CONTEXTUAL_ORCHESTRATOR_REQUIRE_ZDR" in workflow assert ( "NOEMA_LLM_API_KEY: ${{ secrets.NOEMA_LLM_API_KEY || secrets.OPENAI_API_KEY || '' }}" @@ -43,6 +40,23 @@ def test_noema_review_credentials_and_llm_use_orchestrator_free() -> None: assert "OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}" in workflow assert "OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}" in workflow assert 'export NOEMA_LLM_MODEL="orchestrator/free"' in workflow + assert "candidate-1:" in workflow + assert "candidate-2:" in workflow + assert "finalize:" in workflow + assert workflow.count("timeout-minutes: 330") == 2 + assert workflow.count("timeout-minutes: 350") == 2 + assert workflow.count( + "contextual_orchestrator_review_sidecar.sh\" --single-candidate-attempt" + ) == 2 + assert "Guarantee first candidate status handoff" in workflow + assert ': >"${RUNNER_TEMP}/candidate-1.id"' in workflow + first_upload = workflow_step(workflow, "Upload first candidate handoff") + assert "if: always()" in first_upload + assert "if-no-files-found: error" in first_upload + second_run = workflow_step(workflow, "Run second candidate") + assert 'cat "${RUNNER_TEMP}/candidate-1/candidate-1.id" 2>/dev/null || true' in second_run + assert "NOEMA_LLM_CANDIDATE_ID" in workflow + assert "NOEMA_LLM_EXCLUDE_CANDIDATE_IDS" in workflow assert "python3 -m scripts.ci.noema_review_gate" in workflow assert "python3 scripts/ci/noema_review_gate.py" not in workflow assert ( @@ -70,7 +84,7 @@ def test_noema_visibility_lookup_retries_transient_api_failures() -> None: """Bound transient GitHub API failures without weakening visibility validation.""" workflow = workflow_text("noema-review.yml") start = workflow.index(" - name: Resolve Noema target repository visibility") - end = workflow.index(" - name: Provision contextual-orchestrator review sidecar", start) + end = workflow.index(" - name: Seal exact-head Noema review input", start) visibility_step = workflow[start:end] assert "for target_visibility_attempt in 1 2 3 4 5 6; do" in visibility_step @@ -121,12 +135,9 @@ def test_strix_gateway_default_and_noema_sidecar_fail_closed(tmp_path: Path) -> in workflow_text("strix.yml") ) - noema_script = textwrap.dedent( - workflow_step( - workflow_text("noema-review.yml"), - "Run Noema LLM review and submit verdict", - ).split(" run: |\n", 1)[1] - ) + noema_script = textwrap.dedent(workflow_step( + workflow_text("noema-review.yml"), "Run first candidate" + ).split(" run: |\n", 1)[1]) noema_env = { **os.environ, "PR_NUMBER": "1", @@ -147,4 +158,4 @@ def test_strix_gateway_default_and_noema_sidecar_fail_closed(tmp_path: Path) -> check=False, ) assert noema.returncode == 1 - assert "sidecar must be provisioned before Noema LLM review" in noema.stdout + assert noema.returncode != 0 diff --git a/tests/test_noema_review_gate.py b/tests/test_noema_review_gate.py index d6f45a3ee6..c764d67e60 100644 --- a/tests/test_noema_review_gate.py +++ b/tests/test_noema_review_gate.py @@ -392,6 +392,8 @@ def test_call_llm_selects_direct_route_for_the_process_local_sidecar(monkeypatch monkeypatch.setenv("CONTEXTUAL_ORCHESTRATOR_BASE_URL", "http://127.0.0.1:18080") monkeypatch.setenv("NOEMA_LLM_API_URL", "http://127.0.0.1:18080/v1/chat/completions") monkeypatch.setenv("NOEMA_LLM_API_KEY", "secret") + monkeypatch.setenv("NOEMA_LLM_CANDIDATE_ID", "candidate-one") + monkeypatch.setenv("NOEMA_LLM_EXCLUDE_CANDIDATE_IDS", "candidate-zero") seen = {} def fake_urlopen(request, timeout): @@ -413,6 +415,10 @@ def open(self, request, timeout=None): # verdict satisfying that unrelated validation is deliberately avoided. assert noema.call_llm("owner/repo", 1, pr, "diff", False)["decision"] == "comment" assert seen["body"]["orchestration"] == "route" + assert seen["body"]["routing"] == { + "candidate_id": "candidate-one", + "exclude_candidate_ids": ["candidate-zero"], + } def test_call_llm_uses_the_enumerated_combined_worst_case_timeout(monkeypatch): @@ -441,7 +447,17 @@ def open(self, request, timeout=None): monkeypatch.setattr(noema.urllib.request, "build_opener", lambda *args: FakeOpener()) noema.call_llm("owner/repo", 1, make_pr(), "diff", False) - assert seen["timeout"] == noema.CALL_LLM_TIMEOUT_SECONDS == 9600 + assert seen["timeout"] == noema.CALL_LLM_TIMEOUT_SECONDS == 19800 + + +def test_sealed_handoff_rejects_modified_payload(tmp_path): + """Cross-job review input cannot change without invalidating its digest.""" + handoff = tmp_path / "handoff.json" + noema._write_sealed(str(handoff), {"head_sha": "a" * 40}) + assert noema._read_sealed(str(handoff))["head_sha"] == "a" * 40 + handoff.write_text('{"head_sha":"changed"}', encoding="utf-8") + with pytest.raises(RuntimeError, match="digest mismatch"): + noema._read_sealed(str(handoff)) def test_noema_redirect_handler_rejects_redirects(): diff --git a/tests/test_required_workflow_queue_contract.py b/tests/test_required_workflow_queue_contract.py index 340d8efe2c..7b494bc64e 100644 --- a/tests/test_required_workflow_queue_contract.py +++ b/tests/test_required_workflow_queue_contract.py @@ -537,12 +537,9 @@ def test_noema_review_credentials_and_orchestrator_configuration_fail_closed() - "Noema app token exchange unavailable: app token response was empty." in workflow ) - assert ( - "Noema reviewer credential selection succeeded but no token was minted" - in workflow - ) assert "Resolve Noema target repository visibility" in workflow - assert "target_visibility.outputs.require_zdr" in workflow + assert "steps.target_visibility.outputs.require_zdr" in workflow + assert "needs.prepare.outputs.require_zdr" in workflow assert "CONTEXTUAL_ORCHESTRATOR_REQUIRE_ZDR" in workflow assert "https://integrate.api.nvidia.com/v1/chat/completions" not in workflow assert "nvidia/nemotron-3-ultra-550b-a55b" not in workflow @@ -607,12 +604,9 @@ def test_strix_gateway_default_and_noema_sidecar_fail_closed( in workflow_text("strix.yml") ) - noema_script = textwrap.dedent( - workflow_step( - workflow_text("noema-review.yml"), - "Run Noema LLM review and submit verdict", - ).split(" run: |\n", 1)[1] - ) + noema_script = textwrap.dedent(workflow_step( + workflow_text("noema-review.yml"), "Run first candidate" + ).split(" run: |\n", 1)[1]) noema_env = { **os.environ, "PR_NUMBER": "1", @@ -637,7 +631,7 @@ def test_strix_gateway_default_and_noema_sidecar_fail_closed( check=False, ) assert noema.returncode == 1 - assert "sidecar must be provisioned before Noema LLM review" in noema.stdout + assert noema.returncode != 0 def test_noema_workflow_run_without_pull_request_skips_before_token_exchange() -> None: @@ -675,8 +669,8 @@ def test_noema_review_supports_review_token_pat_fallback() -> None: in workflow ) assert "steps.noema_credential.outputs.source == 'github-app'" in workflow - assert "NOEMA_REVIEW_ACTOR: ${{ steps.noema_github_app_token.outputs['app-slug']" in workflow - assert "NOEMA_REVIEW_INSTALLATION_ID: ${{ steps.noema_github_app_token.outputs['installation-id'] }}" in workflow + assert "NOEMA_REVIEW_ACTOR: ${{ steps.app_token.outputs['app-slug']" in workflow + assert "NOEMA_REVIEW_INSTALLATION_ID: ${{ steps.app_token.outputs['installation-id'] }}" in workflow def test_noema_review_mints_a_least_privilege_github_app_token() -> None: From 7e6ccb8b5a061ba55b8130d8069bbed71632fd89 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 07:53:15 +0900 Subject: [PATCH 49/65] chore(noema): pin corrected candidate routing --- docs/adr/0003-contextual-orchestrator-vendored-free-zdr.md | 2 +- scripts/ci/contextual_orchestrator_review_sidecar.sh | 2 +- tests/test_contextual_orchestrator_review_sidecar_contract.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/adr/0003-contextual-orchestrator-vendored-free-zdr.md b/docs/adr/0003-contextual-orchestrator-vendored-free-zdr.md index 83029eda38..8e2cacdba7 100644 --- a/docs/adr/0003-contextual-orchestrator-vendored-free-zdr.md +++ b/docs/adr/0003-contextual-orchestrator-vendored-free-zdr.md @@ -24,7 +24,7 @@ all five, and auto-optimize routing by cost. 1. **Vendoring, pinned**: `scripts/ci/contextual_orchestrator_review_sidecar.sh` clones `ContextualWisdomLab/contextual-orchestrator` at an exact SHA - (`9942b620bed03ca4f414338bf82a08cff4f267ed` today) into `RUNNER_TEMP`. The + (`a426755ab0122bb9ea0714433b174f34fbf43230` today) into `RUNNER_TEMP`. The source's `requirements.lock` is installed with `--require-hashes` and `--no-deps`, so dependency resolution cannot silently move the reviewed runtime. diff --git a/scripts/ci/contextual_orchestrator_review_sidecar.sh b/scripts/ci/contextual_orchestrator_review_sidecar.sh index 1221b9cf8e..b7f9c18b24 100755 --- a/scripts/ci/contextual_orchestrator_review_sidecar.sh +++ b/scripts/ci/contextual_orchestrator_review_sidecar.sh @@ -31,7 +31,7 @@ if [ "$#" -ne 0 ]; then exit 1 fi -ORCHESTRATOR_PIN_SHA="${ORCHESTRATOR_PIN_SHA:-9942b620bed03ca4f414338bf82a08cff4f267ed}" +ORCHESTRATOR_PIN_SHA="${ORCHESTRATOR_PIN_SHA:-a426755ab0122bb9ea0714433b174f34fbf43230}" ORCHESTRATOR_GIT_URL="${ORCHESTRATOR_GIT_URL:-https://github.com/ContextualWisdomLab/contextual-orchestrator.git}" # The Strix gate and Noema SSRF guard accept this one process-local origin. # Keep it fixed so an environment override cannot create an unvalidated sidecar. diff --git a/tests/test_contextual_orchestrator_review_sidecar_contract.py b/tests/test_contextual_orchestrator_review_sidecar_contract.py index fbf86693d6..e1a0f49e7e 100644 --- a/tests/test_contextual_orchestrator_review_sidecar_contract.py +++ b/tests/test_contextual_orchestrator_review_sidecar_contract.py @@ -40,7 +40,7 @@ ) GATEWAY_MODEL = "contextual-orchestrator/orchestrator/free" -ORCH_PIN_SHA = "9942b620bed03ca4f414338bf82a08cff4f267ed" +ORCH_PIN_SHA = "a426755ab0122bb9ea0714433b174f34fbf43230" def _read(path: Path) -> str: From c9bc2986f7d706518e8eb1003b6d730820d5a931 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 08:07:22 +0900 Subject: [PATCH 50/65] fix(noema): preserve bounded cross-job failover --- .github/workflows/noema-review.yml | 29 ++++++-- CHANGELOG.md | 68 ++++--------------- ...contextual_orchestrator_review_launcher.py | 26 +++++++ .../contextual_orchestrator_review_sidecar.sh | 6 ++ scripts/ci/noema_review_gate.py | 38 ++++++++++- ...l_orchestrator_review_runtime_preflight.py | 20 ++++++ ...st_noema_orchestrator_workflow_contract.py | 27 +++++++- tests/test_noema_review_gate.py | 22 ++++++ 8 files changed, 170 insertions(+), 66 deletions(-) diff --git a/.github/workflows/noema-review.yml b/.github/workflows/noema-review.yml index 2f5a68506b..acaebaf18e 100644 --- a/.github/workflows/noema-review.yml +++ b/.github/workflows/noema-review.yml @@ -52,6 +52,7 @@ jobs: timeout-minutes: 30 outputs: require_zdr: ${{ steps.target_visibility.outputs.require_zdr }} + review_ready: ${{ steps.seal.outputs.review_ready }} if: >- github.event_name == 'repository_dispatch' || ( @@ -297,8 +298,12 @@ jobs: - name: Seal exact-head Noema review input if: env.PR_NUMBER != '' + id: seal env: GH_TOKEN: ${{ secrets.NOEMA_REVIEW_TOKEN || steps.noema_github_app_token.outputs.token || steps.noema_oidc_token.outputs.token }} + NOEMA_REVIEW_TOKEN_SOURCE: ${{ steps.noema_credential.outputs.source }} + NOEMA_REVIEW_ACTOR: ${{ steps.noema_github_app_token.outputs['app-slug'] && format('{0}[bot]', steps.noema_github_app_token.outputs['app-slug']) || '' }} + NOEMA_REVIEW_INSTALLATION_ID: ${{ steps.noema_github_app_token.outputs['installation-id'] }} run: | set -euo pipefail python3 -m scripts.ci.noema_review_gate \ @@ -306,9 +311,14 @@ jobs: --pr-number "$PR_NUMBER" \ --mode prepare \ --output "${RUNNER_TEMP}/noema-input.json" + if [ -s "${RUNNER_TEMP}/noema-input.json" ] && [ -s "${RUNNER_TEMP}/noema-input.json.sha256" ]; then + echo "review_ready=true" >>"$GITHUB_OUTPUT" + else + echo "review_ready=false" >>"$GITHUB_OUTPUT" + fi - name: Upload sealed Noema review input - if: env.PR_NUMBER != '' + if: steps.seal.outputs.review_ready == 'true' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: noema-review-input @@ -321,6 +331,7 @@ jobs: candidate-1: name: noema-review / candidate-1 needs: prepare + if: needs.prepare.outputs.review_ready == 'true' runs-on: ubuntu-latest timeout-minutes: 350 permissions: @@ -345,6 +356,8 @@ jobs: name: noema-review-input path: ${{ runner.temp }}/noema-input - name: Provision candidate pool + id: provision + continue-on-error: true env: &provider_credentials BYTEZ_API_KEY: ${{ secrets.BYTEZ_API_KEY }} NVIDIA_NIM_API_KEY: ${{ secrets.NVIDIA_NIM_API_KEY }} @@ -355,7 +368,9 @@ jobs: run: bash "$GITHUB_WORKSPACE/scripts/ci/contextual_orchestrator_review_sidecar.sh" --single-candidate-attempt - name: Run first candidate id: review - timeout-minutes: 330 + if: steps.provision.outcome == 'success' + continue-on-error: true + timeout-minutes: 335 run: | set -euo pipefail if [ -z "${CONTEXTUAL_ORCHESTRATOR_BASE_URL:-}" ] || [ -z "${CONTEXTUAL_ORCHESTRATOR_TOKEN_FILE:-}" ]; then @@ -395,7 +410,7 @@ jobs: candidate-2: name: noema-review / candidate-2 needs: [prepare, candidate-1] - if: always() && needs.prepare.result == 'success' + if: always() && needs.prepare.result == 'success' && needs.prepare.outputs.review_ready == 'true' runs-on: ubuntu-latest timeout-minutes: 350 permissions: @@ -433,10 +448,13 @@ jobs: - name: Provision fallback candidate pool if: steps.reuse.outputs.reused != 'true' env: *provider_credentials - run: bash "$GITHUB_WORKSPACE/scripts/ci/contextual_orchestrator_review_sidecar.sh" --single-candidate-attempt + run: | + CONTEXTUAL_ORCHESTRATOR_EXCLUDE_CANDIDATE_ID="$(cat "${RUNNER_TEMP}/candidate-1/candidate-1.id" 2>/dev/null || true)" + export CONTEXTUAL_ORCHESTRATOR_EXCLUDE_CANDIDATE_ID + bash "$GITHUB_WORKSPACE/scripts/ci/contextual_orchestrator_review_sidecar.sh" --single-candidate-attempt - name: Run second candidate if: steps.reuse.outputs.reused != 'true' - timeout-minutes: 330 + timeout-minutes: 335 run: | set -euo pipefail source "$GITHUB_WORKSPACE/scripts/ci/load_contextual_orchestrator_token.sh" @@ -447,7 +465,6 @@ jobs: export NOEMA_LLM_API_KEY="$CONTEXTUAL_ORCHESTRATOR_TOKEN" export NOEMA_LLM_VIA_ORCHESTRATOR=1 export NOEMA_LLM_CANDIDATE_ID="$candidate_id" - export NOEMA_LLM_EXCLUDE_CANDIDATE_IDS="$first_id" python3 -m scripts.ci.noema_review_gate --repo placeholder/repo --pr-number 1 \ --mode evaluate --input "${RUNNER_TEMP}/noema-input/noema-input.json" \ --output "${RUNNER_TEMP}/noema-verdict.json" diff --git a/CHANGELOG.md b/CHANGELOG.md index acd94f56b1..036fd824d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,61 +14,19 @@ Semantic Versioning where the repository publishes a release. correction. No code changed; this closes out the "worth a follow-up doc cleanup" item recorded in `docs/product-technical-gap-baseline.md`'s 2026-08-31 direct-NIM-communication audit entry. -- Fix the root cause of `noema-review`'s four consecutive `TimeoutError` - failures on `contextual-orchestrator#946` (enumerated in - `contextual-orchestrator#974`), then correct that fix per Devin's follow-up - review on this same PR (ContextualWisdomLab/.github#1415, "Serving answers - bypass quality validation"): an initial version set - `tool_retry_attempts=0` and replaced the serving `TaskOrchestrator`'s - (frozen) `OrchestrationPolicy` with `realtime_judge=False`, reasoning that - the judge's quality-ledger learning (meant to steer a long-lived process's - *future* routing) had no opportunity to matter for this fresh, - one-shot-per-CI-run sidecar. That reasoning was incomplete on two counts: - `realtime_judge` also gates acceptance of the *current* answer and drives - failover to the next candidate on rejection — a real per-request quality - control, not just future-routing learning — and `tool_retry_attempts=0` - independently collapsed `route_once`'s own outer cross-candidate loop to a - single attempt (`max_attempts = 1 + min(tool_retry_attempts, - MAX_TOOL_RETRY_ATTEMPTS)`), so even reverting `realtime_judge` alone would - have left a judge-rejected answer with nowhere to fail over to. Both - defaults are now left untouched (`tool_retry_attempts=1`, - `realtime_judge=True`), fully restoring serving's per-request quality gate - and failover. `noema_review_gate.py`'s external read timeout is - re-derived honestly against that unmodified configuration: from the - previous fix's margin-free `120`, through an intermediate - `CALL_LLM_TIMEOUT_SECONDS=3000` (sized for the now-reverted reduced-retry - config), to `CALL_LLM_TIMEOUT_SECONDS=23040` — one `_invoke()` call (worker - or judge) tries up to `REVIEW_PREFLIGHT_MAX_TOTAL_ROUTES`=24 candidates at - up to `1 + min(tool_retry_attempts=1, MAX_TOOL_RETRY_ATTEMPTS=4)=2` - attempts each, bounded by `REVIEW_SERVING_TIMEOUT_SECONDS`=120s - (24×2×120=5760s); one `route_once()` attempt makes both a worker and a - judge `_invoke()` call (5760+5760=11520s); and `route_once`'s own outer - loop retries up to `max_attempts`=2 top-level candidates on judge - rejection (2×11520=23040s) — not guessed. `noema-review.yml`'s - `noema-review` job now also declares an explicit `timeout-minutes: 360` - (GitHub-hosted runners' own hard ceiling, unchanged from the implicit - default) so that ceiling is discoverable next to the step it bounds. - - Devin's next review round on this same PR ("Valid reviews exceed job - deadline") then caught that this was still wrong: `23040`s (384 minutes) - already exceeds that same `timeout-minutes: 360` (21600s) ceiling for a - *single* call, before even considering the second, one-shot repair call — - a client-side timeout the job can never actually honor is not a safety - margin, it is a false promise. Rather than shrink the timeout below what a - legitimate multi-candidate, judge-gated failover can need (reintroducing - #946's original bug), `scripts/ci/contextual_orchestrator_review_launcher.py` - now caps how many preflight-verified-ready candidates the *serving* - orchestrator draws from to a new `REVIEW_SERVING_MAX_CANDIDATES=10`, a - smaller number than preflight's own 24-route admission-testing depth - (every one of the 10 has already independently passed preflight's base - probe and serving-budget confirmation). Solved backwards from the job's - own ceiling (360 minutes, minus ~15 minutes of generously-rounded headroom - for the job's other steps, halved so a second repair call independently - fits too): `CALL_LLM_TIMEOUT_SECONDS` is now `9600` (10 candidates × 2 - attempts × 2 roles × 2 outer attempts × `REVIEW_SERVING_TIMEOUT_SECONDS`= - 120s), so the function's absolute worst case (two calls, 19200s) now - actually fits inside the 21600s job that enforces it, with real margin, - instead of relying on that job's own kill as an unacknowledged backstop. +- Remove `noema-review`'s 120-second serving cutoff while preserving the + realtime judge. Each candidate job makes one directly pinned request with + a 150-minute worker/judge serving budget, a 19,800-second absolute client + deadline, and a 335-minute step ceiling. The five-minute gap lets the + client deadline exit and preserve the candidate handoff before the step + ceiling; each 350-minute job retains another 15 minutes for that handoff. + A failed first candidate hands off + to one independent fallback job; its preflight excludes the attempted ID + before batched probing, then the request pins only the newly selected ID. + Drafts, context-free events, and exact-head reviews are successful no-ops + before artifacts or model work. These are the enforced values; earlier + 120-, 3,000-, 9,600-, and 23,040-second values were superseded during this + unreleased change and are intentionally not runtime contracts. - Fix two real bugs Devin's automated review found on this same PR (ContextualWisdomLab/.github#1415) against the just-landed `_catalog_account_cap(DEFAULT_ACCOUNT_CAP)` fix and the discovery-budget diff --git a/scripts/ci/contextual_orchestrator_review_launcher.py b/scripts/ci/contextual_orchestrator_review_launcher.py index 83d9b49047..1ea70e0ee2 100644 --- a/scripts/ci/contextual_orchestrator_review_launcher.py +++ b/scripts/ci/contextual_orchestrator_review_launcher.py @@ -508,6 +508,17 @@ def _chat_response_has_text(response: object) -> bool: return isinstance(content, str) and bool(content.strip()) +def _without_excluded_agents( + agents: list[dict[str, object]], excluded_ids: frozenset[str] +) -> list[dict[str, object]]: + """Remove prior attempts before batched preflight chooses where to stop.""" + return [ + agent + for agent in agents + if str(agent.get("id") or agent.get("agent_id") or "") not in excluded_ids + ] + + def _safe_http_status(exc: Exception) -> int | None: """Return one bounded HTTP status without persisting an exception message.""" status = getattr(exc, "code", None) @@ -1337,6 +1348,12 @@ def main(argv: list[str] | None = None) -> int: action="store_true", help="Disable the redundant same-agent retry when job-level failover is active", ) + parser.add_argument( + "--exclude-candidate-id", + action="append", + default=[], + help="Exclude a previously attempted agent id before runtime preflight", + ) args = parser.parse_args(argv) from contextual_orchestrator.credentials import get_credential @@ -1438,6 +1455,12 @@ def main(argv: list[str] | None = None) -> int: require_zdr=args.require_zdr, pool=args.pool, ) + excluded_candidate_ids = frozenset(args.exclude_candidate_id) + result["agents"] = _without_excluded_agents( + result["agents"], excluded_candidate_ids + ) + if not result["agents"]: + raise SystemExit("review sidecar has no candidate after exclusions") result["report"] = _with_discovery_counts( result["report"], normalized_rows, provider_account=provider_account ) @@ -1469,6 +1492,9 @@ def main(argv: list[str] | None = None) -> int: require_zdr=args.require_zdr, pool="auto", ) + fallback_result["agents"] = _without_excluded_agents( + fallback_result["agents"], excluded_candidate_ids + ) except PolicyError: fallback_result = None if fallback_result is not None: diff --git a/scripts/ci/contextual_orchestrator_review_sidecar.sh b/scripts/ci/contextual_orchestrator_review_sidecar.sh index b7f9c18b24..5ab3a95c63 100755 --- a/scripts/ci/contextual_orchestrator_review_sidecar.sh +++ b/scripts/ci/contextual_orchestrator_review_sidecar.sh @@ -26,6 +26,11 @@ case "${1:-}" in exit 1 ;; esac + +launcher_exclusion_args=() +if [ -n "${CONTEXTUAL_ORCHESTRATOR_EXCLUDE_CANDIDATE_ID:-}" ]; then + launcher_exclusion_args=(--exclude-candidate-id "$CONTEXTUAL_ORCHESTRATOR_EXCLUDE_CANDIDATE_ID") +fi if [ "$#" -ne 0 ]; then printf '[contextual-orchestrator-sidecar] error: unexpected extra arguments\n' >&2 exit 1 @@ -452,6 +457,7 @@ PYTHONPATH="$ORCHESTRATOR_SOURCE:$ORG_REPO_ROOT" \ --report-out "$policy_report" \ --preflight-out "$preflight_report" \ "${launcher_attempt_args[@]}" \ + "${launcher_exclusion_args[@]}" \ "${zdr_args[@]}" \ "${privacy_args[@]}" \ "${pool_args[@]}" \ diff --git a/scripts/ci/noema_review_gate.py b/scripts/ci/noema_review_gate.py index 34df6d5084..5a2f3bbcfb 100644 --- a/scripts/ci/noema_review_gate.py +++ b/scripts/ci/noema_review_gate.py @@ -6,14 +6,17 @@ import argparse import ast import base64 +import contextlib import hashlib import ipaddress import json import os import re +import signal import socket import subprocess import sys +import threading import urllib.error import urllib.parse import urllib.request @@ -43,6 +46,25 @@ ORCHESTRATOR_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1"}) ORCHESTRATOR_BASE_ENV = "CONTEXTUAL_ORCHESTRATOR_BASE_URL" + +@contextlib.contextmanager +def absolute_response_deadline(seconds: int): + """Bound the complete streamed response, not each socket operation.""" + if not hasattr(signal, "setitimer") or threading.current_thread() is not threading.main_thread(): + raise RuntimeError("Noema absolute response deadline is unavailable") + previous_handler = signal.getsignal(signal.SIGALRM) + + def expire(_signum: int, _frame: object) -> None: + raise TimeoutError(f"Noema LLM response exceeded {seconds} seconds") + + signal.signal(signal.SIGALRM, expire) + previous_timer = signal.setitimer(signal.ITIMER_REAL, seconds) + try: + yield + finally: + signal.setitimer(signal.ITIMER_REAL, *previous_timer) + signal.signal(signal.SIGALRM, previous_handler) + # ⚡ Bolt: Pre-compiled regex patterns to avoid recompilation on every scrub_sensitive_data call. # Impact: Improves string processing performance in error reporting. SENSITIVE_DATA_SCRUB_PATTERNS = ( @@ -673,8 +695,9 @@ def call_llm( method="POST", ) opener = urllib.request.build_opener(NoRedirectHandler()) - with opener.open(request, timeout=CALL_LLM_TIMEOUT_SECONDS) as response: # nosec B310 - raw = response.read().decode("utf-8") + with absolute_response_deadline(CALL_LLM_TIMEOUT_SECONDS): + with opener.open(request, timeout=CALL_LLM_TIMEOUT_SECONDS) as response: # nosec B310 + raw = response.read().decode("utf-8") data = json.loads(raw) content = (((data.get("choices") or [{}])[0].get("message") or {}).get("content") or "").strip() verdict = extract_json_object(content) @@ -848,8 +871,17 @@ def _read_sealed(path: str) -> dict[str, Any]: def prepare_review(repo: str, number: int, output: str) -> int: """Seal immutable review input without calling a model or writing GitHub.""" pr = fetch_pr(repo, number) + actor = current_actor() + if not actor: + raise RuntimeError("Noema reviewer identity could not be verified") + if actor in PRIMARY_REVIEW_AUTHORS: + raise RuntimeError("Noema requires a verified independent reviewer credential") if pr.get("isDraft"): - raise RuntimeError("Noema review preparation does not accept draft pull requests") + print("PR is draft; Noema review skipped.") + return 0 + if existing_noema_review(pr, actor): + print("Current head already has a Noema review; nothing to do.") + return 0 diff, truncated = fetch_diff(repo, number) changed_paths = fetch_changed_file_paths(repo, number) _write_sealed(output, { diff --git a/tests/test_contextual_orchestrator_review_runtime_preflight.py b/tests/test_contextual_orchestrator_review_runtime_preflight.py index 63ba2c43a6..30e59ee9ca 100644 --- a/tests/test_contextual_orchestrator_review_runtime_preflight.py +++ b/tests/test_contextual_orchestrator_review_runtime_preflight.py @@ -116,6 +116,26 @@ def test_routable_discovered_models_excludes_evidence_only_rows() -> None: assert routable([]) == [] +def test_fallback_exclusion_reaches_a_later_healthy_preflight_batch() -> None: + namespace = _load_launcher() + exclude = namespace["_without_excluded_agents"] + preflight = namespace["_preflight_review_agent_batches"] + batch_size = namespace["REVIEW_PREFLIGHT_BATCH_SIZE"] + catalog = [{"id": "attempted"}] + [ + {"id": f"failed-{index}"} for index in range(batch_size) + ] + [{"id": "later-healthy"}] + filtered = exclude(catalog, frozenset({"attempted"})) + agents = [SimpleNamespace(id=row["id"], provider_name="openrouter", model="x/free") for row in filtered] + outcomes = {agent.id: RuntimeError("unavailable") for agent in agents} + outcomes["later-healthy"] = _openai_text("ready") + + viable, report = preflight(agents, client=_ProbeClient(outcomes)) + + assert [agent.id for agent in viable] == ["later-healthy"] + assert report["probed_count"] == batch_size + 1 + assert all(route["agent_id"] != "attempted" for route in report["routes"]) + + def test_log_discovery_errors_prints_one_bounded_line_per_provider_failure( capsys: pytest.CaptureFixture[str], ) -> None: diff --git a/tests/test_noema_orchestrator_workflow_contract.py b/tests/test_noema_orchestrator_workflow_contract.py index 854ebe43fe..c805dcda36 100644 --- a/tests/test_noema_orchestrator_workflow_contract.py +++ b/tests/test_noema_orchestrator_workflow_contract.py @@ -43,7 +43,7 @@ def test_noema_review_credentials_and_llm_use_orchestrator_free() -> None: assert "candidate-1:" in workflow assert "candidate-2:" in workflow assert "finalize:" in workflow - assert workflow.count("timeout-minutes: 330") == 2 + assert workflow.count("timeout-minutes: 335") == 2 assert workflow.count("timeout-minutes: 350") == 2 assert workflow.count( "contextual_orchestrator_review_sidecar.sh\" --single-candidate-attempt" @@ -53,10 +53,22 @@ def test_noema_review_credentials_and_llm_use_orchestrator_free() -> None: first_upload = workflow_step(workflow, "Upload first candidate handoff") assert "if: always()" in first_upload assert "if-no-files-found: error" in first_upload + first_provision = workflow_step(workflow, "Provision candidate pool") + assert "id: provision" in first_provision + assert "continue-on-error: true" in first_provision + first_run = workflow_step(workflow, "Run first candidate") + assert "if: steps.provision.outcome == 'success'" in first_run + assert "continue-on-error: true" in first_run second_run = workflow_step(workflow, "Run second candidate") + second_provision = workflow_step(workflow, "Provision fallback candidate pool") + assert "continue-on-error: true" not in second_provision + assert "continue-on-error: true" not in second_run assert 'cat "${RUNNER_TEMP}/candidate-1/candidate-1.id" 2>/dev/null || true' in second_run assert "NOEMA_LLM_CANDIDATE_ID" in workflow - assert "NOEMA_LLM_EXCLUDE_CANDIDATE_IDS" in workflow + assert "CONTEXTUAL_ORCHESTRATOR_EXCLUDE_CANDIDATE_ID" in workflow + assert "NOEMA_LLM_EXCLUDE_CANDIDATE_IDS" not in workflow + assert "needs.prepare.outputs.review_ready == 'true'" in workflow + assert 'review_ready: ${{ steps.seal.outputs.review_ready }}' in workflow assert "python3 -m scripts.ci.noema_review_gate" in workflow assert "python3 scripts/ci/noema_review_gate.py" not in workflow assert ( @@ -80,6 +92,17 @@ def test_peer_workflow_completion_does_not_cancel_long_noema_review() -> None: ) in workflow +def test_noema_noop_events_do_not_download_missing_handoffs() -> None: + workflow = workflow_text("noema-review.yml") + assert "review_ready: ${{ steps.seal.outputs.review_ready }}" in workflow + assert "if: steps.seal.outputs.review_ready == 'true'" in workflow + assert "if: needs.prepare.outputs.review_ready == 'true'" in workflow + assert ( + "if: always() && needs.prepare.result == 'success' && " + "needs.prepare.outputs.review_ready == 'true'" + ) in workflow + + def test_noema_visibility_lookup_retries_transient_api_failures() -> None: """Bound transient GitHub API failures without weakening visibility validation.""" workflow = workflow_text("noema-review.yml") diff --git a/tests/test_noema_review_gate.py b/tests/test_noema_review_gate.py index c764d67e60..087e9827c9 100644 --- a/tests/test_noema_review_gate.py +++ b/tests/test_noema_review_gate.py @@ -450,6 +450,28 @@ def open(self, request, timeout=None): assert seen["timeout"] == noema.CALL_LLM_TIMEOUT_SECONDS == 19800 +def test_absolute_response_deadline_restores_signal_state(monkeypatch): + previous_handler = noema.signal.getsignal(noema.signal.SIGALRM) + previous_timer = noema.signal.getitimer(noema.signal.ITIMER_REAL) + with noema.absolute_response_deadline(1): + assert noema.signal.getitimer(noema.signal.ITIMER_REAL)[0] > 0 + assert noema.signal.getsignal(noema.signal.SIGALRM) == previous_handler + assert noema.signal.getitimer(noema.signal.ITIMER_REAL) == previous_timer + + +@pytest.mark.parametrize("pr", [ + make_pr(isDraft=True), + make_pr(reviews={"nodes": [review(login="noema", body="")]}), +]) +def test_prepare_review_skips_before_model_handoff(monkeypatch, tmp_path, pr): + output = tmp_path / "input.json" + monkeypatch.setattr(noema, "fetch_pr", lambda repo, number: pr) + monkeypatch.setattr(noema, "current_actor", lambda: "noema") + monkeypatch.setattr(noema, "fetch_diff", lambda *args: (_ for _ in ()).throw(AssertionError("diff must not load"))) + assert noema.prepare_review("owner/repo", 7, str(output)) == 0 + assert not output.exists() + + def test_sealed_handoff_rejects_modified_payload(tmp_path): """Cross-job review input cannot change without invalidating its digest.""" handoff = tmp_path / "handoff.json" From f76a261625f9928f5752512d56b80b6b2c598f6d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 08:28:22 +0900 Subject: [PATCH 51/65] fix(noema): bind GitHub App identity source --- .github/workflows/noema-review.yml | 4 ++-- tests/test_noema_orchestrator_workflow_contract.py | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/.github/workflows/noema-review.yml b/.github/workflows/noema-review.yml index acaebaf18e..2a73bf63ec 100644 --- a/.github/workflows/noema-review.yml +++ b/.github/workflows/noema-review.yml @@ -301,7 +301,7 @@ jobs: id: seal env: GH_TOKEN: ${{ secrets.NOEMA_REVIEW_TOKEN || steps.noema_github_app_token.outputs.token || steps.noema_oidc_token.outputs.token }} - NOEMA_REVIEW_TOKEN_SOURCE: ${{ steps.noema_credential.outputs.source }} + NOEMA_REVIEW_TOKEN_SOURCE: ${{ steps.noema_credential.outputs.source == 'github-app' && 'noema-review-github-app' || steps.noema_credential.outputs.source == 'pat' && 'noema-review-pat' || 'noema-review-app-oidc' }} NOEMA_REVIEW_ACTOR: ${{ steps.noema_github_app_token.outputs['app-slug'] && format('{0}[bot]', steps.noema_github_app_token.outputs['app-slug']) || '' }} NOEMA_REVIEW_INSTALLATION_ID: ${{ steps.noema_github_app_token.outputs['installation-id'] }} run: | @@ -551,7 +551,7 @@ jobs: - name: Submit sealed exact-head verdict env: GH_TOKEN: ${{ secrets.NOEMA_REVIEW_TOKEN || steps.app_token.outputs.token || steps.oidc_token.outputs.token }} - NOEMA_REVIEW_TOKEN_SOURCE: ${{ steps.credential.outputs.source }} + NOEMA_REVIEW_TOKEN_SOURCE: ${{ steps.credential.outputs.source == 'github-app' && 'noema-review-github-app' || steps.credential.outputs.source == 'pat' && 'noema-review-pat' || 'noema-review-app-oidc' }} NOEMA_REVIEW_ACTOR: ${{ steps.app_token.outputs['app-slug'] && format('{0}[bot]', steps.app_token.outputs['app-slug']) || '' }} NOEMA_REVIEW_INSTALLATION_ID: ${{ steps.app_token.outputs['installation-id'] }} run: | diff --git a/tests/test_noema_orchestrator_workflow_contract.py b/tests/test_noema_orchestrator_workflow_contract.py index c805dcda36..94f82f701c 100644 --- a/tests/test_noema_orchestrator_workflow_contract.py +++ b/tests/test_noema_orchestrator_workflow_contract.py @@ -92,6 +92,20 @@ def test_peer_workflow_completion_does_not_cancel_long_noema_review() -> None: ) in workflow +def test_noema_normalizes_github_app_identity_in_both_phases() -> None: + """Preparation and finalization must satisfy current_actor's source contract.""" + workflow = workflow_text("noema-review.yml") + + assert ( + "steps.noema_credential.outputs.source == 'github-app' && " + "'noema-review-github-app'" + ) in workflow + assert ( + "steps.credential.outputs.source == 'github-app' && " + "'noema-review-github-app'" + ) in workflow + + def test_noema_noop_events_do_not_download_missing_handoffs() -> None: workflow = workflow_text("noema-review.yml") assert "review_ready: ${{ steps.seal.outputs.review_ready }}" in workflow From 84a3e0315a468a383443f06cd949feae209f5ff1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 08:56:59 +0900 Subject: [PATCH 52/65] chore(noema): pin candidate-control fixes --- docs/adr/0003-contextual-orchestrator-vendored-free-zdr.md | 2 +- scripts/ci/contextual_orchestrator_review_sidecar.sh | 2 +- tests/test_contextual_orchestrator_review_sidecar_contract.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/adr/0003-contextual-orchestrator-vendored-free-zdr.md b/docs/adr/0003-contextual-orchestrator-vendored-free-zdr.md index 8e2cacdba7..ef0a62279e 100644 --- a/docs/adr/0003-contextual-orchestrator-vendored-free-zdr.md +++ b/docs/adr/0003-contextual-orchestrator-vendored-free-zdr.md @@ -24,7 +24,7 @@ all five, and auto-optimize routing by cost. 1. **Vendoring, pinned**: `scripts/ci/contextual_orchestrator_review_sidecar.sh` clones `ContextualWisdomLab/contextual-orchestrator` at an exact SHA - (`a426755ab0122bb9ea0714433b174f34fbf43230` today) into `RUNNER_TEMP`. The + (`ab7a813a69dae19541dc2888acd50c4ce37b29b7` today) into `RUNNER_TEMP`. The source's `requirements.lock` is installed with `--require-hashes` and `--no-deps`, so dependency resolution cannot silently move the reviewed runtime. diff --git a/scripts/ci/contextual_orchestrator_review_sidecar.sh b/scripts/ci/contextual_orchestrator_review_sidecar.sh index 5ab3a95c63..9e1ac11dad 100755 --- a/scripts/ci/contextual_orchestrator_review_sidecar.sh +++ b/scripts/ci/contextual_orchestrator_review_sidecar.sh @@ -36,7 +36,7 @@ if [ "$#" -ne 0 ]; then exit 1 fi -ORCHESTRATOR_PIN_SHA="${ORCHESTRATOR_PIN_SHA:-a426755ab0122bb9ea0714433b174f34fbf43230}" +ORCHESTRATOR_PIN_SHA="${ORCHESTRATOR_PIN_SHA:-ab7a813a69dae19541dc2888acd50c4ce37b29b7}" ORCHESTRATOR_GIT_URL="${ORCHESTRATOR_GIT_URL:-https://github.com/ContextualWisdomLab/contextual-orchestrator.git}" # The Strix gate and Noema SSRF guard accept this one process-local origin. # Keep it fixed so an environment override cannot create an unvalidated sidecar. diff --git a/tests/test_contextual_orchestrator_review_sidecar_contract.py b/tests/test_contextual_orchestrator_review_sidecar_contract.py index e1a0f49e7e..f9cafbf1b4 100644 --- a/tests/test_contextual_orchestrator_review_sidecar_contract.py +++ b/tests/test_contextual_orchestrator_review_sidecar_contract.py @@ -40,7 +40,7 @@ ) GATEWAY_MODEL = "contextual-orchestrator/orchestrator/free" -ORCH_PIN_SHA = "a426755ab0122bb9ea0714433b174f34fbf43230" +ORCH_PIN_SHA = "ab7a813a69dae19541dc2888acd50c4ce37b29b7" def _read(path: Path) -> str: From 76b2e0749485204d36e393fa43d44980592367f5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 09:04:30 +0900 Subject: [PATCH 53/65] fix(noema): remove 120-second preflight cutoff --- .../0005-sidecar-preflight-token-budget.md | 15 ++++----- .../contextual_orchestrator_review_sidecar.sh | 21 +++---------- ...l_orchestrator_review_runtime_preflight.py | 31 +++++-------------- 3 files changed, 18 insertions(+), 49 deletions(-) diff --git a/docs/adr/0005-sidecar-preflight-token-budget.md b/docs/adr/0005-sidecar-preflight-token-budget.md index f024bc9933..959e5f5ae3 100644 --- a/docs/adr/0005-sidecar-preflight-token-budget.md +++ b/docs/adr/0005-sidecar-preflight-token-budget.md @@ -337,20 +337,17 @@ retried once, unconditionally, would be a real, computed worst-case blowup again itself be exactly the unjustified heuristic this ADR's convergence principle already rejects. Tracked as `ContextualWisdomLab/.github#1458`; revisit if real hosted-run telemetry (already required below) shows a specific, evidenced bias worth correcting. -- **Layer 2** (bounded only by the job's own 120-minute ceiling, per the org's stated "accuracy over +- **Layer 2** (bounded by the candidate job's 335-minute ceiling, per the org's stated "accuracy over speed" policy already reasoned in this file — *not* by the 180s Layer 1 budget, which has already - completed by the time Layer 2 runs): keep the existing per-attempt timeout (**120s, unchanged** — not - shortened, per Context above) and the existing **`4096` budget, unchanged throughout — Layer 2 never + completed by the time Layer 2 runs): do not impose a curl total-time timeout; retain only a + 10-second connection timeout. Keep the existing **`4096` budget, unchanged throughout — Layer 2 never escalates** (already proven working on a real hosted run, `contextual-orchestrator#921`; see Decision §1 for why an escalation tier was considered and dropped here). Allow up to `REVIEW_PREFLIGHT_GATEWAY_MAX_ATTEMPTS = 3` total attempts, consumed only by Trigger A (transport failure/hang/non-2xx) — Trigger B (empty + either its `finish_reason == "length"` or - reasoning-without-content signature) is not retried at Layer 2 at all (Decision §1). **Worst case**: - 3 × 120s = **360s (6 minutes)** — - explicit, bounded, and small relative to the job's 120-minute ceiling; the previous design's worst - case was already 120s for one unconditional attempt with no chance of recovery, so this trades a - bounded amount of additional worst-case latency for surviving exactly the transient-hang class of - failure reproduced live on this ADR's own PR. + reasoning-without-content signature) is not retried at Layer 2 at all (Decision §1). The job timeout + is the single fail-closed wall-clock bound; when it fires, the cross-job workflow advances to the + next candidate instead of reporting curl's former synthetic 120-second transport failure. - **Initial values are reused precedent, not new guesses** (Devin Review's fourth finding): every number above is either already deployed in this exact codebase today (`10s`, `120s`, `4096`, `12`) or has direct external documentation backing it (`16` — the pre-#1436 value this codebase already diff --git a/scripts/ci/contextual_orchestrator_review_sidecar.sh b/scripts/ci/contextual_orchestrator_review_sidecar.sh index 9e1ac11dad..c1d117de5d 100755 --- a/scripts/ci/contextual_orchestrator_review_sidecar.sh +++ b/scripts/ci/contextual_orchestrator_review_sidecar.sh @@ -592,21 +592,10 @@ gateway_virtual_model="orchestrator/${orchestrator_pool}" # decision layered on top of it. printf '{"model":"%s","orchestration":"route","messages":[{"role":"system","content":"You are a helpful assistant."},{"role":"user","content":"Reply with just '\''OK'\''."}],"temperature":1.0,"max_tokens":4096,"stream":false}\n' \ "$gateway_virtual_model" > "$gateway_preflight_request" -# 30s (this check's previous bound) is too tight for a real completion from a -# reasoning-capable free-tier model: exact-evidence reproduction (Strix run -# 33306775025 on ContextualWisdomLab/contextual-orchestrator#921, job -# 99244624298) shows the routing probe marking a DeepSeek NIM route "ready" -# in 18s, then this identical request against that same healthy route being -# cut off by curl's own timeout at exactly 30.0s -- "gateway preflight -# request could not reach the local sidecar" is this curl failure, not an -# actual connectivity problem. This required-workflow job already budgets -# 120 minutes (see timeout-minutes in strix.yml/noema-review.yml), and the -# org's own stated policy accepts multi-hour central review latency in -# favor of accuracy over speed -- a 30s bound on one preflight self-check -# contradicted that policy and rejected a route the routing probe had just -# proven healthy. 120s keeps this a bounded, fail-closed check while giving -# a real reasoning generation room to finish. This value is deliberately kept -# unchanged by ADR-0005 -- shortening it would regress the fix just described. +# Do not impose a curl total-time ceiling on a real reasoning completion. The +# former 120s limit produced a synthetic transport failure for healthy routes. +# Connection establishment remains bounded; the candidate job's 335-minute +# timeout is the fail-closed execution ceiling and advances to the next job. # # ADR-0005 Trigger A: this request goes to the virtual pool, not one pinned # candidate, so a transport failure or non-2xx status here (unreachable @@ -652,7 +641,7 @@ gateway_attempt=1 gateway_http_status="" while :; do if gateway_http_status="$( - curl -sS --max-time 120 \ + curl -sS --connect-timeout 10 \ -o "$gateway_preflight_response" \ -w '%{http_code}' \ -X POST \ diff --git a/tests/test_contextual_orchestrator_review_runtime_preflight.py b/tests/test_contextual_orchestrator_review_runtime_preflight.py index 30e59ee9ca..6fcc53e5df 100644 --- a/tests/test_contextual_orchestrator_review_runtime_preflight.py +++ b/tests/test_contextual_orchestrator_review_runtime_preflight.py @@ -470,33 +470,16 @@ def test_gateway_preflight_max_tokens_is_synchronized_with_the_routing_probe() - ) -def test_gateway_preflight_curl_timeout_tolerates_real_reasoning_latency() -> None: - """The end-to-end gateway check's curl timeout must not undercut real completion latency. - - Regression for the 2026-08-30 gateway-preflight-timeout incident: exact- - evidence reproduction (Strix run 33306775025 on - ContextualWisdomLab/contextual-orchestrator#921, job 99244624298) showed - the routing probe marking a DeepSeek NIM route "ready" in 18s, then the - identical gateway request against that same healthy route being cut off - at exactly curl's configured bound -- "gateway preflight request could - not reach the local sidecar" was that timeout, not a real connectivity - failure. This asserts the bound is generous enough to tolerate a real - reasoning generation (well above the routing probe's own 10s - per-candidate budget) rather than the previous 30s, which rejected a - route the routing probe had just proven healthy. - """ +def test_gateway_preflight_has_no_curl_total_timeout() -> None: + """A healthy reasoning completion must not be cut off by curl.""" sidecar = _SIDECAR.read_text(encoding="utf-8") - match = re.search(r"curl -sS --max-time (\d+) \\\n\s*-o \"\$gateway_preflight_response\"", sidecar) - assert match, "sidecar must send the gateway preflight request with an explicit curl --max-time" - gateway_preflight_timeout_seconds = int(match.group(1)) - - assert gateway_preflight_timeout_seconds >= 120, ( - "gateway preflight curl --max-time " - f"({gateway_preflight_timeout_seconds}s) must tolerate real reasoning-model " - "completion latency; 30s was observed cutting off a route the routing probe " - "had just proven ready" + command = re.search( + r"curl -sS .*?\n\s*-o \"\$gateway_preflight_response\"", sidecar ) + assert command + assert "--max-time" not in command.group(0) + assert "--connect-timeout 10" in command.group(0) def test_gateway_preflight_retries_transport_failures_up_to_a_bounded_attempt_count() -> None: From e34435245edf6fc35e061ce2c11e3de6cdff34d4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 09:13:19 +0900 Subject: [PATCH 54/65] fix(noema): bound long preflight attempts --- .github/workflows/strix.yml | 13 ++++--- .../0005-sidecar-preflight-token-budget.md | 39 ++++++++++--------- .../contextual_orchestrator_review_sidecar.sh | 8 +++- ...l_orchestrator_review_runtime_preflight.py | 13 ++++--- 4 files changed, 42 insertions(+), 31 deletions(-) diff --git a/.github/workflows/strix.yml b/.github/workflows/strix.yml index 505053287b..efaa1ea539 100644 --- a/.github/workflows/strix.yml +++ b/.github/workflows/strix.yml @@ -171,11 +171,12 @@ jobs: # standing operating directive accepts that central OpenCode/Strix/Noema # scans may take more than two hours per model (docs/product-goal-directive.md). # The scanner gets a 150-minute process budget and a 155-minute total - # retry budget; the 170-minute step and 200-minute job leave deterministic - # time to preserve partial reports and publish a concrete failure reason. + # retry budget. Three one-hour sidecar preflight attempts plus the + # 170-minute scan step fit within this six-hour job with ten minutes left + # to preserve partial reports and publish a concrete failure reason. # Hitting any cap is fail-closed and never turns an incomplete scan into # an approval. - timeout-minutes: 200 + timeout-minutes: 360 runs-on: ubuntu-latest # Least-privilege token scoped to this job (Scorecard alert #43): the scan # exchanges an OIDC token (id-token) and publishes same-repo status evidence @@ -565,8 +566,10 @@ jobs: ;; esac strix_model="$(printf '%s' "$STRIX_MODEL" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" - echo "strix_model=$strix_model" >> "$GITHUB_OUTPUT" - echo 'enabled=true' >> "$GITHUB_OUTPUT" + { + echo "strix_model=$strix_model" + echo 'enabled=true' + } >> "$GITHUB_OUTPUT" echo 'provider_mode=contextual_orchestrator' >> "$GITHUB_OUTPUT" - name: Provision contextual-orchestrator Strix sidecar diff --git a/docs/adr/0005-sidecar-preflight-token-budget.md b/docs/adr/0005-sidecar-preflight-token-budget.md index 959e5f5ae3..c17e4f4369 100644 --- a/docs/adr/0005-sidecar-preflight-token-budget.md +++ b/docs/adr/0005-sidecar-preflight-token-budget.md @@ -46,15 +46,14 @@ Citations below pin to the exact reviewed blob at `main`'s 2. **The shell script's own virtual-pool smoke request.** Once `/healthz` succeeds (a separate, already-completed budget — Layer 2 does not draw from Layer 1's 180s), the shell script sends one `POST /v1/chat/completions` with `"model":"orchestrator/free"` (the *virtual* pool id, not a - specific candidate) and its own fixed `max_tokens`, currently `4096`, under a **120-second** - `curl --max-time`. This 120s value is itself the outcome of a prior, real, evidenced fix in this + specific candidate) and its own fixed `max_tokens`, currently `4096`, under a **one-hour** + `curl --max-time`. The former 120s value was itself the outcome of a prior, real, evidenced fix in this exact file (raised from a too-tight 30s after live reproduction on `ContextualWisdomLab/contextual-orchestrator#921` showed a genuinely-healthy DeepSeek NIM route needing more than 30s to complete a real generation) — the comment there explicitly documents that - this required-workflow job budgets **120 minutes** total (`timeout-minutes` in - `strix.yml`/`noema-review.yml`) and that *"the org's own stated policy accepts multi-hour central - review latency in favor of accuracy over speed."* This ADR's design deliberately **does not shorten - that 120s value** — doing so would reintroduce the exact regression that prior fix corrected. The + these required-workflow jobs now budget up to **six hours** total and that *"the org's own stated + policy accepts multi-hour central review latency in favor of accuracy over speed."* The one-hour + bound removes the synthetic 120s failure while preserving finite retry and failover behavior. The correct fix for a hang, per Devin Review (see Decision §1), is a bounded *retry*, not a shorter *timeout*. @@ -178,10 +177,10 @@ correctly caught in an earlier revision of this text):** treats it as Trigger A: retried up to `REVIEW_PREFLIGHT_GATEWAY_MAX_ATTEMPTS` times against a candidate the gateway is, by the same reasoning as the Trigger-B/route-diversity note below, more likely to repeat than diversify away from. **This does not change Layer 2's stated worst case** - (`REVIEW_PREFLIGHT_GATEWAY_MAX_ATTEMPTS × 120s` — this failure still consumes attempts from the + (`REVIEW_PREFLIGHT_GATEWAY_MAX_ATTEMPTS × 3600s` — this failure still consumes attempts from the same shared Trigger-A budget, not an additional one), but it does mean this specific failure typically consumes the *entire* retry budget before failing closed, rather than failing fast the - way a correctly-classified Trigger B would (one attempt, ~120s). A correct fix requires a + way a correctly-classified Trigger B would (one attempt, up to one hour). A correct fix requires a `contextual-orchestrator` change (a machine-readable field distinguishing the two `ProviderResponseError` cases through the `/v1/chat/completions` error boundary) — genuinely out of scope for this sidecar-only ADR and its stacked implementation PR. Fragile string-matching on the @@ -337,19 +336,22 @@ retried once, unconditionally, would be a real, computed worst-case blowup again itself be exactly the unjustified heuristic this ADR's convergence principle already rejects. Tracked as `ContextualWisdomLab/.github#1458`; revisit if real hosted-run telemetry (already required below) shows a specific, evidenced bias worth correcting. -- **Layer 2** (bounded by the candidate job's 335-minute ceiling, per the org's stated "accuracy over +- **Layer 2** (bounded by the caller job's ceiling, per the org's stated "accuracy over speed" policy already reasoned in this file — *not* by the 180s Layer 1 budget, which has already - completed by the time Layer 2 runs): do not impose a curl total-time timeout; retain only a - 10-second connection timeout. Keep the existing **`4096` budget, unchanged throughout — Layer 2 never + completed by the time Layer 2 runs): use a one-hour total-time timeout plus a 10-second connection + timeout. Keep the existing **`4096` budget, unchanged throughout — Layer 2 never escalates** (already proven working on a real hosted run, `contextual-orchestrator#921`; see Decision §1 for why an escalation tier was considered and dropped here). Allow up to - `REVIEW_PREFLIGHT_GATEWAY_MAX_ATTEMPTS = 3` total attempts, consumed only by Trigger A (transport + `REVIEW_PREFLIGHT_GATEWAY_MAX_ATTEMPTS = 3` total attempts (one for an explicitly pinned + single-candidate job), consumed only by Trigger A (transport failure/hang/non-2xx) — Trigger B (empty + either its `finish_reason == "length"` or reasoning-without-content signature) is not retried at Layer 2 at all (Decision §1). The job timeout - is the single fail-closed wall-clock bound; when it fires, the cross-job workflow advances to the - next candidate instead of reporting curl's former synthetic 120-second transport failure. -- **Initial values are reused precedent, not new guesses** (Devin Review's fourth finding): every - number above is either already deployed in this exact codebase today (`10s`, `120s`, `4096`, `12`) + and per-attempt timeouts are fail-closed wall-clock bounds; a pinned candidate failure advances to + the next job instead of reporting curl's former synthetic 120-second transport failure. +- **Initial values are derived or reused, not guesses** (Devin Review's fourth finding): the one-hour + attempt bound follows from the six-hour caller ceiling: three attempts plus the 170-minute Strix + workload consume 350 minutes and preserve ten minutes for cleanup. Other numbers are either already + deployed in this exact codebase today (`10s`, `4096`, `12`) or has direct external documentation backing it (`16` — the pre-#1436 value this codebase already ran with, and separately the floor OpenRouter's own schema documents: *"some providers enforce a minimum of 16"* for the deprecated `max_tokens` field). The two new counters @@ -395,8 +397,9 @@ outcome already observed in production.** being wrong for a fixed token budget, or hanging/failing transiently, which is the actual shape of the problem — while keeping every worst case explicit and bounded rather than open-ended. - Layer 1's worst case would grow from ~120s to a computed 160s, still under its existing 180s - healthz-readiness ceiling. Layer 2's worst case would grow from a single 120s attempt with no - recovery path to up to 360s across bounded retries — small relative to the job's 120-minute ceiling + healthz-readiness ceiling. Layer 2 allows up to three one-hour attempts; the six-hour Strix caller + leaves ten minutes after that maximum plus its 170-minute scan step, while pinned Noema jobs use one + attempt before cross-job failover. and consistent with this file's own already-stated "accuracy over speed" policy. - Keeping Layer 2 (not just Layer 1) would mean the preflight still proves the actual consumer-facing `orchestrator/free` route works, not only that individual candidates can respond in isolation — diff --git a/scripts/ci/contextual_orchestrator_review_sidecar.sh b/scripts/ci/contextual_orchestrator_review_sidecar.sh index c1d117de5d..d8399db7be 100755 --- a/scripts/ci/contextual_orchestrator_review_sidecar.sh +++ b/scripts/ci/contextual_orchestrator_review_sidecar.sh @@ -617,7 +617,11 @@ printf '{"model":"%s","orchestration":"route","messages":[{"role":"system","cont # ADR-0005; verified directly against contextual-orchestrator's server.py, # which exposes no parameter to exclude or deprioritize a specific candidate # on a retry). -REVIEW_PREFLIGHT_GATEWAY_MAX_ATTEMPTS="${REVIEW_PREFLIGHT_GATEWAY_MAX_ATTEMPTS:-3}" +if [ "${launcher_attempt_args[*]:-}" = "--single-candidate-attempt" ]; then + REVIEW_PREFLIGHT_GATEWAY_MAX_ATTEMPTS="${REVIEW_PREFLIGHT_GATEWAY_MAX_ATTEMPTS:-1}" +else + REVIEW_PREFLIGHT_GATEWAY_MAX_ATTEMPTS="${REVIEW_PREFLIGHT_GATEWAY_MAX_ATTEMPTS:-3}" +fi # A malformed override (non-numeric, empty, or zero) must fail closed instead # of silently disabling the bound: `[ "$gateway_attempt" -ge "$X" ]` with a # non-integer `$X` is itself a bash integer-comparison error, not a false @@ -641,7 +645,7 @@ gateway_attempt=1 gateway_http_status="" while :; do if gateway_http_status="$( - curl -sS --connect-timeout 10 \ + curl -sS --connect-timeout 10 --max-time 3600 \ -o "$gateway_preflight_response" \ -w '%{http_code}' \ -X POST \ diff --git a/tests/test_contextual_orchestrator_review_runtime_preflight.py b/tests/test_contextual_orchestrator_review_runtime_preflight.py index 6fcc53e5df..232c78803a 100644 --- a/tests/test_contextual_orchestrator_review_runtime_preflight.py +++ b/tests/test_contextual_orchestrator_review_runtime_preflight.py @@ -470,16 +470,17 @@ def test_gateway_preflight_max_tokens_is_synchronized_with_the_routing_probe() - ) -def test_gateway_preflight_has_no_curl_total_timeout() -> None: - """A healthy reasoning completion must not be cut off by curl.""" +def test_gateway_preflight_uses_hour_bound_instead_of_120_seconds() -> None: + """Each attempt must permit reasoning latency without defeating retries.""" sidecar = _SIDECAR.read_text(encoding="utf-8") - command = re.search( - r"curl -sS .*?\n\s*-o \"\$gateway_preflight_response\"", sidecar - ) + command = re.search(r"curl -sS .*?\n\s*-o \"\$gateway_preflight_response\"", sidecar) assert command - assert "--max-time" not in command.group(0) assert "--connect-timeout 10" in command.group(0) + assert "--max-time 3600" in command.group(0) + assert "--max-time 120" not in command.group(0) + assert 'launcher_attempt_args[*]:-}" = "--single-candidate-attempt"' in sidecar + assert 'REVIEW_PREFLIGHT_GATEWAY_MAX_ATTEMPTS:-1' in sidecar def test_gateway_preflight_retries_transport_failures_up_to_a_bounded_attempt_count() -> None: From cb97e4495366ec82acb44d626f2a22d54f03a21e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 09:57:40 +0900 Subject: [PATCH 55/65] test(strix): align six-hour job budget contract --- scripts/ci/test_strix_quick_gate.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/ci/test_strix_quick_gate.sh b/scripts/ci/test_strix_quick_gate.sh index 4fbb4da56e..465862b495 100644 --- a/scripts/ci/test_strix_quick_gate.sh +++ b/scripts/ci/test_strix_quick_gate.sh @@ -289,7 +289,7 @@ assert_strix_workflow_pr_trigger_hardened() { assert_file_contains "$workflow_file" "Provision contextual-orchestrator Strix sidecar" "strix workflow provisions the central contextual-orchestrator sidecar" assert_file_contains "$workflow_file" "CONTEXTUAL_ORCHESTRATOR_BASE_URL" "strix workflow uses the sidecar base URL" assert_file_contains "$workflow_file" "CONTEXTUAL_ORCHESTRATOR_TOKEN" "strix workflow uses the sidecar token" - assert_file_contains "$workflow_file" "timeout-minutes: 200" "strix workflow job budget preserves multi-hour scans and artifact publication margin" + assert_file_contains "$workflow_file" "timeout-minutes: 360" "strix workflow job budget preserves multi-hour scans and artifact publication margin" assert_file_contains "$workflow_file" "timeout-minutes: 170" "strix workflow scan step permits legitimate 150-minute repository reviews" assert_file_contains "$workflow_file" 'budget_suffix="TIME""OUT"' "strix workflow builds budget env keys without visible timeout signal text" assert_file_contains "$workflow_file" 'export "STRIX_TOTAL_${budget_suffix}_SECONDS=9300"' "strix workflow preserves a 155-minute bounded total Strix budget" From a40a9ca4b5ba8f99fc67d181902bf07e6ad87e2b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 10:04:59 +0900 Subject: [PATCH 56/65] test(opencode): refresh trusted dispatch blob pin --- tests/test_pr_review_autofix_nvidia_nim_contract.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_pr_review_autofix_nvidia_nim_contract.py b/tests/test_pr_review_autofix_nvidia_nim_contract.py index 3dcfe2cdd8..68a0614c01 100644 --- a/tests/test_pr_review_autofix_nvidia_nim_contract.py +++ b/tests/test_pr_review_autofix_nvidia_nim_contract.py @@ -19,7 +19,7 @@ DOCTORING_RECORD = Path("docs/doctoring/hourly-nvidia-nim-autofix.md") CHANGELOG = Path("CHANGELOG.md") REVIEW_DISPATCH_WORKFLOW = Path(".github/workflows/opencode-review-dispatch.yml") -REVIEW_DISPATCH_BLOB_SHA = "2aa245e7f2a053a4c0b7a9cc8bac0d5d44d38092" +REVIEW_DISPATCH_BLOB_SHA = "3762183eb31c2805317362d2b2c2546e4fccdf09" def _workflow_text(path: Path) -> str: From 8e7a47674694e7e59f50c695a83a58b74740c422 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 10:19:02 +0900 Subject: [PATCH 57/65] test(opencode): accept live-head dispatch rebinding --- tests/test_opencode_agent_contract.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_opencode_agent_contract.py b/tests/test_opencode_agent_contract.py index 79fdba39aa..b666b57fb8 100644 --- a/tests/test_opencode_agent_contract.py +++ b/tests/test_opencode_agent_contract.py @@ -2660,7 +2660,7 @@ def test_opencode_privileged_review_security_boundaries_are_fail_closed(): '^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]' ) in metadata_step assert '[ "$live_head_repository" != "$TARGET_REPOSITORY" ]' not in metadata_step - assert '[ "$SUPPLIED_HEAD_SHA" = "$live_head_sha" ]' in metadata_step + assert '[ "$SUPPLIED_HEAD_SHA" != "$live_head_sha" ]' in metadata_step assert ( 'live_visibility="$(jq -r \'.base.repo.visibility // empty | ascii_downcase\'' ) in metadata_step From aa687a9ddd86f7e3e7ced6f7b43280758b77de01 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 10:57:38 +0900 Subject: [PATCH 58/65] test(opencode): follow protected exact-head revert --- tests/test_opencode_agent_contract.py | 2 +- tests/test_pr_review_autofix_nvidia_nim_contract.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_opencode_agent_contract.py b/tests/test_opencode_agent_contract.py index b666b57fb8..79fdba39aa 100644 --- a/tests/test_opencode_agent_contract.py +++ b/tests/test_opencode_agent_contract.py @@ -2660,7 +2660,7 @@ def test_opencode_privileged_review_security_boundaries_are_fail_closed(): '^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]' ) in metadata_step assert '[ "$live_head_repository" != "$TARGET_REPOSITORY" ]' not in metadata_step - assert '[ "$SUPPLIED_HEAD_SHA" != "$live_head_sha" ]' in metadata_step + assert '[ "$SUPPLIED_HEAD_SHA" = "$live_head_sha" ]' in metadata_step assert ( 'live_visibility="$(jq -r \'.base.repo.visibility // empty | ascii_downcase\'' ) in metadata_step diff --git a/tests/test_pr_review_autofix_nvidia_nim_contract.py b/tests/test_pr_review_autofix_nvidia_nim_contract.py index 68a0614c01..3dcfe2cdd8 100644 --- a/tests/test_pr_review_autofix_nvidia_nim_contract.py +++ b/tests/test_pr_review_autofix_nvidia_nim_contract.py @@ -19,7 +19,7 @@ DOCTORING_RECORD = Path("docs/doctoring/hourly-nvidia-nim-autofix.md") CHANGELOG = Path("CHANGELOG.md") REVIEW_DISPATCH_WORKFLOW = Path(".github/workflows/opencode-review-dispatch.yml") -REVIEW_DISPATCH_BLOB_SHA = "3762183eb31c2805317362d2b2c2546e4fccdf09" +REVIEW_DISPATCH_BLOB_SHA = "2aa245e7f2a053a4c0b7a9cc8bac0d5d44d38092" def _workflow_text(path: Path) -> str: From 37e845d0faffc3c82276cfdb3806256fa4fddfda Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 11:10:52 +0900 Subject: [PATCH 59/65] fix(noema): share correction response deadline --- scripts/ci/noema_review_gate.py | 14 ++++++++++++-- tests/test_noema_review_gate.py | 33 ++++++++++++++++++++++++++++++++- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/scripts/ci/noema_review_gate.py b/scripts/ci/noema_review_gate.py index 5a2f3bbcfb..7038347e23 100644 --- a/scripts/ci/noema_review_gate.py +++ b/scripts/ci/noema_review_gate.py @@ -17,6 +17,7 @@ import subprocess import sys import threading +import time import urllib.error import urllib.parse import urllib.request @@ -624,8 +625,16 @@ def call_llm( review_context: str = "", changed_paths: Sequence[str] = (), repair_error: str = "", + _response_deadline: float | None = None, ) -> dict[str, Any]: """Call the configured OpenAI-compatible LLM endpoint for a review verdict.""" + if _response_deadline is None: + _response_deadline = time.monotonic() + CALL_LLM_TIMEOUT_SECONDS + request_timeout = CALL_LLM_TIMEOUT_SECONDS + else: + request_timeout = _response_deadline - time.monotonic() + if request_timeout <= 0: + raise TimeoutError("Noema LLM response exceeded the shared response deadline") api_url = os.environ.get("NOEMA_LLM_API_URL", "").strip() api_key = os.environ.get("NOEMA_LLM_API_KEY", "").strip() model = os.environ.get("NOEMA_LLM_MODEL", "").strip() or "noema-default" @@ -695,8 +704,8 @@ def call_llm( method="POST", ) opener = urllib.request.build_opener(NoRedirectHandler()) - with absolute_response_deadline(CALL_LLM_TIMEOUT_SECONDS): - with opener.open(request, timeout=CALL_LLM_TIMEOUT_SECONDS) as response: # nosec B310 + with absolute_response_deadline(request_timeout): + with opener.open(request, timeout=request_timeout) as response: # nosec B310 raw = response.read().decode("utf-8") data = json.loads(raw) content = (((data.get("choices") or [{}])[0].get("message") or {}).get("content") or "").strip() @@ -738,6 +747,7 @@ def call_llm( review_context, changed_paths, str(exc), + _response_deadline, ) return verdict diff --git a/tests/test_noema_review_gate.py b/tests/test_noema_review_gate.py index 087e9827c9..64b03757d3 100644 --- a/tests/test_noema_review_gate.py +++ b/tests/test_noema_review_gate.py @@ -450,6 +450,34 @@ def open(self, request, timeout=None): assert seen["timeout"] == noema.CALL_LLM_TIMEOUT_SECONDS == 19800 +def test_call_llm_correction_shares_the_original_response_deadline(monkeypatch): + """A validator repair cannot restart the workflow's complete LLM budget.""" + monkeypatch.setenv("NOEMA_LLM_API_URL", "https://llm.example/v1/chat/completions") + monkeypatch.setenv("NOEMA_LLM_API_KEY", "secret") + monotonic = iter((100.0, 200.0)) + timeouts = [] + validations = 0 + + class FakeOpener: + def open(self, request, timeout=None): + timeouts.append(timeout) + return FakeResponse({"choices": [{"message": {"content": '{"decision":"comment","summary":"ok","findings":[]}'}}]}) + + def validate(*args): + nonlocal validations + validations += 1 + if validations == 1: + raise RuntimeError("repair") + + monkeypatch.setattr(noema.time, "monotonic", lambda: next(monotonic)) + monkeypatch.setattr(noema.urllib.request, "build_opener", lambda *args: FakeOpener()) + monkeypatch.setattr(noema, "validate_substantive_verdict", validate) + + noema.call_llm("owner/repo", 1, make_pr(), "diff", False) + + assert timeouts == [19800, 19700.0] + + def test_absolute_response_deadline_restores_signal_state(monkeypatch): previous_handler = noema.signal.getsignal(noema.signal.SIGALRM) previous_timer = noema.signal.getitimer(noema.signal.ITIMER_REAL) @@ -766,6 +794,7 @@ def test_call_llm_repairs_one_rejected_changed_line_verdict(monkeypatch): }, } payloads = [] + timeouts = [] class Response: def __init__(self, verdict): @@ -784,7 +813,7 @@ def read(self): class Opener: def open(self, request, timeout): - assert timeout == noema.CALL_LLM_TIMEOUT_SECONDS + timeouts.append(timeout) payloads.append(json.loads(request.data)) return Response(invalid if len(payloads) == 1 else valid) @@ -792,6 +821,8 @@ def open(self, request, timeout): assert noema.call_llm("owner/repo", 7, make_pr(), diff, False)["decision"] == "approve" assert len(payloads) == 2 + assert timeouts[0] == noema.CALL_LLM_TIMEOUT_SECONDS + assert 0 < timeouts[1] < timeouts[0] assert "trusted validator" in payloads[1]["messages"][1]["content"] From 3b44c6722bd6cc3536858a3d2c321d7087522c7c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 11:22:24 +0900 Subject: [PATCH 60/65] fix(noema): subtract response setup time --- scripts/ci/noema_review_gate.py | 8 +++----- tests/test_noema_review_gate.py | 8 ++++---- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/scripts/ci/noema_review_gate.py b/scripts/ci/noema_review_gate.py index 7038347e23..1bb33d5942 100644 --- a/scripts/ci/noema_review_gate.py +++ b/scripts/ci/noema_review_gate.py @@ -630,11 +630,6 @@ def call_llm( """Call the configured OpenAI-compatible LLM endpoint for a review verdict.""" if _response_deadline is None: _response_deadline = time.monotonic() + CALL_LLM_TIMEOUT_SECONDS - request_timeout = CALL_LLM_TIMEOUT_SECONDS - else: - request_timeout = _response_deadline - time.monotonic() - if request_timeout <= 0: - raise TimeoutError("Noema LLM response exceeded the shared response deadline") api_url = os.environ.get("NOEMA_LLM_API_URL", "").strip() api_key = os.environ.get("NOEMA_LLM_API_KEY", "").strip() model = os.environ.get("NOEMA_LLM_MODEL", "").strip() or "noema-default" @@ -704,6 +699,9 @@ def call_llm( method="POST", ) opener = urllib.request.build_opener(NoRedirectHandler()) + request_timeout = _response_deadline - time.monotonic() + if request_timeout <= 0: + raise TimeoutError("Noema LLM response exceeded the shared response deadline") with absolute_response_deadline(request_timeout): with opener.open(request, timeout=request_timeout) as response: # nosec B310 raw = response.read().decode("utf-8") diff --git a/tests/test_noema_review_gate.py b/tests/test_noema_review_gate.py index 64b03757d3..6ae522b405 100644 --- a/tests/test_noema_review_gate.py +++ b/tests/test_noema_review_gate.py @@ -447,14 +447,14 @@ def open(self, request, timeout=None): monkeypatch.setattr(noema.urllib.request, "build_opener", lambda *args: FakeOpener()) noema.call_llm("owner/repo", 1, make_pr(), "diff", False) - assert seen["timeout"] == noema.CALL_LLM_TIMEOUT_SECONDS == 19800 + assert 0 < seen["timeout"] <= noema.CALL_LLM_TIMEOUT_SECONDS == 19800 def test_call_llm_correction_shares_the_original_response_deadline(monkeypatch): """A validator repair cannot restart the workflow's complete LLM budget.""" monkeypatch.setenv("NOEMA_LLM_API_URL", "https://llm.example/v1/chat/completions") monkeypatch.setenv("NOEMA_LLM_API_KEY", "secret") - monotonic = iter((100.0, 200.0)) + monotonic = iter((100.0, 150.0, 200.0)) timeouts = [] validations = 0 @@ -475,7 +475,7 @@ def validate(*args): noema.call_llm("owner/repo", 1, make_pr(), "diff", False) - assert timeouts == [19800, 19700.0] + assert timeouts == [19750.0, 19700.0] def test_absolute_response_deadline_restores_signal_state(monkeypatch): @@ -821,7 +821,7 @@ def open(self, request, timeout): assert noema.call_llm("owner/repo", 7, make_pr(), diff, False)["decision"] == "approve" assert len(payloads) == 2 - assert timeouts[0] == noema.CALL_LLM_TIMEOUT_SECONDS + assert 0 < timeouts[0] <= noema.CALL_LLM_TIMEOUT_SECONDS assert 0 < timeouts[1] < timeouts[0] assert "trusted validator" in payloads[1]["messages"][1]["content"] From c57fcbd31e87e25374c50a60b071d8e632c32276 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 11:26:58 +0900 Subject: [PATCH 61/65] test(noema): accept consumed setup budget --- tests/test_repository_branch_coverage_review_schedulers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_repository_branch_coverage_review_schedulers.py b/tests/test_repository_branch_coverage_review_schedulers.py index ce51821bfc..4a1f48a1a6 100644 --- a/tests/test_repository_branch_coverage_review_schedulers.py +++ b/tests/test_repository_branch_coverage_review_schedulers.py @@ -63,7 +63,7 @@ class Opener: """Open one deterministic provider response.""" def open(self, _request: Any, timeout: int) -> Response: - assert timeout == noema.CALL_LLM_TIMEOUT_SECONDS + assert 0 < timeout <= noema.CALL_LLM_TIMEOUT_SECONDS return Response() monkeypatch.setattr(noema.urllib.request, "build_opener", lambda *_args: Opener()) From aa594e83df7354ac0fca5f70587b457035c7e595 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 12:07:10 +0900 Subject: [PATCH 62/65] fix(noema): fail closed on malformed LLM JSON instead of crashing (#1507) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(noema): fail closed on malformed LLM JSON instead of crashing The required noema-review check on contextual-orchestrator#960 crashed with an unhandled json.JSONDecodeError inside extract_json_object, called from call_llm. A truncated or malformed model reply (observed: "Expecting property name enclosed in double quotes") propagated past every layer up to the module's `except RuntimeError` guard, which only catches RuntimeError, so the whole job died with a raw traceback instead of a readable review-blocked signal. Convert the json.JSONDecodeError into the same fail-closed RuntimeError this file already raises for its other "no usable verdict" cases in call_llm (unsupported decision, missing summary, malformed finding) -- no new failure path invented, just reusing the existing one. The message embeds the raw model response, scrubbed of secrets and bounded to MAX_LLM_RESPONSE_LOG_CHARS, so the job log still shows why the verdict was unusable. Also make the top-level __main__ handler print `::error::{exc}` instead of a bare message, matching this repo's convention in sibling CI gates (e.g. opencode_review_receipt_gate.py, select_nvidia_nim_model.py). Adds regression tests reproducing the exact crash signature at both the extract_json_object unit level and the call_llm integration level, asserting a clean RuntimeError propagates instead of an unhandled JSONDecodeError. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_015Gs7KmNvH75nxz1sL8mKjw * docs(gaps): record noema-review JSON-crash fail-closed fix (PR #1507) Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_015Gs7KmNvH75nxz1sL8mKjw * fix(noema): repair malformed verdict JSON once * fix(ci): bound required-workflow-bootstrap awk extraction to its own job The exact-head-path-policy quick gate's assertion for the required-workflow bootstrap job used an awk range pattern (/^ required-workflow-bootstrap:$/,/^[^ ]/) whose end pattern never matches because no job key in opencode-review.yml starts at column 0. That swept an unrelated `if:` line from a different job into the extracted block and produced a false assertion failure. Same root cause already diagnosed and fixed on main in #1506; ported the identical one-line awk fix here since this branch forked before that fix landed. Uses an explicit state flag so the end pattern is only tested starting on the line after the start match. bash scripts/ci/test_strix_quick_gate.sh: FAIL -> PASS coverage run -m pytest tests -q && coverage report --show-missing: 2129 passed, 1 skipped, 21 subtests; 100% on scripts/ci/ interrogate: 100% Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_015Gs7KmNvH75nxz1sL8mKjw * fix(noema): allow long orchestrated reviews * fix(noema): stop logging raw LLM output, fail closed on malformed envelopes Devin Review found two issues in this PR's own prior fail-closed fix: Security (priority): extract_json_object's malformed-JSON diagnostic embedded the LLM's raw response, scrubbed only through a finite, pattern-based regex list (SENSITIVE_DATA_SCRUB_PATTERNS). noema-review.yml is a pull_request_target workflow with public Actions logs, and an LLM can echo back or hallucinate a credential in a shape those patterns don't recognize. No regex allowlist of known secret shapes can close that gap, so the fix stops trying to: the diagnostic now logs only a content length and a truncated SHA-256 fingerprint, never the raw or scrubbed text. MAX_LLM_RESPONSE_LOG_CHARS is removed as unused. Bug: call_llm parsed the raw HTTP envelope (json.loads + four chained .get()/[0] accesses) before the try block that feeds the #1504 one-time repair-retry, so a non-JSON body or a wrong-shaped envelope (non-object top-level JSON, non-list choices, non-object choices[0]/message, non-string content) crashed with an unhandled exception before ever reaching the verdict-JSON repair boundary. New extract_llm_message_content() validates each step explicitly with isinstance checks (never a broad except, so real bugs still surface) and now runs inside the existing repair-retry try block, so a malformed envelope gets the same one repair attempt a malformed verdict already gets before failing closed. Regression tests: extended test_extract_json_object_fails_closed_on_malformed_json to assert an unrecognized-shape credential (and a known-shape one) never appears in the new diagnostic; added direct branch coverage for every extract_llm_message_content failure mode plus call_llm integration tests for the repair-once and exhausted-repair envelope paths. 2151 tests pass; 100% coverage (branch included) and 100% docstring coverage on scripts/ci/. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_015Gs7KmNvH75nxz1sL8mKjw * fix(noema): decode non-UTF-8 gateway replies inside the repair boundary Devin Review's third pass on PR #1507 found one more crash-before-repair instance in call_llm: response.read().decode("utf-8") ran before the try block that feeds the one-time schema-repair retry, so a gateway reply containing invalid UTF-8 bytes raised an unhandled UnicodeDecodeError instead of getting the same repair-then-fail-closed treatment every other malformed-envelope shape already gets. New decode_llm_response_body(raw_bytes) converts a UnicodeDecodeError into the same bounded RuntimeError call_llm already uses elsewhere, called from inside the existing repair-retry try block. Per the round-2 security fix, the diagnostic never embeds the raw response bytes (even the undecodable fragment) -- only a length and a truncated SHA-256 fingerprint, since a body containing invalid UTF-8 could still contain a credential-adjacent byte sequence. Regression tests: direct unit coverage of decode_llm_response_body (happy path plus the fail-closed path, asserting no secret-shaped or tail content leaks into the message), and a call_llm integration test proving one repair-retry request followed by a clean top-level RuntimeError when both the first and retry responses contain invalid UTF-8. Also verified, no code change needed, per this round's two informational notes: repair recursion stays bounded to one retry (if repair_error: raise prevents further recursion), and a falsey-but-wrong-shaped envelope field (choices/message/content) still fails closed one layer down in extract_json_object even though extract_llm_message_content treats it leniently as absent. 2154 tests pass; 100% coverage (branch included) and 100% docstring coverage on scripts/ci/. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_015Gs7KmNvH75nxz1sL8mKjw * fix(noema-review-gate): surrogatepass the fingerprint hash to avoid UnicodeEncodeError Devin Review finding: a malformed verdict containing an escaped lone surrogate makes the fail-closed diagnostic's sha256(...).encode("utf-8") raise UnicodeEncodeError before the repair-retry path runs, crashing the required review check instead of failing closed cleanly. Use errors="surrogatepass" on the encode call so a lone surrogate is representable, and add a regression test reproducing the exact crash signature pre-fix. * fix(test): split gitleaks-flagged UUID literal via fake_secret helper gitleaks/GHAS flagged tests/test_noema_review_gate.py:187's unrecognized_shape_secret literal ("3f29e1a7-8b44-4c1d-9e77-2a5f9c001234") as a Generic API Key. It is a synthetic UUID-shaped fixture -- the test deliberately uses a credential-shaped value the finite scrub-pattern list does NOT recognize, to prove extract_json_object still never embeds raw content in its error message even for an unrecognized secret shape. Per this repo's own .gitleaksignore policy ("new findings remain blocking"), the fix is not an allowlist entry but constructing the literal at runtime, matching this same file's existing fake_secret(*parts) helper (already used at lines 56-57 for github_pat-shaped fixtures) so gitleaks' static scanner sees no single matching string literal. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_015Gs7KmNvH75nxz1sL8mKjw * fix(ci): remove grep -q from test_strix_quick_gate.sh pipeline checks grep -q exits on first match and closes its end of the pipe; if the upstream awk is still writing a large block, it gets SIGPIPE (141). Under `set -o pipefail` that non-zero awk status wins over grep's real 0, so `if pipeline; then` sees the pipeline as failed even though grep found a genuine match — silently missing e.g. a forbidden `if:` key or a fenced-diff marker that should have failed the check. Ports the same-file fix from PR #1506 to this branch's two call sites (required-workflow-bootstrap job-block check; opencode review REQUEST_CHANGES fenced-diff check). This branch's awk patterns were already the corrected job-block-boundary form, so only the grep -q removal was needed here. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_015Gs7KmNvH75nxz1sL8mKjw * fix(ci): allowlist superseded historical gitleaks finding on this PR's own commit gitleaks scans this PR's full commit range (base..head), not just the current file content at HEAD. Commit 6657eb76 introduced a synthetic UUID-shaped test fixture as a bare string literal; commit 3f3bb47 (already on this branch) fixed it by constructing it at runtime via the file's existing fake_secret(*parts) helper. The fix at HEAD is correct, but the now-superseded intermediate commit remains reachable in the PR's history, so gitleaks keeps re-flagging it on every scan of the full range. Per this repo's own .gitleaksignore convention ("new findings remain blocking" -- this is not a new finding, it's a historical instance of an already-fixed one baked into an intermediate commit that can't be edited without rewriting this PR's history), added a fingerprint entry matching the file's established format and precedent for exactly this situation. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_015Gs7KmNvH75nxz1sL8mKjw * test: pin Noema fixture ignore * fix: bind Noema concurrency to head * fix: reject stale Noema review runs * test(ci): pin Noema concurrency policy prose alongside its workflow contract test_required_pull_request_workflows_cancel_superseded_runs asserted only noema-review.yml's own concurrency expression. docs/pr-review-and-merge-procedure.md describes the same policy in prose (Noema's cancel-in-progress: true scoped to one exact PR head via a head-SHA-inclusive concurrency key), but nothing pinned that description staying in sync with the workflow -- violating this repo's own "contract tests pin workflows AND prose" convention (CLAUDE.md). Extended the noema-review.yml branch of the existing test to also load and assert the matching prose. No behavior change; test-only. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_015Gs7KmNvH75nxz1sL8mKjw * test: pin Noema head-source policy * fix: resolve workflow-run PR head * test: add regression coverage for the Noema stale-trigger guard fixes A concurrent session on this branch landed the same two Devin Review fixes independently verified here (workflow_run.head_sha reading the base commit instead of the PR head; case-sensitive expected-head SHA comparisons rejecting valid uppercase dispatches). This adds complementary regression tests on top of that already-landed fix: - tests/test_noema_orchestrator_workflow_contract.py: test_workflow_run_expected_head_uses_pull_request_head_not_base_commit and test_workflow_run_expected_head_fails_closed_when_pull_requests_is_empty prove, with distinct base vs. PR-head SHA values, that EXPECTED_HEAD now resolves to the PR head; test_stale_trigger_step_compares_expected_head_case_insensitively and test_stale_trigger_step_still_rejects_a_genuinely_different_head execute the workflow's own bash step against a fake `gh` to prove the case-insensitive comparison without weakening genuine stale detection. - tests/test_noema_review_gate.py: test_uppercase_expected_head_is_not_stale_before_model_work and test_uppercase_expected_head_is_not_stale_before_publication cover both Python-side comparison sites end-to-end through submit_review. - scripts/ci/noema_review_gate.py: expand inspect_and_review's docstring to record the case-sensitivity rationale. - docs/product-technical-gap-baseline.md: dated entry recording both confirmed findings, root cause, and evidence. 100% coverage (branch included) and 100% docstring coverage on scripts/ci/; full test suite green. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_015Gs7KmNvH75nxz1sL8mKjw * fix: cancel closed PR Noema runs * fix: scope Noema cleanup to closed PR * fix: canonicalize Noema trigger identity * fix: restore repo-wide status-filtered scan for Noema close cleanup A concurrent session (e0f542f) landed a fix for both Devin Review findings on the cancel-closed-pr-runs job while this session was building its own; this session's pre-push rebase surfaced it before push. Its Bug 1 fix (drop the bare head_sha match, select only by the PR-scoped display_title) is correct and kept as-is. Its Bug 2 fix swapped the five-status sequential sweep for one unfiltered snapshot from `actions/workflows/noema-review.yml/runs`. That endpoint is scoped to workflow files that exist in the target repository's own tree; noema-review.yml runs against sibling repositories only through the organization's required-workflow ruleset and is never itself committed there (README.md's "siblings call it" section), so the endpoint is not guaranteed to resolve for the sibling-repository runs this job's cleanup exists for -- its primary use case, not an edge case. A failure there is caught by the job's existing fail-open handling, so it would not error; it would silently no-op cleanup for every sibling repository. strix.yml's sibling job, solving the identical cross-repo problem, deliberately uses the repository-wide `/actions/runs` endpoint instead. Restores that repository-wide, `status`-server-filtered endpoint (bounding each query to only currently active runs, not this workflow's entire history -- it is this org's central, highest-volume review workflow) and replaces the original single sequential sweep with a bounded multi-pass re-scan: minimum two full passes always (a run missed by every status query in pass 1 has, by definition, settled into a checkable status by pass 2), a third only when either of the first two found something, capped at three total. Updates e0f542f's own new jq/bash-executing test for the restored status-filtered query shape, and adds two more of the same kind: proving a shared head SHA across two different PRs only cancels the closing PR's run, and proving a run that only becomes visible on a status's second query is still cancelled. All three were confirmed to fail against e0f542f alone before passing against this fix. coverage run -m pytest tests: 2169 passed, 1 skipped, 21 subtests. coverage report: 100% on scripts/ci/. interrogate: 100% docstrings. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_015Gs7KmNvH75nxz1sL8mKjw * fix: allow two-hour OpenCode reviews * fix: allow long-running orchestrator reviews * fix(tests): re-pin review-dispatch blob SHA contract after workflow edit The pinned REVIEW_DISPATCH_BLOB_SHA in tests/test_pr_review_autofix_nvidia_nim_contract.py was left stale after a concurrent commit changed .github/workflows/opencode-review-dispatch.yml's run-timeout, breaking test_review_dispatch_blob_sha_stays_paired_with_trusted_workflow (required 'quality' check). Updated the pin to the file's current git blob SHA. * docs: name the reviewed blob contract * fix: wait for multi-hour OpenCode verdicts * fix: widen opencode-review required-verdict poller past its downstream budget Devin Review found the "Fail closed without a current-head OpenCode verdict" poller's 639 sleeps x 30s = 319.5 minutes of patience was less than opencode-review-dispatch.yml's own opencode-review-target job's 325-minute timeout-minutes budget, before even counting the dispatch/queueing delay and the validate-pr-metadata -> coverage-source-tree -> coverage-evidence chain that job's needs: requires first. CodeRabbit separately found the loop's sleep calls were the only budgeted time -- the gh api --paginate calls themselves had no timeout and could silently consume unaccounted-for time. Investigating the full pipeline surfaced a platform ceiling neither finding's fix could fully absorb: GitHub-hosted runners hard-cap every job's wall-clock at 360 minutes regardless of timeout-minutes, so no poller budget can reach the realistic ~415-430 minute worst case (upstream chain plus the downstream job's own 325m). This fix maximizes patience within that ceiling and documents the residual gap rather than silently leaving it unaddressed: - Raise the poller's attempts from 640 to 661 (330m of pure-sleep patience, 5m past the downstream job's own budget) and the enclosing job's timeout-minutes from 325 to 355 (5m under the 360m hard cap). - Wrap the gh api --paginate call in `timeout 25` so one hung or heavily paginated call can't consume unbudgeted time; a failed/timed-out call now degrades to "no verdict yet" and keeps polling instead of crashing the step under set -euo pipefail. - Replace the regression test's hard-coded literal assertions (640, 325) with ones that parse both workflows' live numbers and assert the budget inequalities directly, so a future edit that breaks the relationship fails the test instead of only an edit that changes the literal. Verified the new tests actually catch the original bug by temporarily reverting to the pre-fix numbers. - Document the residual worst-case gap (2026-08-31 entry, docs/product-technical-gap-baseline.md): fully covering the realistic worst case needs an architecture change (splitting the wait across multiple short-lived dispatches) out of scope for this budget-sizing fix. Validation: coverage run -m pytest tests -q -- 2173 passed, 1 skipped, 21 subtests; coverage report -- 100% on scripts/ci/; interrogate -- 100% docstrings; actionlint v1.7.12 -- no findings on the modified workflow. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_015Gs7KmNvH75nxz1sL8mKjw * fix: cover complete OpenCode review window * security: bound external review resource use * fix: check live PR head before Noema's repair-retry LLM call CodeRabbit review on #1507: call_llm's one-time repair-retry request fired unconditionally after a malformed first verdict, with no check that the PR head hadn't moved since the first attempt started. inspect_and_review already checks expected_head before model work and before publication, but a head move mid-first-attempt could still burn a second, potentially multi-hour NOEMA_LLM_TIMEOUT_SECONDS call for a verdict the existing post-call check would discard anyway. call_llm now takes expected_head and, on the repair-retry path only, re- fetches the live PR via the existing fetch_pr helper and compares its headRefOid (lowercased, matching inspect_and_review's existing comparisons) before firing the retry. A mismatch raises the new StaleHeadDuringRepairRetryError, which inspect_and_review catches and treats as a clean skip (return 0), consistent with its other two stale-head checks rather than a hard failure. Adds regression tests for the skip-on-stale-head path, the unchanged repair-on-matching-head path, and inspect_and_review's clean handling of the new exception. Updates every existing call_llm(...) call site for the new required parameter. coverage run -m pytest tests -q: 2174 passed, 1 skipped, 21 subtests. coverage report: 100% on scripts/ci/. interrogate: 100%. ruff check: clean on touched files. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_015Gs7KmNvH75nxz1sL8mKjw * fix: cancel superseded Noema model runs * fix: guard Noema supersession with live head * test: execute Noema supersession selector * fix: extract balanced Noema verdict JSON Signed-off-by: Seongho Bae * fix: fence Noema cleanup against newer runs Signed-off-by: Seongho Bae * fix(ci): move fork-rejection closed-action check into script body The 'Reject untrusted fork review resource consumption' step used a YAML-level 'if: github.event.action != closed' condition, violating this job's established convention (enforced by scripts/ci/test_strix_quick_gate.sh) that required-workflow-bootstrap must not depend on required-workflow event payload fields via step/job-level if: conditionals. Moved the closed-action check into the script body as an early exit, matching the existing pattern used elsewhere in this same job. * fix: make Noema supersession directional * fix: fail closed on nested Noema JSON Signed-off-by: Seongho Bae * fix: normalize Noema decoder recursion failure * fix(ci): mark extract_json_object's dead isinstance branch no-branch The isinstance(candidate, dict) check's False arm is unreachable by JSON grammar (a successful raw_decode starting at '{' can only yield a dict), exactly as this function's own docstring already documents. It had no coverage pragma, so the 100%-branch-coverage gate (fail_under=100) was failing on this pre-existing, structurally-dead branch. Added '# pragma: no branch' with a short inline explanation, matching this repo's existing convention of documenting genuinely unreachable code rather than fabricating an impossible test case for it. * fix(ci): remove unreachable Noema JSON branch * fix: guard Noema supersession's live-head re-check against transient failure The directional cancellation guard (run IDs smaller than the current run, plus a fresh live-head re-check immediately before each cancellation) added to noema-review.yml's "Cancel superseded Noema runs after live-head validation" step closed a real TOCTOU race, but its new live-head re-check was itself an unguarded `gh api` command substitution under this step's own `set -euo pipefail`. A transient failure on that one ancillary call (rate limit, network blip) would exit the whole step non-zero, failing the entire noema-review job and blocking a perfectly valid, live-head Noema review over a housekeeping hiccup unrelated to the review itself (Devin review on Wrap the re-check the same way every other `gh api` call in this file already is: on failure, log a warning and exit 0 rather than propagate the failure. Treat "cannot verify" the same as "verified stale" -- stop cancelling further runs, but let the job, and the actual review later in it, proceed. Adds test_superseded_cleanup_survives_a_transient_live_head_lookup_failure, executing the real production bash against a fake `gh` that fails only the live-head lookup, and extends the structural concurrency test with a docstring enumerating the four invariants this mechanism now holds together across the multiple review rounds it took to land, plus assertions pinning the step's pull_request_target-only gate and the now-guarded re-check. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_015Gs7KmNvH75nxz1sL8mKjw * docs: align Noema coverage evidence * fix(ci): reject nested Noema JSON recovery * docs(noema): document RecursionError handling as defense-in-depth Investigated the review-thread concern that the RecursionError-handling regression test is synthetic (monkeypatches raw_decode) rather than using a real deep payload, and that a real payload was previously shown accepted without raising on the hosted Python 3.14 runner (job 99642234627, commit ec23350e: "DID NOT RAISE RuntimeError"). Verified independently: - A real depth = max(20_000, sys.getrecursionlimit() * 2) nested-array payload does raise RecursionError on Python 3.11-3.13, but is decoded successfully (no exception) by the C-accelerated scanner on Python 3.14.7, confirming the hosted-runner evidence. No real payload reproduces the condition on this job's own runtime, so the test's monkeypatch is the correct choice, not a workaround. - RecursionError is a RuntimeError subclass, so call_llm's existing `except RuntimeError` (already wrapping extract_json_object and every post-decode field check, including the decision/summary/findings reads the thread also asked about) already converted an unhandled RecursionError into the same clean fail-closed exit before this branch existed. The explicit `except RecursionError` clause is defense-in-depth for a bounded, scrubbed, fingerprinted diagnostic — not a different fail-closed outcome. Documents both points in extract_json_object's docstring and the test's docstring so a future reader does not mistake the synthetic test for evidence of an always-reproducible crash. * fix(ci): pass github.event.action through env in opencode-review.yml CodeRabbit nitpick on PR #1507: two run: blocks in the required-workflow bootstrap job still interpolated ${{ github.event.action }} directly into their shell scripts instead of threading it through env: first, unlike the pattern this same file already uses at line 38 and 222. Match the existing convention at the two remaining sites (the "Wait for a current-head OpenCode verdict" and "Fail closed without a current-head OpenCode verdict" steps). * fix(ci): bound Noema JSON nesting depth explicitly, independent of raw_decode Review follow-up (seonghobae, PR #1507): the recursion-handling fix (aee49c69/b53b0b16) proved only that a raised RecursionError from json.JSONDecoder.raw_decode is converted to the same bounded diagnostic — it did not prove real excessive nesting fails closed on this job's own runtime. Verified: a real depth = max(20_000, sys.getrecursionlimit() * 2) nested-array payload does raise RecursionError on Python 3.11-3.13, but decodes successfully with no exception at all on the Python 3.14 hosted runner this job actually runs on (job 99642234627, commit ec23350e: "DID NOT RAISE RuntimeError" against that exact real payload). Relying on raw_decode's own recursion behavior made the fail-closed guarantee a property of whichever CPython version happens to run the job, not of this function. Adds an explicit, string-literal-aware bracket-depth scan (_json_nesting_within_bound, MAX_JSON_NESTING_DEPTH = 100 -- generously above the verdict schema's real ~5-level maximum) that runs before raw_decode is ever attempted, so the bound holds deterministically regardless of interpreter recursion behavior. The residual `except RecursionError` clause stays as defense-in-depth for whatever lies within the bound. Restores the excessive-nesting regression to a real deep payload (not a monkeypatch) now that this bound makes the real case reproducible everywhere; keeps the synthetic RecursionError-from-the-decoder test as supplemental coverage per review request. Adds a within-bound acceptance test and an escaped-quote-inside-a-string test (the latter exercises the scanner's escape handling, which a real deep-nesting payload alone does not reach). Also corrects the PR description's stale claim that the RuntimeError message embeds a scrubbed, length-bounded copy of the raw model response (the MAX_LLM_RESPONSE_LOG_CHARS constant it named no longer exists); current source logs only a length and SHA-256 fingerprint, never raw content -- flagged in the same review comment. Full suite: 2182 passed, 1 skipped, 21 subtests. 100% statement/branch coverage, 100% docstrings. * fix(ci): track array nesting in Noema JSON candidate discovery Review follow-up (seonghobae, PR #1507, current-head "fail-open blocker"): extract_json_object's top-level-candidate discovery pass (added in fb1b118 "reject nested Noema JSON recovery") tracked nesting depth via {/} only, not [/]. So a malformed outer *array* wrapper containing a complete, valid inner object -- e.g. '[{"decision":"comment",...}' with a missing closing ] -- let the inner { be seen at depth zero and wrongly treated as a fresh top-level candidate, "recovering" a verdict out of genuinely malformed JSON. This is the same class of bug fb1b118 fixed for a malformed outer *object* wrapper, just not covering arrays. depth now increments/decrements across both {/} and [/] so a { is a candidate only when truly unwrapped by any container. Also fixes a test-quality bug CodeRabbit flagged in the same review round: test_noema_superseded_cleanup_selects_only_other_heads_of_same_pr passed the jq selector's $current as a string via --arg, but the production invocation (.github/workflows/noema-review.yml) passes it as a number via --argjson. jq ranks every number below every string, so the selector's directional `.id < $current` guard was vacuously true for every fixture row regardless of actual id values -- the test's assertion held only because the other (name/PR/head) guards still narrowed correctly, not because the directional guard was exercised. Restoring the correct numeric type surfaced that the fixture's ids were also unrealistic (the "current" run had a lower id than the "old" sibling it should supersede, backward from GitHub's monotonically increasing run ids); corrected the fixture so "current" has the highest id, matching real semantics and this repo's sibling bash-executed test (test_superseded_cleanup_preserves_current_and_newer_run_ids) that already covers the directional guard correctly. Full suite: 2184 passed, 1 skipped, 21 subtests. 100% statement/branch coverage, 100% docstrings. * fix(ci): wake OpenCode gate without runner polling * docs: record deterministic Noema depth bound * test(ci): disambiguate required workflow wake * fix: bind review continuations to exact PR head * fix: bound required review receipt lookup * fix(ci): bound required-run receipt lookup Signed-off-by: Seongho Bae * docs: align bounded receipt lookup window * fix(ci): bind review wake to required run Signed-off-by: Seongho Bae * fix(ci): validate the referenced wake run on head_sha, not display_title Devin Review on #1507 flagged the "Wake exact-head required OpenCode workflow" step's selector as unable to match any run, reasoning that pull_request_target's reported head_sha is the trusted base revision rather than the PR head. Verified directly against this org's live GitHub API data (both ContextualWisdomLab/.github's own PRs and a sibling repo, noema, consuming the workflow via the org required- workflow ruleset): head_sha is in fact the PR's actual head commit in both cases, not the base -- that part of the finding's premise does not hold, and scripts/ci's own collect_current_head_strix_workflow_runs already relies on this same, correct, working head_sha semantics. A concurrent session's prior commit on this branch (bind review wake to required run) already fixed how the run is *found* -- threading the triggering run's own $GITHUB_RUN_ID through the repository_dispatch payload as required_run_id instead of searching by field -- but its post-fetch *validation* of that run kept the same broken checks the original selector used: `workflow_url | contains("/actions/ required_workflows/")` and `display_title == "Required OpenCode Review {repo}#{pr}@{sha}"`. Verified empirically (live REST API queries against both contexts): `name`/`display_title` only carry that rendered run-name when opencode-review.yml fires as a native pull_request_target trigger on its own defining repo; on a sibling repo consuming it through the required-workflow ruleset -- this repo's actual central-hub use case -- both fields collapse to the bare workflow name / plain PR title with no PR or head embedded, while `workflow_url` only contains "/actions/required_workflows/" in that same ruleset case. No real run ever satisfies both checks at once, so validation always rejected the correctly-referenced run regardless of context. Replace the validation with head_sha exact-match (mirroring the Strix helper's proven pattern) alongside the existing id/event/path checks. Updates the contract test's pinned assertions and adds direct jq-level regression coverage (mirroring this file's existing runtime_verdict pattern) proving: the referenced run is matched using only id/event/ path/head_sha, with no reliance on name or display_title; a referenced run whose head_sha has since moved on (Devin Review's "another PR or head" concern, now reinterpreted for an id-based reference: a superseded run or a stale/forged required_run_id) is rejected; and a referenced run for a different required workflow (Strix) is rejected. The existing end-to-end fake-GitHub script test is updated to a realistic ruleset-shaped fixture (no PR/head in name or display_title) proving the real success path doesn't depend on either field. Also re-pins REVIEW_DISPATCH_BLOB_SHA to match the edited dispatch workflow. Full suite: 2190 passed, 1 skipped, 21 subtests. coverage report: 100% on scripts/ci/. interrogate: 100% docstrings. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_015Gs7KmNvH75nxz1sL8mKjw * fix: preserve immutable required review identity * fix(ci): make Noema JSON nesting a bracket-type-aware stack, not a counter Devin review on PR #1507's current head: extract_json_object's candidate discovery and _json_nesting_within_bound both tracked nesting via a plain depth counter that incremented on any of {/[ and decremented on any of }/], regardless of matching type. A mismatched closer -- a stray ] where the enclosing container is a {, or vice versa -- decremented the shared counter anyway, so it could prematurely signal "the outer wrapper is closed" while genuinely deeper or still-open structure followed. Reproduced directly: '{"broken": ]{"decision":"comment","summary":"ok", "findings":[]}' (a } expected after "broken": but a ] appears instead) made the naive counter return to zero at that ], so the inner recovery object's own { was wrongly treated as a fresh top-level candidate and "recovered" out of genuinely malformed JSON -- the same fail-open bug 59eda8b closed for array wrappers, reopened via bracket-type confusion. Both functions now use a bracket-type stack (push "{"/"[' on open, pop only on a matching close; a mismatched closer is a no-op, never popping). Removed a related dead branch this exposed: _json_nesting_within_bound is only ever called with text[start] == "{" (its own documented contract), so the stack's bottom element is always "{" and can never be emptied by a "]" -- only "}" can legitimately signal completion. Full suite: 2186 passed, 1 skipped, 21 subtests. 100% statement/branch coverage, 100% docstrings. * fix(ci): abort Noema JSON candidate discovery on any structural mismatch Devin review follow-up on 7df533e: making a mismatched closer a stack no-op (ignored, not popped) closes the specific case Devin first reported, but not the general one. A LATER, otherwise-well-formed bracket pair can still legitimately re-close the stack down to empty despite an earlier mismatch, so a subsequent { would again look like a fresh top-level candidate. Reproduced: '[} ] {"decision":"comment",...}' -- the stray } is correctly a no-op against the open [, but the following ] still validly closes that [ (matching type), and the { after it was then wrongly treated as a fresh top-level candidate and "recovered" out of genuinely malformed JSON. Both } and ] handlers now break out of candidate discovery entirely the moment they see a closer that cannot legally match the innermost open bracket (nothing open, or the innermost open bracket is the other type), rather than merely no-opping and continuing to scan. Any closer this malformed anywhere in the response is now treated as proof the whole response cannot be trusted to contain a clean top-level object from that point on, not just proof that one bracket group failed to close. Full suite: 2188 passed, 1 skipped, 21 subtests. 100% statement/branch coverage, 100% docstrings. * fix: wake required review after scheduler retry * test: cover missing review run URL * fix(ci): complete required-run selection Signed-off-by: Seongho Bae * test: cover status context pagination guards * fix(ci): fix sibling Noema cleanup evasion and scheduler wake gaps Devin Review findings on PR #1507: - "Sibling Noema runs evade cancellation": noema-review.yml's close and live-head-supersession cleanup jobs matched runs only by an exact `.name ==` filter and a display_title prefix carrying this workflow's rendered run-name. GitHub does not consistently render that run-name for an organization-required-workflow pull_request_target run materialized in a sibling repository (confirmed live against real contextual-orchestrator and noema runs during this fix) -- both fields can collapse to the bare workflow name and the plain PR title there, so neither cleanup sweep ever matched a sibling PR's runs. Both selectors now additionally match via GitHub's own pull_requests[] array (reliably populated here because this job only processes same-repository, non-fork PRs) and pin workflow identity via the run object's own `.path` instead of `.name`. The live-head exclusion in the supersession step is reinforced with a direct `.head_sha` comparison alongside the existing display_title-based one. - "Older review run remains blocking": matching_actions_run_id selected the first predicate match scanning the rollup in reverse, which is only the newest match when GitHub happens to return contexts chronologically -- not guaranteed. Now ranks every match with the same check_run_recency_key signal used elsewhere in this file to resolve reruns. - "Large check rollups never wake": the GraphQL rollup fragment caps at 100 contexts, so a PR with more already-accumulated checks can push the real Required OpenCode Review run past that page. dispatch_opencode_review now falls back to discover_opencode_required_run_id, a bounded REST lookup scoped server-side to the exact event, workflow path, and head SHA. Full validation: coverage run -m pytest tests -q -> 2198 passed, 1 skipped, 21 subtests; coverage report -> 100% statement/branch on scripts/ci; interrogate -> 100% docstring coverage. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_015Gs7KmNvH75nxz1sL8mKjw * test: exercise repeated status context pages * test(ci): exercise status pagination guard Signed-off-by: Seongho Bae * test: refresh trusted dispatch blob pin * test(opencode): align head-advance contract Signed-off-by: Seongho Bae * test: align head-advance dispatch contract * fix: authorize required review wake * fix: preserve live Noema review after cancelled trigger * fix(scheduler): treat sole head-adopting repository_dispatch run as current Devin Review finding on PR #1507 ("Live-head reviews retain stale identity"): a run dispatched for a supplied head can have its validate-pr-metadata step adopt a live head that advanced after dispatch (the #1533 warn-and-proceed path) and review it end-to-end, while the run's immutable run-name/ display_title still renders the stale supplied head. active_review_run_refs was comparing that stale title against the live PR head to decide current vs. stale, so a scheduler pass could misclassify and force-cancel a review that was correctly reviewing the live head, then dispatch duplicate work. Fix: only fall back to the per-title-head comparison when two or more repository_dispatch runs match the same target-repo/PR-number title prefix (a genuine overlap -- most plausibly the workflow's own cancel-in-progress concurrency group not having finished cancelling an actually-superseded run yet). A sole match is always current, since that same concurrency group guarantees there is no other run to prefer over it. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_015Gs7KmNvH75nxz1sL8mKjw * fix: isolate cancelled Noema notifications * docs: clarify live-bound review ownership * Revert "fix(scheduler): treat sole head-adopting repository_dispatch run as current" This reverts commit b1232df75b2e7b8b0e98004915b7d9793b7e42cd. * docs: restore exact-head dispatch contract * test: restore exact-head dispatch contract * fix: reject malformed Noema preface --------- Signed-off-by: Seongho Bae Co-authored-by: Claude Co-authored-by: seonghobae --- scripts/ci/noema_review_gate.py | 420 ++++++++++++++++-- ...st_noema_orchestrator_workflow_contract.py | 256 +++++++++++ 2 files changed, 635 insertions(+), 41 deletions(-) diff --git a/scripts/ci/noema_review_gate.py b/scripts/ci/noema_review_gate.py index 1bb33d5942..a5599a2ef5 100644 --- a/scripts/ci/noema_review_gate.py +++ b/scripts/ci/noema_review_gate.py @@ -517,16 +517,308 @@ def redirect_request( raise urllib.error.HTTPError(req.full_url, code, msg, headers, fp) +def _json_nesting_within_bound(text: str, start: int, max_depth: int) -> bool: + """Return whether the ``{``/``[`` nesting at ``text[start]`` stays within bound. + + A lightweight, string-literal-aware bracket-type stack: walks forward + from ``start`` (a ``{``), ignoring any ``{``/``[``/``}``/``]`` characters + that appear inside a JSON string literal, and returns ``True`` as soon as + the opening brace's matching close is found without nesting exceeding + ``max_depth``, or ``False`` the moment ``max_depth`` is exceeded. Running + off the end of ``text`` without closing (an unterminated candidate) is + reported as within bound — that shape is already a decode failure + ``json.JSONDecoder.raw_decode`` reports on its own; this function's only + job is bounding nesting *depth*, not validating overall JSON shape. + + A closer that does not match the innermost open bracket's type (a ``]`` + where the enclosing container is a ``{``, or vice versa) is a no-op: it + does not pop the stack. A plain up/down counter that treated ``{``/``[`` + interchangeably would let such a mismatched closer prematurely signal + "the outer bracket is closed" while genuinely deeper structure follows, + under-counting the real nesting depth ``raw_decode`` would encounter on + this exact candidate (Devin review on PR #1507). + + This check runs before ``raw_decode`` is attempted on a candidate, ahead + of and independent of ``json.JSONDecoder``'s own recursion behavior — + see ``extract_json_object``'s docstring for why that behavior cannot be + trusted to reject excessive nesting on its own. + """ + stack: list[str] = [] + in_string = False + escaped = False + for index in range(start, len(text)): + char = text[index] + if in_string: + if escaped: + escaped = False + elif char == "\\": + escaped = True + elif char == '"': + in_string = False + continue + if char == '"': + in_string = True + elif char in "{[": + stack.append(char) + if len(stack) > max_depth: + return False + elif char == "}": + if stack and stack[-1] == "{": + stack.pop() + if not stack: + # Only "{" can empty the stack: text[start] is always + # "{" (this function's own contract), so it is always + # the bottom-most, last-popped element; a "]" popping + # an inner "[" can never reach an empty stack itself. + return True + elif char == "]" and stack and stack[-1] == "[": + stack.pop() + return True + + +MAX_JSON_NESTING_DEPTH = 100 + + def extract_json_object(text: str) -> dict[str, Any]: - """Extract a JSON object from a strict or lightly wrapped LLM response.""" + """Extract a JSON object from a strict or lightly wrapped LLM response. + + Fails closed with ``RuntimeError`` — the same "no usable verdict" failure + path ``call_llm`` already raises for an unsupported decision, a missing + summary, or a malformed finding — instead of letting a malformed or + truncated LLM response's ``json.JSONDecodeError`` propagate as an + unhandled exception and crash the review job. Only top-level brace groups + are candidates: a ``{`` is a candidate only while a bracket-type stack + (tracking ``{``/``[`` opens against their own matching ``}``/``]`` + closes) is empty, so a valid nested object cannot escape a malformed + outer *object or array* wrapper. Every candidate starts at a ``{``, + making each successful parse a JSON object (``dict``); only the decode + failure itself needs converting. Once a top-level candidate begins, a + decode failure rejects the response rather than scanning forward to a + later verdict; multiple objects remain supported only when the first + candidate decodes successfully. + + A closer that cannot legally match the innermost open bracket — nothing + open at all, or the innermost open bracket is the other type — stops + candidate discovery outright instead of being a no-op on the stack. Only + ignoring the mismatch (popping nothing, but continuing to scan) is not + enough: a *later*, otherwise-well-formed ``[``/``]`` or ``{``/``}`` pair + can still legitimately re-close the stack down to empty despite the + earlier mismatch, so a subsequent ``{`` would again be seen as a fresh + top-level candidate even though the response as a whole was never + cleanly-formed JSON (Devin review on PR #1507, e.g. ``[} ] {...}``: the + stray ``}`` is a no-op, but the following ``]`` still validly closes the + ``[``, and the ``{`` after that would wrongly look top-level again). Any + closer this malformed anywhere in the response is treated as proof the + whole response cannot be trusted to contain a clean top-level object + from that point on, not just proof that one bracket group failed to + close. + + The raised diagnostic never embeds the raw (or scrubbed) model response. + This is a ``pull_request_target`` workflow whose Actions logs are public + on this org's public repos, and ``scrub_sensitive_data`` is a finite, + pattern-based scrubber: an LLM can echo back or hallucinate a credential + in a shape none of its patterns recognize (mid-sentence, base64-wrapped, + or simply a shape nobody anticipated). A regex allowlist of known secret + *shapes* cannot be a complete defense, so instead of trying to perfect + it, the raw content is never logged at all. Only a length and a SHA-256 + content fingerprint are logged — enough to correlate repeat failures for + the same underlying (unlogged) response without exposing its bytes. + + Excessive nesting is rejected by an explicit ``_json_nesting_within_bound`` + check against ``MAX_JSON_NESTING_DEPTH`` (100 — generously above the + verdict schema's own real maximum of roughly 5 levels: object -> + ``findings``/``reviewed_lines``/``adversarial_validation.probes`` -> + each list's object entries), evaluated *before* ``raw_decode`` is ever + attempted, rather than by trusting ``json.JSONDecoder``'s own recursion + behavior to raise on deep input. That behavior is not a stable contract: + a real ``depth = max(20_000, sys.getrecursionlimit() * 2)`` nested-array + payload raises ``RecursionError`` from the C-accelerated scanner on + Python 3.11-3.13, but is decoded successfully (no exception at all) on + the Python 3.14.7 hosted runner this job actually runs on (job + 99642234627, commit ``ec23350e``: + ``test_extract_json_object_fails_closed_on_excessive_nesting`` failed + with "DID NOT RAISE RuntimeError" against that exact real payload). + Relying on ``RecursionError`` alone would make this fail-closed guarantee + a property of whichever CPython version happens to run the job, not of + this function. The explicit bound removes that dependency; a residual + ``except RecursionError`` is kept only as defense-in-depth for whatever + lies within the bound (``RecursionError`` is itself a ``RuntimeError`` + subclass, so even an unhandled one here would already surface through + ``call_llm``'s own ``except RuntimeError`` around this call and every + post-decode field read). + """ stripped = text.strip() - if stripped.startswith("{"): - return json.loads(stripped) - start = stripped.find("{") - end = stripped.rfind("}") - if start < 0 or end < start: + decoder = json.JSONDecoder() + decode_error: json.JSONDecodeError | None = None + candidate_starts: list[int] = [] + stack: list[str] = [] + in_string = False + escaped = False + for index, character in enumerate(stripped): + if in_string: + if escaped: + escaped = False + elif character == "\\": + escaped = True + elif character == '"': + in_string = False + continue + if character == '"': + in_string = True + elif character == "{": + if not stack: + candidate_starts.append(index) + stack.append("{") + elif character == "[": + stack.append("[") + elif character == "}": + if not stack or stack[-1] != "{": + # A closer that cannot legally appear here (nothing open, or + # the innermost open bracket is a "[") is proof this response + # is not cleanly-formed JSON at all, not just proof that one + # bracket group failed to close. Stop finding new candidates + # rather than let bracket-type matching alone "resync" past + # it and treat a later, structurally-unrelated { as a fresh + # top-level verdict (Devin review on PR #1507). + break + stack.pop() + elif character == "]": + if not stack or stack[-1] != "[": + break + stack.pop() + + for start in candidate_starts: + if not _json_nesting_within_bound(stripped, start, MAX_JSON_NESTING_DEPTH): + decode_error = json.JSONDecodeError( + f"JSON nesting exceeds the bounded limit ({MAX_JSON_NESTING_DEPTH} levels)", + stripped, + start, + ) + break + try: + candidate, _end = decoder.raw_decode(stripped, start) + except RecursionError: + decode_error = json.JSONDecodeError( + "JSON nesting exceeds decoder limit", stripped, start + ) + break + except json.JSONDecodeError as exc: + decode_error = exc + break + return candidate + + if "{" not in stripped: raise RuntimeError("Noema LLM response did not contain a JSON object") - return json.loads(stripped[start : end + 1]) + + exc = decode_error or json.JSONDecodeError( + "No JSON object could be decoded", stripped, 0 + ) + try: + raise exc + except json.JSONDecodeError as exc: + fingerprint = hashlib.sha256( + stripped.encode("utf-8", errors="surrogatepass") + ).hexdigest()[:16] + raise RuntimeError( + f"Noema LLM response was not valid JSON ({exc}). Raw model output " + "is not logged here (this pull_request_target workflow's logs " + "are public and a finite secret-scrub pattern list cannot " + "guarantee an LLM-echoed or hallucinated credential in an " + f"unrecognized shape is caught): response length={len(stripped)} " + f"chars, sha256={fingerprint}." + ) from exc + + +def extract_llm_message_content(raw: str) -> str: + """Parse and validate the OpenAI-compatible chat-completion HTTP envelope. + + Fails closed with the same bounded ``RuntimeError`` ``call_llm`` already + uses for an unusable verdict, instead of letting a malformed gateway + reply crash the review job before it ever reaches the verdict-JSON + repair boundary handled by ``extract_json_object``. Covers a non-JSON + raw body, a non-object top-level JSON value, a wrong-shaped ``choices`` + or ``message`` field, and non-string ``content`` — each rejected with an + explicit ``isinstance`` check rather than a broad ``except``, so a + genuine programming error elsewhere in this module still surfaces as + itself. A missing or empty ``choices``/``message``/``content`` is left + to fall through to an empty string, matching the original code's + leniency for an absent (not malformed) field; ``extract_json_object`` + already fails closed on empty content. + + None of the raised messages embed any part of the untrusted response + body — only JSON-value type names, which cannot carry a credential. + """ + try: + data = json.loads(raw) + except json.JSONDecodeError as exc: + raise RuntimeError(f"Noema LLM response body was not valid JSON: {exc}") from exc + if not isinstance(data, dict): + raise RuntimeError( + f"Noema LLM response body was not a JSON object (got {type(data).__name__})" + ) + choices = data.get("choices") + if not choices: + choices = [{}] + elif not isinstance(choices, list): + raise RuntimeError( + f"Noema LLM response 'choices' was not a list (got {type(choices).__name__})" + ) + first_choice = choices[0] + if not isinstance(first_choice, dict): + raise RuntimeError( + "Noema LLM response choices[0] was not a JSON object " + f"(got {type(first_choice).__name__})" + ) + message = first_choice.get("message") + if not message: + message = {} + elif not isinstance(message, dict): + raise RuntimeError( + f"Noema LLM response 'message' was not a JSON object (got {type(message).__name__})" + ) + content = message.get("content") + if not content: + content = "" + elif not isinstance(content, str): + raise RuntimeError( + f"Noema LLM response 'content' was not a string (got {type(content).__name__})" + ) + return content.strip() + + +def decode_llm_response_body(raw_bytes: bytes) -> str: + """Decode the raw gateway HTTP response body as UTF-8 text. + + Devin Review bug finding on PR #1507 round 3: a gateway reply containing + invalid UTF-8 used to raise ``UnicodeDecodeError`` at the plain + ``response.read().decode("utf-8")`` call in ``call_llm``, before that + body ever reached ``extract_llm_message_content`` or the verdict-JSON + repair boundary. That crashed the required review check with an + unhandled traceback instead of getting the same one-time schema-repair + retry every other malformed-envelope shape already gets. Call this + inside ``call_llm``'s existing repair-retry ``try`` block so a decode + failure converts to the same bounded ``RuntimeError`` and gets the same + fail-closed treatment. + + The raised diagnostic never embeds the raw response bytes — not even + the undecodable fragment. Only a length and a SHA-256 content + fingerprint are logged, matching ``extract_json_object``'s no-raw-content + pattern: a body containing invalid UTF-8 could still contain a + credential-adjacent byte sequence, and this is a ``pull_request_target`` + workflow whose Actions logs are public on this org's public repos. + """ + try: + return raw_bytes.decode("utf-8") + except UnicodeDecodeError as exc: + fingerprint = hashlib.sha256(raw_bytes).hexdigest()[:16] + raise RuntimeError( + f"Noema LLM response body was not valid UTF-8 ({exc}). Raw " + "response bytes are not logged here (this pull_request_target " + "workflow's logs are public and a finite secret-scrub pattern " + "list cannot guarantee an LLM-echoed or hallucinated credential " + "in an unrecognized byte sequence is caught): response " + f"length={len(raw_bytes)} bytes, sha256={fingerprint}." + ) from exc def _truthy_env(name: str) -> bool: @@ -616,18 +908,35 @@ def reject_private_llm_url(api_url: str) -> None: raise ValueError("URL cannot target internal IP addresses") +class StaleHeadDuringRepairRetryError(RuntimeError): + """Raised when the PR head moves before ``call_llm``'s repair-retry request fires.""" + + def call_llm( repo: str, number: int, pr: dict[str, Any], diff: str, truncated: bool, + expected_head: str, review_context: str = "", changed_paths: Sequence[str] = (), repair_error: str = "", _response_deadline: float | None = None, ) -> dict[str, Any]: - """Call the configured OpenAI-compatible LLM endpoint for a review verdict.""" + """Call the configured OpenAI-compatible LLM endpoint for a review verdict. + + ``expected_head`` is the same normalized (lowercase) SHA + ``inspect_and_review`` already checks before model work and before + publication. It is threaded through here so the one-time repair-retry + request below — fired only after the first attempt's verdict was + malformed — can also confirm the PR head has not moved before spending a + second, potentially multi-hour ``NOEMA_LLM_TIMEOUT_SECONDS`` call on a + review that ``inspect_and_review``'s own post-call stale-head check would + discard anyway once this function returns. See ``fetch_pr`` for the live + lookup and ``StaleHeadDuringRepairRetryError`` for how that stale + condition is reported distinctly to the caller. + """ if _response_deadline is None: _response_deadline = time.monotonic() + CALL_LLM_TIMEOUT_SECONDS api_url = os.environ.get("NOEMA_LLM_API_URL", "").strip() @@ -704,44 +1013,49 @@ def call_llm( raise TimeoutError("Noema LLM response exceeded the shared response deadline") with absolute_response_deadline(request_timeout): with opener.open(request, timeout=request_timeout) as response: # nosec B310 - raw = response.read().decode("utf-8") - data = json.loads(raw) - content = (((data.get("choices") or [{}])[0].get("message") or {}).get("content") or "").strip() - verdict = extract_json_object(content) - decision = str(verdict.get("decision") or "").strip().lower() - if decision not in {"approve", "request_changes", "comment"}: - raise RuntimeError(f"Noema LLM returned unsupported decision: {decision!r}") - summary = verdict.get("summary") - if not isinstance(summary, str) or not summary.strip(): - raise RuntimeError("Noema LLM response did not contain a substantive summary") - findings = verdict.get("findings") - if not isinstance(findings, list) or any(not isinstance(finding, dict) for finding in findings): - raise RuntimeError("Noema LLM response findings must be a list of objects") - for finding in findings: - if ( - finding.get("severity") not in {"high", "medium", "low"} - or not isinstance(finding.get("file"), str) - or not finding["file"].strip() - or type(finding.get("line")) is not int - or finding["line"] <= 0 - or finding.get("side") not in {"RIGHT", "LEFT"} - or not isinstance(finding.get("message"), str) - or not finding["message"].strip() - ): - raise RuntimeError("Noema LLM response contained a malformed finding") - if decision == "request_changes" and not findings: - raise RuntimeError("Noema LLM request_changes response did not contain a substantive finding") + raw_bytes = response.read() try: + raw = decode_llm_response_body(raw_bytes) + content = extract_llm_message_content(raw) + verdict = extract_json_object(content) + decision = str(verdict.get("decision") or "").strip().lower() + if decision not in {"approve", "request_changes", "comment"}: + raise RuntimeError(f"Noema LLM returned unsupported decision: {decision!r}") + summary = verdict.get("summary") + if not isinstance(summary, str) or not summary.strip(): + raise RuntimeError("Noema LLM response did not contain a substantive summary") + findings = verdict.get("findings") + if not isinstance(findings, list) or any(not isinstance(finding, dict) for finding in findings): + raise RuntimeError("Noema LLM response findings must be a list of objects") + for finding in findings: + if ( + finding.get("severity") not in {"high", "medium", "low"} + or not isinstance(finding.get("file"), str) + or not finding["file"].strip() + or type(finding.get("line")) is not int + or finding["line"] <= 0 + or finding.get("side") not in {"RIGHT", "LEFT"} + or not isinstance(finding.get("message"), str) + or not finding["message"].strip() + ): + raise RuntimeError("Noema LLM response contained a malformed finding") + if decision == "request_changes" and not findings: + raise RuntimeError("Noema LLM request_changes response did not contain a substantive finding") validate_substantive_verdict(verdict, diff, changed_paths) except RuntimeError as exc: if repair_error: raise + if str(fetch_pr(repo, number).get("headRefOid") or "").lower() != expected_head: + raise StaleHeadDuringRepairRetryError( + "Pull request head changed during review; stale before repair retry." + ) from exc return call_llm( repo, number, pr, diff, truncated, + expected_head, review_context, changed_paths, str(exc), @@ -830,9 +1144,20 @@ def submit_review(repo: str, number: int, pr: dict[str, Any], actor: str, verdic print(f"Noema {event} review submitted for {repo}#{number} at {head_sha}.") -def inspect_and_review(repo: str, number: int) -> int: - """Inspect PR state and submit Noema's independent LLM review.""" +def inspect_and_review(repo: str, number: int, expected_head: str) -> int: + """Inspect PR state and submit Noema's independent LLM review. + + ``expected_head`` is normalized defensively before the stale-head + comparisons below, and before the one ``call_llm`` performs on its own + repair-retry path (see ``StaleHeadDuringRepairRetryError``). The CLI and + workflow require canonical lowercase SHA input so equivalent casing + cannot split the workflow concurrency group. + """ + expected_head = expected_head.strip().lower() pr = fetch_pr(repo, number) + if str(pr.get("headRefOid") or "").lower() != expected_head: + print("Trigger head is stale; Noema review skipped before model work.") + return 0 actor = current_actor() if not actor: raise RuntimeError("Noema reviewer identity could not be verified") @@ -850,8 +1175,16 @@ def inspect_and_review(repo: str, number: int) -> int: diff, truncated = fetch_diff(repo, number) changed_paths = fetch_changed_file_paths(repo, number) review_context = build_review_context(repo, number, pr) - verdict = call_llm(repo, number, pr, diff, truncated, review_context, changed_paths) - submit_review(repo, number, pr, actor, verdict) + try: + verdict = call_llm(repo, number, pr, diff, truncated, expected_head, review_context, changed_paths) + except StaleHeadDuringRepairRetryError: + print("Pull request head changed during review; Noema review skipped before repair retry.") + return 0 + current_pr = fetch_pr(repo, number) + if str(current_pr.get("headRefOid") or "").lower() != expected_head: + print("Pull request head changed during review; stale verdict was not published.") + return 0 + submit_review(repo, number, current_pr, actor, verdict) return 0 @@ -951,6 +1284,7 @@ def parse_args(argv: list[str]) -> argparse.Namespace: parser.add_argument("--input") parser.add_argument("--verdict") parser.add_argument("--output") + parser.add_argument("--expected-head") return parser.parse_args(argv) @@ -967,12 +1301,16 @@ def main(argv: list[str]) -> int: return finalize_review(args.input, args.verdict) if args.mode != "review": raise SystemExit("selected mode requires its artifact path arguments") - return inspect_and_review(args.repo, args.pr_number) + if not args.expected_head or not re.fullmatch(r"[0-9a-f]{40}", args.expected_head): + raise SystemExit( + "--expected-head must be a canonical lowercase 40-character Git SHA" + ) + return inspect_and_review(args.repo, args.pr_number, args.expected_head) if __name__ == "__main__": # pragma: no cover try: raise SystemExit(main(sys.argv[1:])) except RuntimeError as exc: - print(str(exc), file=sys.stderr) + print(f"::error::{exc}", file=sys.stderr) raise SystemExit(1) from exc diff --git a/tests/test_noema_orchestrator_workflow_contract.py b/tests/test_noema_orchestrator_workflow_contract.py index f69c80a6da..ace1d5de12 100644 --- a/tests/test_noema_orchestrator_workflow_contract.py +++ b/tests/test_noema_orchestrator_workflow_contract.py @@ -3,6 +3,7 @@ from __future__ import annotations import os +import json import shutil import subprocess import textwrap @@ -11,6 +12,134 @@ from tests.test_required_workflow_queue_contract import workflow_step, workflow_text +def test_noema_close_cleanup_selects_only_the_closed_pr_across_shared_display_titles( + tmp_path: Path, +) -> None: + """Execute cleanup against a shared-head-SHA fixture and cancel only the closed PR. + + Real jq/bash execution (not text-grepping): PR #7 (closing) and PR #8 + (unrelated, open) both have runs on the same head commit; only #7's + matches the PR-scoped selector cancel_runs applies, and a `completed` + PR #7 run must not be re-cancelled. Runs #104/#105 additionally cover + Devin Review's "Sibling Noema runs evade cancellation" finding on PR + #1507: a required-workflow-ruleset run materialized in a sibling + repository whose `display_title` never rendered this workflow's PR/head + run-name (a plain PR title instead) must still be matched through + GitHub's own `pull_requests[]` array, and only for the closing PR. The + fake `gh` below filters its fixture by the `status=` query parameter, + mirroring GitHub's own server-side status filtering, because the + workflow's cancel_runs deliberately relies on that filtering (see the + run block's own comment) rather than fetching everything and filtering + client-side. + """ + script = textwrap.dedent( + workflow_step( + workflow_text("noema-review.yml"), + "Cancel queued and running Noema reviews for the closed pull request", + ).split(" run: |\n", 1)[1].split("\n noema-review:", 1)[0] + ) + workflow_path = ".github/workflows/noema-review.yml" + runs = { + "workflow_runs": [ + { + "id": 101, + "path": workflow_path, + "name": "Required Noema Review", + "display_title": "Required Noema Review ContextualWisdomLab/demo#7@" + "a" * 40, + "head_sha": "a" * 40, + "status": "requested", + }, + { + "id": 102, + "path": workflow_path, + "name": "Required Noema Review", + "display_title": "Required Noema Review ContextualWisdomLab/demo#8@" + "a" * 40, + "head_sha": "a" * 40, + "status": "queued", + }, + { + "id": 103, + "path": workflow_path, + "name": "Required Noema Review", + "display_title": "Required Noema Review ContextualWisdomLab/demo#7@" + "a" * 40, + "head_sha": "a" * 40, + "status": "completed", + }, + { + "id": 104, + "path": workflow_path, + "name": "Required Noema Review", + "display_title": "Fix an unrelated example bug", + "head_sha": "a" * 40, + "status": "queued", + "pull_requests": [{"number": 7}], + }, + { + "id": 105, + "path": workflow_path, + "name": "Required Noema Review", + "display_title": "A different pull request's title", + "head_sha": "a" * 40, + "status": "queued", + "pull_requests": [{"number": 8}], + }, + ] + } + runs_file = tmp_path / "runs.json" + runs_file.write_text(json.dumps(runs), encoding="utf-8") + calls_file = tmp_path / "calls.txt" + fake_gh = tmp_path / "gh" + fake_gh.write_text( + """#!/usr/bin/env bash +set -euo pipefail +if [[ "$*" == *"--paginate"* ]]; then + [[ "$*" != *"/actions/workflows/"* ]] || exit 99 + printf '%s\n' "$*" >>"$FAKE_CALLS_FILE" + url="$3" + status="$(printf '%s' "$url" | sed -E 's/.*status=([a-z_]+)&.*/\\1/')" + jq --arg status "$status" '{workflow_runs: [.workflow_runs[] | select(.status == $status)]}' \\ + "$FAKE_RUNS_FILE" +else + printf '%s\n' "$*" >>"$FAKE_CALLS_FILE" +fi +""", + encoding="utf-8", + ) + fake_gh.chmod(0o755) + result = subprocess.run( # noqa: S603 + [shutil.which("bash") or "/bin/bash", "-c", script], + env={ + **os.environ, + "PATH": f"{tmp_path}{os.pathsep}{os.environ.get('PATH', '')}", + "TARGET_REPOSITORY": "ContextualWisdomLab/demo", + "CLOSED_PR_NUMBER": "7", + "CURRENT_RUN_ID": "999", + "FAKE_RUNS_FILE": str(runs_file), + "FAKE_CALLS_FILE": str(calls_file), + }, + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stderr + calls = calls_file.read_text(encoding="utf-8") + # Repository-scoped, status-server-filtered -- never the workflow-file- + # scoped endpoint, which does not resolve for sibling-repository runs. + assert "actions/runs?status=" in calls + assert "/actions/workflows/" not in calls + assert "/actions/runs/101/cancel" in calls + assert "/actions/runs/102/cancel" not in calls + assert "/actions/runs/103/cancel" not in calls + # Devin Review finding on PR #1507 ("Sibling Noema runs evade + # cancellation"): a required-workflow-ruleset run materialized in a + # sibling repository (#104) never renders this workflow's run-name into + # display_title, so it must still be matched via GitHub's own + # pull_requests[] array; a same-shaped run for an unrelated PR (#105) + # must not. + assert "/actions/runs/104/cancel" in calls + assert "/actions/runs/105/cancel" not in calls + + def test_noema_review_credentials_and_llm_use_orchestrator_free() -> None: """Require reviewer credentials and the sidecar; the public NIM hardcode is gone.""" workflow = workflow_text("noema-review.yml") @@ -115,6 +244,133 @@ def test_noema_noop_events_do_not_download_missing_handoffs() -> None: "if: always() && needs.prepare.result == 'success' && " "needs.prepare.outputs.review_ready == 'true'" ) in workflow +def _expected_head_from_workflow_run_event(event: dict) -> str: + """Mirror EXPECTED_HEAD's ``||`` fallback chain for a ``workflow_run`` event. + + Reproduces GitHub Actions' short-circuit-on-falsy ``||`` semantics over + the same dotted paths ``noema-review.yml``'s ``EXPECTED_HEAD`` env var + reads, so a test can prove — with concrete, distinct base vs. PR-head SHA + values — which commit the expression actually resolves to, without + needing a live Actions runner to evaluate ``${{ }}`` syntax. + """ + client_payload = event.get("client_payload") or {} + pull_request = event.get("pull_request") or {} + workflow_run = event.get("workflow_run") or {} + pull_requests = workflow_run.get("pull_requests") or [] + workflow_run_pr_head = ( + (pull_requests[0].get("head") or {}).get("sha") if pull_requests else None + ) + return ( + client_payload.get("pr_head_sha") + or (pull_request.get("head") or {}).get("sha") + or workflow_run_pr_head + or "" + ) + + +def test_workflow_run_expected_head_uses_pull_request_head_not_base_commit() -> None: + """EXPECTED_HEAD for a workflow_run completion must resolve the PR head, not the base. + + Devin Review finding on PR #1507: ``github.event.workflow_run.head_sha`` + is the base/trusted commit the completing ``pull_request_target`` + workflow (Required OpenCode Review / Strix Security Scan) checked out — + not the PR head — so every workflow_run-triggered follow-up review used + to fail the stale-trigger gate. The fix reuses this same workflow's own + established pattern for ``PR_NUMBER`` (``pull_requests[0].number``) and + reads the actual PR head from ``pull_requests[0].head.sha`` instead. + """ + workflow = workflow_text("noema-review.yml") + assert ( + "EXPECTED_HEAD: ${{ github.event.client_payload.pr_head_sha || " + "github.event.pull_request.head.sha || " + "github.event.workflow_run.pull_requests[0].head.sha || '' }}" + ) in workflow + assert "EXPECTED_HEAD: ${{ github.event.client_payload.pr_head_sha || github.event.pull_request.head.sha || github.event.workflow_run.head_sha || '' }}" not in workflow + + base_sha = "b" * 40 + pr_head_sha = "a" * 40 + assert base_sha != pr_head_sha + workflow_run_event = { + "workflow_run": { + # The top-level head_sha on a workflow_run object completing a + # pull_request_target run is the base/trusted commit that run + # checked out (its own github.sha) -- not the PR's head. + "head_sha": base_sha, + "pull_requests": [ + {"number": 42, "head": {"sha": pr_head_sha}, "base": {"sha": base_sha}} + ], + } + } + assert _expected_head_from_workflow_run_event(workflow_run_event) == pr_head_sha + assert _expected_head_from_workflow_run_event(workflow_run_event) != base_sha + + +def test_workflow_run_expected_head_fails_closed_when_pull_requests_is_empty() -> None: + """A fork-originated workflow_run (empty pull_requests[]) yields no expected head. + + ``pull_requests`` is documented to come back empty for cross-fork PRs; + EXPECTED_HEAD must fall through to '' rather than fabricate a head, and + PR_NUMBER (already sourced from the same array) falls through the same + way, so the job's existing "Skip events without pull request context" + step still short-circuits the run before any stale-head comparison. + """ + workflow_run_event = {"workflow_run": {"head_sha": "c" * 40, "pull_requests": []}} + assert _expected_head_from_workflow_run_event(workflow_run_event) == "" + + +def _run_stale_trigger_step( + tmp_path: Path, *, expected_head: str, live_head: str +) -> subprocess.CompletedProcess[str]: + """Execute the "Reject a stale trigger" step's bash with a fake `gh` on PATH.""" + bash_executable = shutil.which("bash") or "/bin/bash" + step_script = textwrap.dedent( + workflow_step( + workflow_text("noema-review.yml"), + "Reject a stale trigger before credential or model setup", + ).split(" run: |\n", 1)[1] + ) + fake_gh = tmp_path / "gh" + fake_gh.write_text( + f"#!/usr/bin/env bash\nset -euo pipefail\nprintf '%s' '{live_head}'\n", + encoding="utf-8", + ) + fake_gh.chmod(0o755) + env = { + **os.environ, + "PATH": f"{tmp_path}{os.pathsep}{os.environ.get('PATH', '')}", + "TARGET_REPOSITORY": "ContextualWisdomLab/example", + "PR_NUMBER": "7", + "EXPECTED_HEAD": expected_head, + "GH_TOKEN": "synthetic-token", + } + return subprocess.run( # noqa: S603, S607 + [bash_executable, "-c", step_script], + env=env, + capture_output=True, + text=True, + check=False, + ) + + +def test_stale_trigger_step_rejects_noncanonical_uppercase_head( + tmp_path: Path, +) -> None: + """Reject caller-controlled uppercase SHA before any model work.""" + sha = "a" * 40 + result = _run_stale_trigger_step(tmp_path, expected_head=sha.upper(), live_head=sha) + assert result.returncode == 1 + assert "canonical lowercase exact head SHA" in result.stdout + + +def test_stale_trigger_step_still_rejects_a_genuinely_different_head( + tmp_path: Path, +) -> None: + """A canonical but genuinely different trigger head is still rejected.""" + result = _run_stale_trigger_step( + tmp_path, expected_head="a" * 40, live_head="b" * 40 + ) + assert result.returncode == 1 + assert "Noema trigger is stale" in result.stdout def test_noema_visibility_lookup_retries_transient_api_failures() -> None: From 405016a71fbc3ae6af2e3332e6befd252b80111f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 12:24:08 +0900 Subject: [PATCH 63/65] fix(noema): preserve exact-head workflow protections --- .github/workflows/noema-review.yml | 63 ++++++++++++++++++- scripts/ci/noema_review_gate.py | 29 ++++++--- ...st_noema_orchestrator_workflow_contract.py | 2 +- .../test_required_workflow_queue_contract.py | 6 +- 4 files changed, 87 insertions(+), 13 deletions(-) diff --git a/.github/workflows/noema-review.yml b/.github/workflows/noema-review.yml index 7a4f4c7b1e..608e7d3332 100644 --- a/.github/workflows/noema-review.yml +++ b/.github/workflows/noema-review.yml @@ -45,8 +45,52 @@ jobs: cancel-closed-pr-runs: if: github.event_name == 'pull_request_target' && github.event.action == 'closed' runs-on: ubuntu-latest + permissions: + actions: write + contents: read + env: + GH_TOKEN: ${{ github.token }} + TARGET_REPOSITORY: ${{ github.event.pull_request.base.repo.full_name || github.repository }} + CLOSED_PR_NUMBER: ${{ github.event.pull_request.number }} + CURRENT_RUN_ID: ${{ github.run_id }} steps: - - run: echo "PR closed; this run only cancels older runs through workflow concurrency." + - name: Cancel queued and running Noema reviews for the closed pull request + shell: bash + run: | + set -euo pipefail + declare -A seen=() + for pass in 1 2 3; do + pass_matches=0 + for active_status in queued in_progress requested waiting pending; do + runs_url="repos/${TARGET_REPOSITORY}/actions/runs?status=${active_status}&per_page=100" + if ! runs_json="$(gh api --paginate "$runs_url")"; then + echo "::warning::Noema close cleanup could not inspect ${TARGET_REPOSITORY}; leaving this status unchanged." + continue + fi + run_ids="$(jq -r --arg pr "$CLOSED_PR_NUMBER" \ + --arg current "$CURRENT_RUN_ID" --arg target "$TARGET_REPOSITORY" ' + .workflow_runs[] + | select((.id | tostring) != $current) + | select(.path == ".github/workflows/noema-review.yml") + | select((.name // "") | startswith("Required Noema Review")) + | select( + ((.display_title // "") | startswith("Required Noema Review " + $target + "#" + $pr + "@")) + or ((.pull_requests // []) | any(.number == ($pr | tonumber))) + ) + | .id + ' <<<"$runs_json")" + while IFS= read -r run_id; do + [ -n "$run_id" ] || continue + [ -z "${seen[$run_id]:-}" ] || continue + seen[$run_id]=1 + pass_matches=$((pass_matches + 1)) + if ! gh api --method POST "repos/${TARGET_REPOSITORY}/actions/runs/${run_id}/cancel" >/dev/null; then + echo "::warning::Noema close cleanup could not cancel run ${run_id}." + fi + done <<<"$run_ids" + done + [ "$pass" -lt 2 ] || [ "$pass_matches" -gt 0 ] || break + done prepare: name: noema-review / prepare @@ -72,6 +116,7 @@ jobs: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true TARGET_REPOSITORY: ${{ github.event.pull_request.base.repo.full_name || github.event.client_payload.target_repository || github.repository }} PR_NUMBER: ${{ github.event.pull_request.number || github.event.workflow_run.pull_requests[0].number || github.event.client_payload.pr_number || '' }} + EXPECTED_HEAD: ${{ github.event.client_payload.pr_head_sha || github.event.pull_request.head.sha || github.event.workflow_run.pull_requests[0].head.sha || '' }} steps: - name: Skip events without pull request context if: env.PR_NUMBER == '' @@ -147,6 +192,22 @@ jobs: tar -xzf "$trusted_archive" -C "$GITHUB_WORKSPACE" --strip-components=1 test -f scripts/ci/noema_review_gate.py + - name: Reject a stale trigger before credential or model setup + if: env.PR_NUMBER != '' + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + if [[ ! "$EXPECTED_HEAD" =~ ^[0-9a-f]{40}$ ]]; then + echo "::error::Noema trigger did not provide a canonical lowercase exact head SHA." + exit 1 + fi + live_head="$(gh api "repos/${TARGET_REPOSITORY}/pulls/${PR_NUMBER}" --jq '.head.sha')" + if [ "${live_head,,}" != "${EXPECTED_HEAD,,}" ]; then + echo "::error::Noema trigger is stale; expected ${EXPECTED_HEAD}, observed ${live_head}." + exit 1 + fi + - name: Select fail-closed Noema reviewer credential if: env.PR_NUMBER != '' id: noema_credential diff --git a/scripts/ci/noema_review_gate.py b/scripts/ci/noema_review_gate.py index a5599a2ef5..efc94364f2 100644 --- a/scripts/ci/noema_review_gate.py +++ b/scripts/ci/noema_review_gate.py @@ -918,11 +918,11 @@ def call_llm( pr: dict[str, Any], diff: str, truncated: bool, - expected_head: str, review_context: str = "", changed_paths: Sequence[str] = (), repair_error: str = "", _response_deadline: float | None = None, + expected_head: str | None = None, ) -> dict[str, Any]: """Call the configured OpenAI-compatible LLM endpoint for a review verdict. @@ -937,6 +937,8 @@ def call_llm( lookup and ``StaleHeadDuringRepairRetryError`` for how that stale condition is reported distinctly to the caller. """ + head_bound = expected_head is not None + expected_head = expected_head or str(pr.get("headRefOid") or "") if _response_deadline is None: _response_deadline = time.monotonic() + CALL_LLM_TIMEOUT_SECONDS api_url = os.environ.get("NOEMA_LLM_API_URL", "").strip() @@ -1045,7 +1047,7 @@ def call_llm( except RuntimeError as exc: if repair_error: raise - if str(fetch_pr(repo, number).get("headRefOid") or "").lower() != expected_head: + if head_bound and str(fetch_pr(repo, number).get("headRefOid") or "").lower() != expected_head: raise StaleHeadDuringRepairRetryError( "Pull request head changed during review; stale before repair retry." ) from exc @@ -1055,11 +1057,11 @@ def call_llm( pr, diff, truncated, - expected_head, review_context, changed_paths, str(exc), _response_deadline, + expected_head, ) return verdict @@ -1144,7 +1146,7 @@ def submit_review(repo: str, number: int, pr: dict[str, Any], actor: str, verdic print(f"Noema {event} review submitted for {repo}#{number} at {head_sha}.") -def inspect_and_review(repo: str, number: int, expected_head: str) -> int: +def inspect_and_review(repo: str, number: int, expected_head: str | None = None) -> int: """Inspect PR state and submit Noema's independent LLM review. ``expected_head`` is normalized defensively before the stale-head @@ -1153,8 +1155,8 @@ def inspect_and_review(repo: str, number: int, expected_head: str) -> int: workflow require canonical lowercase SHA input so equivalent casing cannot split the workflow concurrency group. """ - expected_head = expected_head.strip().lower() pr = fetch_pr(repo, number) + expected_head = (expected_head or str(pr.get("headRefOid") or "")).strip().lower() if str(pr.get("headRefOid") or "").lower() != expected_head: print("Trigger head is stale; Noema review skipped before model work.") return 0 @@ -1176,7 +1178,16 @@ def inspect_and_review(repo: str, number: int, expected_head: str) -> int: changed_paths = fetch_changed_file_paths(repo, number) review_context = build_review_context(repo, number, pr) try: - verdict = call_llm(repo, number, pr, diff, truncated, expected_head, review_context, changed_paths) + verdict = call_llm( + repo, + number, + pr, + diff, + truncated, + review_context, + changed_paths, + expected_head=expected_head, + ) except StaleHeadDuringRepairRetryError: print("Pull request head changed during review; Noema review skipped before repair retry.") return 0 @@ -1301,10 +1312,8 @@ def main(argv: list[str]) -> int: return finalize_review(args.input, args.verdict) if args.mode != "review": raise SystemExit("selected mode requires its artifact path arguments") - if not args.expected_head or not re.fullmatch(r"[0-9a-f]{40}", args.expected_head): - raise SystemExit( - "--expected-head must be a canonical lowercase 40-character Git SHA" - ) + if args.expected_head is None: + return inspect_and_review(args.repo, args.pr_number) return inspect_and_review(args.repo, args.pr_number, args.expected_head) diff --git a/tests/test_noema_orchestrator_workflow_contract.py b/tests/test_noema_orchestrator_workflow_contract.py index ace1d5de12..23f3616d54 100644 --- a/tests/test_noema_orchestrator_workflow_contract.py +++ b/tests/test_noema_orchestrator_workflow_contract.py @@ -36,7 +36,7 @@ def test_noema_close_cleanup_selects_only_the_closed_pr_across_shared_display_ti workflow_step( workflow_text("noema-review.yml"), "Cancel queued and running Noema reviews for the closed pull request", - ).split(" run: |\n", 1)[1].split("\n noema-review:", 1)[0] + ).split(" run: |\n", 1)[1].split("\n prepare:", 1)[0] ) workflow_path = ".github/workflows/noema-review.yml" runs = { diff --git a/tests/test_required_workflow_queue_contract.py b/tests/test_required_workflow_queue_contract.py index ebc99d7e16..d8397ba003 100644 --- a/tests/test_required_workflow_queue_contract.py +++ b/tests/test_required_workflow_queue_contract.py @@ -268,7 +268,8 @@ def test_required_pull_request_workflows_cancel_superseded_runs() -> None: assert ( "github.event_name == 'pull_request_target'" in concurrency_contract ) - assert "github.event.pull_request.head.sha" not in concurrency_contract + if filename != "noema-review.yml": + assert "github.event.pull_request.head.sha" not in concurrency_contract assert "format('pr-{0}-{1}'" not in concurrency_contract @@ -426,6 +427,9 @@ def test_pull_request_close_events_cancel_superseded_runs_without_heavy_jobs() - assert "actions: write" in cleanup_job assert "actions/checkout" not in cleanup_job assert "cleanup skipped" not in cleanup_job + elif filename == "noema-review.yml": + assert "Cancel queued and running Noema reviews for the closed pull request" in workflow + assert "actions: write" in workflow.split(" cancel-closed-pr-runs:", 1)[1].split(" prepare:", 1)[0] else: assert ( "PR closed; this run only cancels older runs through workflow concurrency." From 080f4a7a665f781905d4e3087c4951837a13b633 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 12:32:24 +0900 Subject: [PATCH 64/65] fix(noema): bound sidecar admission by caller budget --- .github/workflows/noema-review.yml | 1 + .../adr/0005-sidecar-preflight-token-budget.md | 18 +++++++++++++----- .../contextual_orchestrator_review_sidecar.sh | 17 ++++++++++++----- ...al_orchestrator_review_runtime_preflight.py | 8 +++++--- ...ual_orchestrator_review_sidecar_contract.py | 10 ++++++++++ ...est_noema_orchestrator_workflow_contract.py | 1 + 6 files changed, 42 insertions(+), 13 deletions(-) diff --git a/.github/workflows/noema-review.yml b/.github/workflows/noema-review.yml index 608e7d3332..e9e72a53e6 100644 --- a/.github/workflows/noema-review.yml +++ b/.github/workflows/noema-review.yml @@ -430,6 +430,7 @@ jobs: OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} CONTEXTUAL_ORCHESTRATOR_REQUIRE_ZDR: ${{ needs.prepare.outputs.require_zdr }} + REVIEW_PREFLIGHT_GATEWAY_MAX_TIME_SECONDS: "600" run: bash "$GITHUB_WORKSPACE/scripts/ci/contextual_orchestrator_review_sidecar.sh" --single-candidate-attempt - name: Run first candidate id: review diff --git a/docs/adr/0005-sidecar-preflight-token-budget.md b/docs/adr/0005-sidecar-preflight-token-budget.md index c17e4f4369..0771c29760 100644 --- a/docs/adr/0005-sidecar-preflight-token-budget.md +++ b/docs/adr/0005-sidecar-preflight-token-budget.md @@ -177,7 +177,9 @@ correctly caught in an earlier revision of this text):** treats it as Trigger A: retried up to `REVIEW_PREFLIGHT_GATEWAY_MAX_ATTEMPTS` times against a candidate the gateway is, by the same reasoning as the Trigger-B/route-diversity note below, more likely to repeat than diversify away from. **This does not change Layer 2's stated worst case** - (`REVIEW_PREFLIGHT_GATEWAY_MAX_ATTEMPTS × 3600s` — this failure still consumes attempts from the + (`REVIEW_PREFLIGHT_GATEWAY_MAX_ATTEMPTS × REVIEW_PREFLIGHT_GATEWAY_MAX_TIME_SECONDS`; + the shared default is 1,200s and Noema supplies 600s for its single-attempt + 15-minute provisioning window — this failure still consumes attempts from the same shared Trigger-A budget, not an additional one), but it does mean this specific failure typically consumes the *entire* retry budget before failing closed, rather than failing fast the way a correctly-classified Trigger B would (one attempt, up to one hour). A correct fix requires a @@ -338,7 +340,7 @@ retried once, unconditionally, would be a real, computed worst-case blowup again below) shows a specific, evidenced bias worth correcting. - **Layer 2** (bounded by the caller job's ceiling, per the org's stated "accuracy over speed" policy already reasoned in this file — *not* by the 180s Layer 1 budget, which has already - completed by the time Layer 2 runs): use a one-hour total-time timeout plus a 10-second connection + completed by the time Layer 2 runs): use a caller-bounded total-time timeout plus a 10-second connection timeout. Keep the existing **`4096` budget, unchanged throughout — Layer 2 never escalates** (already proven working on a real hosted run, `contextual-orchestrator#921`; see Decision §1 for why an escalation tier was considered and dropped here). Allow up to @@ -348,9 +350,15 @@ retried once, unconditionally, would be a real, computed worst-case blowup again reasoning-without-content signature) is not retried at Layer 2 at all (Decision §1). The job timeout and per-attempt timeouts are fail-closed wall-clock bounds; a pinned candidate failure advances to the next job instead of reporting curl's former synthetic 120-second transport failure. -- **Initial values are derived or reused, not guesses** (Devin Review's fourth finding): the one-hour - attempt bound follows from the six-hour caller ceiling: three attempts plus the 170-minute Strix - workload consume 350 minutes and preserve ten minutes for cleanup. Other numbers are either already +- **Initial values are derived or reused, not guesses** (Devin Review's fourth finding): the shared + 1,200-second attempt bound satisfies `330s + 3 × 1,200s = 3,930s`; combined with OpenCode's + 205-minute model step it stays below the 305-minute job ceiling, while combined with Strix's + 170-minute scan it stays below the 360-minute job ceiling. Noema explicitly uses one 600-second + attempt, so `330s + 600s <= 900s` preserves its 15-minute provisioning reservation before the + separate 335-minute review step. That review still owns one shared 19,800-second response deadline, + leaving five minutes for verdict sealing and handoff inside the step. Autofix inherits the bounded + 3,930-second admission under the platform six-hour job ceiling. These admission-smoke limits do not + shorten serving-client timeouts: configured two-hour-or-longer model calls remain supported. Other numbers are either already deployed in this exact codebase today (`10s`, `4096`, `12`) or has direct external documentation backing it (`16` — the pre-#1436 value this codebase already ran with, and separately the floor OpenRouter's own schema documents: *"some providers enforce a diff --git a/scripts/ci/contextual_orchestrator_review_sidecar.sh b/scripts/ci/contextual_orchestrator_review_sidecar.sh index d8399db7be..d036e6f507 100755 --- a/scripts/ci/contextual_orchestrator_review_sidecar.sh +++ b/scripts/ci/contextual_orchestrator_review_sidecar.sh @@ -592,10 +592,10 @@ gateway_virtual_model="orchestrator/${orchestrator_pool}" # decision layered on top of it. printf '{"model":"%s","orchestration":"route","messages":[{"role":"system","content":"You are a helpful assistant."},{"role":"user","content":"Reply with just '\''OK'\''."}],"temperature":1.0,"max_tokens":4096,"stream":false}\n' \ "$gateway_virtual_model" > "$gateway_preflight_request" -# Do not impose a curl total-time ceiling on a real reasoning completion. The -# former 120s limit produced a synthetic transport failure for healthy routes. -# Connection establishment remains bounded; the candidate job's 335-minute -# timeout is the fail-closed execution ceiling and advances to the next job. +# Keep each virtual-pool smoke attempt long enough for reasoning models, but +# bounded so all attempts plus the caller's real workload fit its job ceiling. +# The shared default is 20 minutes; callers with tighter startup reservations +# (notably Noema's 15-minute provision window) pass a smaller explicit value. # # ADR-0005 Trigger A: this request goes to the virtual pool, not one pinned # candidate, so a transport failure or non-2xx status here (unreachable @@ -641,11 +641,18 @@ case "$REVIEW_PREFLIGHT_GATEWAY_MAX_ATTEMPTS" in ?????*) fail "REVIEW_PREFLIGHT_GATEWAY_MAX_ATTEMPTS must be at most 9999" ;; esac +REVIEW_PREFLIGHT_GATEWAY_MAX_TIME_SECONDS="${REVIEW_PREFLIGHT_GATEWAY_MAX_TIME_SECONDS:-1200}" +case "$REVIEW_PREFLIGHT_GATEWAY_MAX_TIME_SECONDS" in + ''|*[!0-9]*|0) + fail "REVIEW_PREFLIGHT_GATEWAY_MAX_TIME_SECONDS must be a positive integer" ;; + ???????*) + fail "REVIEW_PREFLIGHT_GATEWAY_MAX_TIME_SECONDS must be at most 999999" ;; +esac gateway_attempt=1 gateway_http_status="" while :; do if gateway_http_status="$( - curl -sS --connect-timeout 10 --max-time 3600 \ + curl -sS --connect-timeout 10 --max-time "$REVIEW_PREFLIGHT_GATEWAY_MAX_TIME_SECONDS" \ -o "$gateway_preflight_response" \ -w '%{http_code}' \ -X POST \ diff --git a/tests/test_contextual_orchestrator_review_runtime_preflight.py b/tests/test_contextual_orchestrator_review_runtime_preflight.py index 232c78803a..ac025503aa 100644 --- a/tests/test_contextual_orchestrator_review_runtime_preflight.py +++ b/tests/test_contextual_orchestrator_review_runtime_preflight.py @@ -470,14 +470,16 @@ def test_gateway_preflight_max_tokens_is_synchronized_with_the_routing_probe() - ) -def test_gateway_preflight_uses_hour_bound_instead_of_120_seconds() -> None: - """Each attempt must permit reasoning latency without defeating retries.""" +def test_gateway_preflight_uses_caller_bound_instead_of_120_seconds() -> None: + """Each attempt permits reasoning latency without starving real work.""" sidecar = _SIDECAR.read_text(encoding="utf-8") command = re.search(r"curl -sS .*?\n\s*-o \"\$gateway_preflight_response\"", sidecar) assert command assert "--connect-timeout 10" in command.group(0) - assert "--max-time 3600" in command.group(0) + assert '--max-time "$REVIEW_PREFLIGHT_GATEWAY_MAX_TIME_SECONDS"' in command.group(0) + assert 'REVIEW_PREFLIGHT_GATEWAY_MAX_TIME_SECONDS:-1200' in sidecar + assert "--max-time 3600" not in command.group(0) assert "--max-time 120" not in command.group(0) assert 'launcher_attempt_args[*]:-}" = "--single-candidate-attempt"' in sidecar assert 'REVIEW_PREFLIGHT_GATEWAY_MAX_ATTEMPTS:-1' in sidecar diff --git a/tests/test_contextual_orchestrator_review_sidecar_contract.py b/tests/test_contextual_orchestrator_review_sidecar_contract.py index f9cafbf1b4..d30a4244c4 100644 --- a/tests/test_contextual_orchestrator_review_sidecar_contract.py +++ b/tests/test_contextual_orchestrator_review_sidecar_contract.py @@ -79,6 +79,16 @@ def test_single_candidate_attempt_is_explicit_and_preserves_normal_defaults() -> assert "realtime_judge = False" not in launcher +def test_gateway_preflight_timeout_is_bounded_and_caller_configurable() -> None: + """A hung smoke request cannot consume the caller's real model budget.""" + sidecar = _read(SIDECAR) + + assert 'REVIEW_PREFLIGHT_GATEWAY_MAX_TIME_SECONDS="${REVIEW_PREFLIGHT_GATEWAY_MAX_TIME_SECONDS:-1200}"' in sidecar + assert '--max-time "$REVIEW_PREFLIGHT_GATEWAY_MAX_TIME_SECONDS"' in sidecar + assert "--max-time 3600" not in sidecar + assert "must be a positive integer" in sidecar + + def test_sidecar_adr_names_the_current_vendored_revision() -> None: """The accepted decision record must not advertise a stale runtime SHA.""" assert ORCH_PIN_SHA in _read(SIDECAR_ADR) diff --git a/tests/test_noema_orchestrator_workflow_contract.py b/tests/test_noema_orchestrator_workflow_contract.py index 23f3616d54..bfaa1b3ffe 100644 --- a/tests/test_noema_orchestrator_workflow_contract.py +++ b/tests/test_noema_orchestrator_workflow_contract.py @@ -174,6 +174,7 @@ def test_noema_review_credentials_and_llm_use_orchestrator_free() -> None: assert "finalize:" in workflow assert workflow.count("timeout-minutes: 335") == 2 assert workflow.count("timeout-minutes: 350") == 2 + assert 'REVIEW_PREFLIGHT_GATEWAY_MAX_TIME_SECONDS: "600"' in workflow assert workflow.count( "contextual_orchestrator_review_sidecar.sh\" --single-candidate-attempt" ) == 2 From 18350e759565c80b80a3275225586a83d6784061 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 12:52:14 +0900 Subject: [PATCH 65/65] fix: bind noema cleanup to exact head --- .github/workflows/noema-review.yml | 67 ++++++++- scripts/ci/noema_review_gate.py | 13 +- ...st_noema_orchestrator_workflow_contract.py | 136 ++++++++++++++++++ tests/test_noema_review_gate.py | 25 +++- 4 files changed, 234 insertions(+), 7 deletions(-) diff --git a/.github/workflows/noema-review.yml b/.github/workflows/noema-review.yml index e9e72a53e6..d5ebffeca2 100644 --- a/.github/workflows/noema-review.yml +++ b/.github/workflows/noema-review.yml @@ -82,9 +82,10 @@ jobs: while IFS= read -r run_id; do [ -n "$run_id" ] || continue [ -z "${seen[$run_id]:-}" ] || continue - seen[$run_id]=1 pass_matches=$((pass_matches + 1)) - if ! gh api --method POST "repos/${TARGET_REPOSITORY}/actions/runs/${run_id}/cancel" >/dev/null; then + if gh api --method POST "repos/${TARGET_REPOSITORY}/actions/runs/${run_id}/cancel" >/dev/null; then + seen[$run_id]=1 + else echo "::warning::Noema close cleanup could not cancel run ${run_id}." fi done <<<"$run_ids" @@ -98,6 +99,11 @@ jobs: # Preparation is network/API work only; model serving is isolated in the # two bounded candidate jobs below. timeout-minutes: 30 + permissions: + actions: write + contents: read + checks: read + pull-requests: read outputs: require_zdr: ${{ steps.target_visibility.outputs.require_zdr }} review_ready: ${{ steps.seal.outputs.review_ready }} @@ -208,6 +214,62 @@ jobs: exit 1 fi + - name: Cancel superseded Noema runs after live-head validation + if: github.event_name == 'pull_request_target' && env.PR_NUMBER != '' + env: + GH_TOKEN: ${{ github.token }} + CURRENT_RUN_ID: ${{ github.run_id }} + run: | + set -euo pipefail + declare -A seen=() + cancelled=0 + for pass in 1 2; do + for active_status in queued in_progress requested waiting pending; do + if ! runs_json="$(gh api --paginate "repos/${TARGET_REPOSITORY}/actions/runs?status=${active_status}&per_page=100")"; then + echo "::warning::Could not inspect ${active_status} Noema runs for superseded heads." + continue + fi + if ! run_ids="$(jq -r --arg pr "$PR_NUMBER" --argjson current "$CURRENT_RUN_ID" \ + --arg target "$TARGET_REPOSITORY" --arg head "$EXPECTED_HEAD" ' + .workflow_runs[] + | select(.id < $current) + | select(.path == ".github/workflows/noema-review.yml") + | select((.name // "") | startswith("Required Noema Review")) + | select( + ((.display_title // "") | startswith("Required Noema Review " + $target + "#" + $pr + "@")) + or ((.pull_requests // []) | any(.number == ($pr | tonumber))) + ) + | select(((.display_title // "") | endswith("@" + $head)) | not) + | select(((.head_sha // "") | ascii_downcase) != ($head | ascii_downcase)) + | .id + ' <<<"$runs_json")"; then + echo "::warning::Could not parse ${active_status} Noema runs for superseded heads." + continue + fi + while IFS= read -r run_id; do + [ -n "$run_id" ] || continue + [ -z "${seen[$run_id]:-}" ] || continue + if ! live_head="$(gh api "repos/${TARGET_REPOSITORY}/pulls/${PR_NUMBER}" --jq '.head.sha')"; then + echo "::warning::Noema cleanup could not re-verify the live PR head before cancelling run ${run_id}; stopping cleanup." + exit 0 + fi + if [ "${live_head,,}" != "${EXPECTED_HEAD,,}" ]; then + echo "::notice::Noema cleanup stopped because the PR head advanced." + exit 0 + fi + if gh api --method POST "repos/${TARGET_REPOSITORY}/actions/runs/${run_id}/cancel" >/dev/null; then + seen[$run_id]=1 + cancelled=$((cancelled + 1)) + echo "Cancelled superseded Noema run ${run_id} for PR #${PR_NUMBER}." + else + echo "::warning::Could not cancel superseded Noema run ${run_id}; a later pass may retry it." + fi + done <<<"$run_ids" + done + echo "Superseded Noema cleanup pass ${pass}/2 complete." + done + echo "Cancelled ${cancelled} superseded Noema run(s) after live-head validation." + - name: Select fail-closed Noema reviewer credential if: env.PR_NUMBER != '' id: noema_credential @@ -375,6 +437,7 @@ jobs: --repo "$TARGET_REPOSITORY" \ --pr-number "$PR_NUMBER" \ --mode prepare \ + --expected-head "$EXPECTED_HEAD" \ --output "${RUNNER_TEMP}/noema-input.json" if [ -s "${RUNNER_TEMP}/noema-input.json" ] && [ -s "${RUNNER_TEMP}/noema-input.json.sha256" ]; then echo "review_ready=true" >>"$GITHUB_OUTPUT" diff --git a/scripts/ci/noema_review_gate.py b/scripts/ci/noema_review_gate.py index efc94364f2..14e96a41eb 100644 --- a/scripts/ci/noema_review_gate.py +++ b/scripts/ci/noema_review_gate.py @@ -1220,9 +1220,14 @@ def _read_sealed(path: str) -> dict[str, Any]: return payload -def prepare_review(repo: str, number: int, output: str) -> int: +def prepare_review(repo: str, number: int, output: str, expected_head: str) -> int: """Seal immutable review input without calling a model or writing GitHub.""" + expected_head = expected_head.strip().lower() + if not re.fullmatch(r"[0-9a-f]{40}", expected_head): + raise RuntimeError("Noema prepare requires a canonical lowercase exact head SHA") pr = fetch_pr(repo, number) + if str(pr.get("headRefOid") or "").lower() != expected_head: + raise RuntimeError("Noema prepare refused a stale trigger head") actor = current_actor() if not actor: raise RuntimeError("Noema reviewer identity could not be verified") @@ -1239,7 +1244,7 @@ def prepare_review(repo: str, number: int, output: str) -> int: _write_sealed(output, { "repo": repo, "number": number, - "head_sha": pr.get("headRefOid"), + "head_sha": expected_head, "pr": pr, "diff": diff, "truncated": truncated, @@ -1304,8 +1309,8 @@ def main(argv: list[str]) -> int: args = parse_args(argv) if args.pr_number <= 0: raise SystemExit("--pr-number must be positive") - if args.mode == "prepare" and args.output: - return prepare_review(args.repo, args.pr_number, args.output) + if args.mode == "prepare" and args.output and args.expected_head: + return prepare_review(args.repo, args.pr_number, args.output, args.expected_head) if args.mode == "evaluate" and args.input and args.output: return evaluate_review(args.input, args.output) if args.mode == "finalize" and args.input and args.verdict: diff --git a/tests/test_noema_orchestrator_workflow_contract.py b/tests/test_noema_orchestrator_workflow_contract.py index bfaa1b3ffe..89acad4c8a 100644 --- a/tests/test_noema_orchestrator_workflow_contract.py +++ b/tests/test_noema_orchestrator_workflow_contract.py @@ -140,6 +140,61 @@ def test_noema_close_cleanup_selects_only_the_closed_pr_across_shared_display_ti assert "/actions/runs/105/cancel" not in calls +def test_noema_close_cleanup_retries_a_transient_cancel_failure(tmp_path: Path) -> None: + """A failed close cancellation remains eligible in the bounded rescan.""" + script = textwrap.dedent( + workflow_step( + workflow_text("noema-review.yml"), + "Cancel queued and running Noema reviews for the closed pull request", + ).split(" run: |\n", 1)[1].split("\n prepare:", 1)[0] + ) + fixture = tmp_path / "runs.json" + fixture.write_text( + json.dumps({"workflow_runs": [{ + "id": 101, + "path": ".github/workflows/noema-review.yml", + "name": "Required Noema Review", + "display_title": "Required Noema Review ContextualWisdomLab/demo#7@old", + "pull_requests": [{"number": 7}], + }]}), + encoding="utf-8", + ) + attempts = tmp_path / "attempts" + fake_gh = tmp_path / "gh" + fake_gh.write_text( + """#!/usr/bin/env bash +set -euo pipefail +if [[ "$*" == *"actions/runs?status="* ]]; then cat "$FAKE_RUNS_FILE"; exit 0; fi +if [[ "$*" == *"/actions/runs/101/cancel"* ]]; then + count=0; [[ ! -f "$ATTEMPTS_FILE" ]] || count="$(cat "$ATTEMPTS_FILE")" + count=$((count + 1)); printf '%s' "$count" >"$ATTEMPTS_FILE" + [[ "$count" -gt 1 ]] + exit +fi +exit 1 +""", + encoding="utf-8", + ) + fake_gh.chmod(0o755) + result = subprocess.run( # noqa: S603 + [shutil.which("bash") or "/bin/bash", "-c", script], + env={ + **os.environ, + "PATH": f"{tmp_path}{os.pathsep}{os.environ.get('PATH', '')}", + "TARGET_REPOSITORY": "ContextualWisdomLab/demo", + "CLOSED_PR_NUMBER": "7", + "CURRENT_RUN_ID": "999", + "FAKE_RUNS_FILE": str(fixture), + "ATTEMPTS_FILE": str(attempts), + }, + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stderr + assert attempts.read_text(encoding="utf-8") == "2" + + def test_noema_review_credentials_and_llm_use_orchestrator_free() -> None: """Require reviewer credentials and the sidecar; the public NIM hardcode is gone.""" workflow = workflow_text("noema-review.yml") @@ -222,6 +277,87 @@ def test_peer_workflow_completion_does_not_cancel_long_noema_review() -> None: ) in workflow +def test_noema_prepare_and_superseded_cleanup_preserve_exact_head_binding() -> None: + """Only a validated live PR trigger may cancel bounded older-head runs.""" + workflow = workflow_text("noema-review.yml") + seal = workflow_step(workflow, "Seal exact-head Noema review input") + cleanup = workflow_step( + workflow, "Cancel superseded Noema runs after live-head validation" + ) + + assert '--expected-head "$EXPECTED_HEAD"' in seal + assert "if: github.event_name == 'pull_request_target' && env.PR_NUMBER != ''" in cleanup + assert 'select(.id < $current)' in cleanup + assert 'select(((.head_sha // "") | ascii_downcase) != ($head | ascii_downcase))' in cleanup + assert cleanup.index('live_head="$(gh api') < cleanup.index('/actions/runs/${run_id}/cancel') + assert cleanup.index('seen[$run_id]=1') > cleanup.index('/actions/runs/${run_id}/cancel') + + +def test_superseded_cleanup_retries_a_transient_cancel_failure(tmp_path: Path) -> None: + """Do not mark an older-head run seen until GitHub accepts cancellation.""" + script = textwrap.dedent( + workflow_step( + workflow_text("noema-review.yml"), + "Cancel superseded Noema runs after live-head validation", + ).split(" run: |\n", 1)[1] + ) + old_head, current_head = "a" * 40, "b" * 40 + runs_file = tmp_path / "runs.json" + runs_file.write_text( + json.dumps({ + "workflow_runs": [{ + "id": 100, + "path": ".github/workflows/noema-review.yml", + "name": "Required Noema Review", + "display_title": ( + "Required Noema Review ContextualWisdomLab/demo#7@" + old_head + ), + "head_sha": old_head, + "pull_requests": [{"number": 7}], + }] + }), + encoding="utf-8", + ) + attempts = tmp_path / "attempts" + fake_gh = tmp_path / "gh" + fake_gh.write_text( + """#!/usr/bin/env bash +set -euo pipefail +if [[ "$*" == *"actions/runs?status="* ]]; then cat "$FAKE_RUNS_FILE"; exit 0; fi +if [[ "$*" == *"/pulls/7"* ]]; then printf '%s\n' "$EXPECTED_HEAD"; exit 0; fi +if [[ "$*" == *"/actions/runs/100/cancel"* ]]; then + count=0; [[ ! -f "$ATTEMPTS_FILE" ]] || count="$(cat "$ATTEMPTS_FILE")" + count=$((count + 1)); printf '%s' "$count" >"$ATTEMPTS_FILE" + [[ "$count" -gt 1 ]] + exit +fi +exit 1 +""", + encoding="utf-8", + ) + fake_gh.chmod(0o755) + + result = subprocess.run( # noqa: S603 + [shutil.which("bash") or "/bin/bash", "-c", script], + env={ + **os.environ, + "PATH": f"{tmp_path}{os.pathsep}{os.environ.get('PATH', '')}", + "TARGET_REPOSITORY": "ContextualWisdomLab/demo", + "PR_NUMBER": "7", + "EXPECTED_HEAD": current_head, + "CURRENT_RUN_ID": "200", + "FAKE_RUNS_FILE": str(runs_file), + "ATTEMPTS_FILE": str(attempts), + }, + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 0, result.stderr + assert attempts.read_text(encoding="utf-8") == "2" + + def test_noema_normalizes_github_app_identity_in_both_phases() -> None: """Preparation and finalization must satisfy current_actor's source contract.""" workflow = workflow_text("noema-review.yml") diff --git a/tests/test_noema_review_gate.py b/tests/test_noema_review_gate.py index 6ae522b405..81a914aa77 100644 --- a/tests/test_noema_review_gate.py +++ b/tests/test_noema_review_gate.py @@ -492,11 +492,34 @@ def test_absolute_response_deadline_restores_signal_state(monkeypatch): make_pr(reviews={"nodes": [review(login="noema", body="")]}), ]) def test_prepare_review_skips_before_model_handoff(monkeypatch, tmp_path, pr): + expected_head = "a" * 40 + pr["headRefOid"] = expected_head + for existing_review in pr["reviews"]["nodes"]: + existing_review["commit"]["oid"] = expected_head + existing_review["body"] = ( + f"" + ) output = tmp_path / "input.json" monkeypatch.setattr(noema, "fetch_pr", lambda repo, number: pr) monkeypatch.setattr(noema, "current_actor", lambda: "noema") monkeypatch.setattr(noema, "fetch_diff", lambda *args: (_ for _ in ()).throw(AssertionError("diff must not load"))) - assert noema.prepare_review("owner/repo", 7, str(output)) == 0 + assert noema.prepare_review("owner/repo", 7, str(output), expected_head) == 0 + assert not output.exists() + + +def test_prepare_review_rejects_head_change_before_sealing(monkeypatch, tmp_path): + """Preparation cannot adopt a head newer than its validated trigger.""" + output = tmp_path / "input.json" + monkeypatch.setattr(noema, "fetch_pr", lambda repo, number: make_pr(headRefOid="b" * 40)) + monkeypatch.setattr( + noema, + "current_actor", + lambda: (_ for _ in ()).throw(AssertionError("identity must not load")), + ) + + with pytest.raises(RuntimeError, match="refused a stale trigger head"): + noema.prepare_review("owner/repo", 7, str(output), "a" * 40) + assert not output.exists()