Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@

- `opencode-review-dispatch.yml` now carries a workflow-level `concurrency` group keyed by the dispatched pull request (`opencode-review-dispatch-<target repository>-<pr number>`, `cancel-in-progress: true`), matching `codeql-scan-dispatch.yml`'s workflow-level group and the rationale already recorded in `strix.yml`, `noema-review.yml` and `opencode-review.yml`: a job-level group is never evaluated while the whole run waits behind the organization job ceiling. The workflow kept its group only on the long `opencode-review-target` job, so two dispatches for one pull request each queued for hours and each was allocated a runner before the older one could be discarded. Measured on 2026-09-06: four of the five dispatch runs that passed `validate-pr-metadata` were rejected hours later by the privileged metadata check because the head had moved while they queued (runs `34002473295`, `34010256951`, `34015973300`, `34016922761`), each after `coverage-source-tree` and `coverage-evidence` had run. The privileged check itself is unchanged -- it rejected exactly what it should; what changes is that the superseded run is now cancelled at creation instead of spending a slot to discover its subject moved.

### 통신 실패 뒤 응답 정리 보완

- 리뷰·정책·출판·준비 상태 검사에서 실패한 응답을 정리하고, 정리 오류가 원래 실패 원인을 가리지 않게 했습니다. 사용자 취소는 그대로 전달합니다.

### Strix gate names the sandbox bootstrap failure and retries it once

- `scripts/ci/strix_quick_gate.sh` gives the Caido sandbox bootstrap race (`loginAsGuest failed after 10 attempts` on `127.0.0.1:<port>`, upstream usestrix/strix#1036/#1037/#1056) its own bounded same-model retry budget, `STRIX_SANDBOX_BOOTSTRAP_RETRIES` (default 1), drawn on top of `STRIX_TRANSIENT_RETRY_PER_MODEL`. That budget is 0 in production because the gateway owns model failover, so the documented sandbox retry never ran: `argos` Strix run 34013128112 (2026-09-06) shows one attempt, `Docker image ready`, the proxy never reachable, Strix exiting after 240 s -- while the sidecar reported four ready and four deferred routes that were never called. The budget is charged in the same branch that grants the attempt, so a log matching the sandbox class together with a gateway class cannot extend the loop without charging it (caught by adversarial review of the first draft). The primary-scan verdict for that class now reads `STRIX_PROVIDER_UNAVAILABLE: STRIX_SANDBOX_UNAVAILABLE: the last Strix attempt ended in the sandbox bootstrap (...) after N sandbox-specific same-model retries (budget B); this verdict names Strix's sandbox, not the LLM gateway.` instead of `orchestrator/free exhausted`, stating only what the gate observed; the leading token is unchanged so the workflow's finding-free classification and its tests are untouched, and the second token lets the review census split sandbox outages from gateway ones (two of six recent Strix artifacts were this class). Refs #1948.
Expand Down
35 changes: 35 additions & 0 deletions docs/doctoring/noema-repair-attempt-telemetry.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,41 @@ Noema workflow -> local contextual-orchestrator sidecar -> orchestrator/free ->

If the gateway cannot produce a valid structured verdict, Noema fails closed after that one caller request. If the PR head moves during model work, the post-call exact-head check discards the stale verdict. If telemetry carries hostile model identifiers, annotation sanitization prevents CR/LF or surrogate data from becoming workflow commands or crashing the runner.

## 응답 정리와 원래 오류 보존 — 2026-09-06

HTTP 오류는 예외이면서 읽을 수 있는 응답이기도 하다. 요청을 수행한
소유자가 필요한 제한 길이의 진단을 읽은 뒤 응답을 닫는다. 빌려 받은
스트림을 해석하는 함수에 정리를 떠넘기거나 가비지 수집 시점에 의존하지 않는다.
Noema뿐 아니라 정책 검사, Pages 검증, 격리 실행의 준비 상태 확인도 같은
소유권을 지킨다. 이들은 별도 요청 경계이므로 새 공통 클라이언트를 만들지 않았다.

기존 [PR #1879](https://github.com/ContextualWisdomLab/.github/pull/1879)의
`0723a0c7d9d4da82e64f884cff8babf1f0e0c81a`는 일반 응답 정리를 수행하지만,
정리 중 오류가 발생하면 원래 실패를 덮는다. 네 실제 호출 경계에 각각
`OSError`, `ValueError`, `RuntimeError`를 주입해 12개 실패를 재현했다.
응답 정리 한 문장에만 `contextlib.suppress(Exception)`을 적용하여 기존
통신 오류와 준비 실패 결과를 보존한다. `KeyboardInterrupt`와 `SystemExit`는
전파한다. 정리 시도 자체가 실패했을 때 모든 운영체제 자원이 반드시 해제됐다는
보장은 하지 않으며, 이를 성공한 요청으로 바꾸지도 않는다.

직접 redirect 예외를 만든 네 기존 테스트도 자신이 소유한 응답을 닫는다.
경고 필터나 수집기 강제 실행은 추가하지 않는다. URL·리다이렉트·프록시 정책,
재시도 횟수, 모델 제한 시간, 리뷰 내용 검증은 이 수정의 대상이 아니다.

회귀 검사는 `tests/test_http_error_response_ownership.py`에서 네 실제 호출자와
여섯 정리 결과를 교차 검증한다. 네트워크 요청만 대체하고 실제 HTTPError와
응답 스트림의 닫힘 상태를 확인한다. 전체 검증은 저장소 루트에서
`python -m pytest -q -W error --cov=scripts/ci --cov-branch --cov-fail-under=100`을
실행한다. 로컬 결과와 정확한 커밋은 PR에 기록하며 실제 공급자 호출·호스팅된
Checks·보호 병합의 근거로 대신 쓰지 않는다. Noema의 별도 schema 개선
[#1641](https://github.com/ContextualWisdomLab/.github/pull/1641)은 이 PR에 복제하지 않는다.

Python Software Foundation. (n.d.-a). *urllib.error — Exception classes raised by urllib.request*.
Retrieved September 6, 2026, from https://docs.python.org/3.14/library/urllib.error.html

Python Software Foundation. (n.d.-b). *contextlib — Utilities for with-statement contexts*.
Retrieved September 6, 2026, from https://docs.python.org/3.14/library/contextlib.html#contextlib.suppress

## Verification

The permanent contract test forbids `NOEMA_REPAIR_DEADLINE_SECONDS`, `_repair_wall_clock_deadline`, `NoemaRepairDeadlineExceeded`, `signal.setitimer`, retry-only parameters/recursion, and caller-specified `temperature`. Focused regressions prove one request on success and failure, one annotation per attempt, safe serving-model telemetry, strict missing-value rejection, accepted genuine trailing commas, and preserved exact changed-line diagnostics.
8 changes: 7 additions & 1 deletion scripts/ci/noema_review_gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import urllib.parse
import urllib.request
from collections.abc import Sequence
from contextlib import suppress
from typing import Any

from scripts.ci.opencode_review_normalize_output import changed_file_is_material
Expand Down Expand Up @@ -1636,7 +1637,12 @@ def call_llm(
gateway_telemetry: dict[str, str | int] = {}
if isinstance(exc, urllib.error.HTTPError):
active_phase = "response_error"
gateway_telemetry = _extract_http_error_telemetry(exc)
try:
gateway_telemetry = _extract_http_error_telemetry(exc)
finally:
# Preserve the transport failure; process cancellation still propagates.
with suppress(Exception):
exc.close()
model_value = gateway_telemetry.get("served_model")
served_model = model_value if isinstance(model_value, str) else None
elapsed = time.monotonic() - attempt_started
Expand Down
4 changes: 4 additions & 0 deletions scripts/ci/pingora_edge_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import re
import sys
import zlib
from contextlib import suppress
from dataclasses import dataclass
from pathlib import PurePosixPath
from typing import Callable, Mapping, Sequence
Expand Down Expand Up @@ -304,6 +305,9 @@ def _github_open_json(url: str, token: str) -> object:
with github_opener.open(request, timeout=30) as response:
payload = response.read(MAX_RESPONSE_BYTES + 1)
except (HTTPError, URLError, TimeoutError) as exc:
if isinstance(exc, HTTPError):
with suppress(Exception):
exc.close()
raise PolicyError(f"GitHub API request failed for policy evidence: {type(exc).__name__}") from exc
if len(payload) > MAX_RESPONSE_BYTES:
raise PolicyError("GitHub API policy response exceeded the bounded response size")
Expand Down
6 changes: 5 additions & 1 deletion scripts/ci/reconcile_repository_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@
import re
import subprocess
import sys
from contextlib import suppress
from pathlib import Path
from typing import Any
from urllib.error import URLError
from urllib.error import HTTPError, URLError
from urllib.request import HTTPRedirectHandler, Request, build_opener


Expand Down Expand Up @@ -247,6 +248,9 @@ def _pages_publication_ready(repository: str, current: dict[str, Any]) -> None:
if not response.read(1):
raise RuntimeError(f"GitHub Pages returned empty content for {repository}")
except (URLError, TimeoutError, OSError) as exc:
if isinstance(exc, HTTPError):
with suppress(Exception):
exc.close()
raise RuntimeError(f"GitHub Pages is not reachable for {repository}") from exc


Expand Down
6 changes: 5 additions & 1 deletion scripts/ci/sandboxed_web_e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import urllib.parse
import urllib.request
from collections.abc import Sequence
from contextlib import suppress
from dataclasses import dataclass
from pathlib import Path

Expand Down Expand Up @@ -584,7 +585,10 @@ def wait_for_url(url: str, timeout: int, service: Service) -> bool:
if 200 <= response.status < 500:
return True
time.sleep(1)
except (urllib.error.URLError, TimeoutError):
except (urllib.error.URLError, TimeoutError) as exc:
if isinstance(exc, urllib.error.HTTPError):
with suppress(Exception):
exc.close()
time.sleep(1)
return False

Expand Down
73 changes: 73 additions & 0 deletions tests/test_http_error_response_ownership.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
"""Request owners close failed responses without masking failure or cancellation."""

from contextlib import nullcontext
from io import BytesIO
from itertools import count
from types import SimpleNamespace
from urllib.error import HTTPError

import pytest

from scripts.ci import noema_review_gate as noema
from scripts.ci import pingora_edge_policy as policy
from scripts.ci import reconcile_repository_metadata as metadata
from scripts.ci import sandboxed_web_e2e as sandbox


@pytest.mark.parametrize("caller_name", ["policy", "pages", "readiness", "noema"])
@pytest.mark.parametrize("close_error_type", [None, OSError, ValueError, RuntimeError, KeyboardInterrupt, SystemExit])
def test_http_error_owner_preserves_failure_and_cancellation(monkeypatch, caller_name, close_error_type):
"""Removing cleanup or letting its exception replace the result breaks this contract."""
response_body = BytesIO(b"fixture response")

class ResponseError(HTTPError):
def close(self):
super().close()
if close_error_type is not None:
raise close_error_type("fixture cleanup")

response_error = ResponseError("https://example.test", 502, "fixture failure", {}, response_body)

def fail_request(*_args, **_kwargs):
raise response_error

def opener_factory(*_args):
return SimpleNamespace(open=fail_request)

expected_error, expected_message = {
"policy": (policy.PolicyError, "GitHub API request failed"),
"pages": (RuntimeError, "not reachable"),
"readiness": (None, ""),
"noema": (noema.NoemaTransportError, "Noema gateway transport failed"),
}[caller_name]
if close_error_type in (KeyboardInterrupt, SystemExit):
expected_error, expected_message = close_error_type, "fixture cleanup"
expected_outcome = pytest.raises(expected_error, match=expected_message) if expected_error else nullcontext()

try:
with expected_outcome:
if caller_name == "policy":
monkeypatch.setattr(policy.github_opener, "open", fail_request)
policy._github_open_json("https://api.github.com/repos/fixture/example", "fixture-token")
elif caller_name == "pages":
monkeypatch.setattr(metadata, "build_opener", opener_factory)
metadata._pages_publication_ready("Fixture", {
"status": "built", "html_url": "https://contextualwisdomlab.github.io/Fixture/",
})
elif caller_name == "noema":
monkeypatch.setenv("NOEMA_LLM_API_URL", "https://8.8.8.8/chat")
monkeypatch.setenv("NOEMA_LLM_API_KEY", "fixture-token")
monkeypatch.setenv("NOEMA_LLM_MODEL", "orchestrator/free")
monkeypatch.setattr(noema.urllib.request, "build_opener", opener_factory)
noema.call_llm("fixture/example", 1, {"headRefOid": "a" * 40}, "", False, "a" * 40)
else:
monkeypatch.setattr(sandbox.urllib.request, "build_opener", opener_factory)
monkeypatch.setattr(sandbox, "time", SimpleNamespace(
monotonic=count().__next__, sleep=lambda _seconds: None,
))
service_state = SimpleNamespace(process=SimpleNamespace(poll=lambda: None))
assert sandbox.wait_for_url("http://127.0.0.1:8123/ready", 2, service_state) is False
assert response_error.closed
assert response_body.closed
finally:
HTTPError.close(response_error)
8 changes: 6 additions & 2 deletions tests/test_noema_review_gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -1613,10 +1613,12 @@ def test_call_llm_reports_only_safe_model_from_bounded_http_error(monkeypatch, c
}
).encode()

response_body = io.BytesIO(body)

class Opener:
def open(self, request):
raise noema.urllib.error.HTTPError(
request.full_url, 502, "Bad Gateway", {}, io.BytesIO(body)
request.full_url, 502, "Bad Gateway", {}, response_body
)

monkeypatch.setattr(noema.urllib.request, "build_opener", lambda *_args: Opener())
Expand All @@ -1637,6 +1639,7 @@ def open(self, request):
assert "terminal_reason=eligible_candidates_exhausted" in output
assert secret not in output
assert secret not in diagnostic
assert response_body.closed


@pytest.mark.parametrize(
Expand Down Expand Up @@ -1743,7 +1746,7 @@ def test_noema_redirect_handler_rejects_redirects():
handler = noema.NoRedirectHandler()
request = noema.urllib.request.Request("https://llm.example.test/chat")

with pytest.raises(noema.urllib.error.HTTPError):
with pytest.raises(noema.urllib.error.HTTPError) as response_error:
handler.redirect_request(
request,
fp=None,
Expand All @@ -1752,6 +1755,7 @@ def test_noema_redirect_handler_rejects_redirects():
headers={},
newurl="http://169.254.169.254/latest/meta-data/",
)
response_error.value.close()


def test_call_llm_rejects_control_character_scheme_evasion(monkeypatch):
Expand Down
5 changes: 4 additions & 1 deletion tests/test_pingora_edge_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -792,6 +792,8 @@ def fail(_request: object, timeout: int) -> object:
monkeypatch.setattr(policy.github_opener, "open", fail)
with pytest.raises(policy.PolicyError, match=type(exc).__name__):
policy._github_open_json("https://api.github.com/repos/a/b", "token")
if isinstance(exc, HTTPError):
assert exc.fp.closed


def test_github_open_json_rejects_oversized_and_malformed_payloads(monkeypatch: pytest.MonkeyPatch) -> None:
Expand Down Expand Up @@ -826,8 +828,9 @@ def test_github_open_json_rejects_nonapproved_origins(url: str) -> None:
def test_github_opener_never_constructs_redirect_requests() -> None:
"""The policy opener refuses redirects rather than changing API origins."""

with pytest.raises(HTTPError):
with pytest.raises(HTTPError) as response_error:
policy.NoRedirectHandler().redirect_request(policy.Request("https://example.com"), None, 302, "Found", {}, "https://evil.example")
response_error.value.close()


def test_annotation_escapes_workflow_command_fields() -> None:
Expand Down
25 changes: 24 additions & 1 deletion tests/test_repository_metadata_live_verification.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import argparse
import importlib.util
import json
from io import BytesIO
from pathlib import Path

import pytest
Expand Down Expand Up @@ -63,6 +64,27 @@ def open(self, request, timeout):
return self.response


def test_pages_transport_error_closes_response_body(monkeypatch) -> None:
body = BytesIO(b"redirect")
error = RECONCILER.HTTPError(
"https://contextualwisdomlab.github.io/Repo/", 302, "redirect", {}, body
)
monkeypatch.setattr(
RECONCILER, "build_opener", lambda *_args: FakeOpener(error=error)
)

with pytest.raises(RuntimeError, match="not reachable"):
RECONCILER._pages_publication_ready(
"Repo",
{
"status": "built",
"html_url": "https://contextualwisdomlab.github.io/Repo/",
},
)

assert body.closed


def install_live_state(
monkeypatch,
*,
Expand Down Expand Up @@ -145,10 +167,11 @@ def build_ok(handler):
assert len(handlers) == 1
assert isinstance(handlers[0], RECONCILER._NoPagesRedirects)
from urllib.error import HTTPError
with pytest.raises(HTTPError):
with pytest.raises(HTTPError) as response_error:
handlers[0].redirect_request(
RECONCILER.Request("https://example.com"), None, 302, "redirect", {}, "http://127.0.0.1/"
)
response_error.value.close()

with pytest.raises(RuntimeError, match="not built"):
RECONCILER._pages_publication_ready("Repo", {**ready, "status": "building"})
Expand Down
32 changes: 32 additions & 0 deletions tests/test_sandboxed_web_e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import socket
import subprocess
import sys
from io import BytesIO
from pathlib import Path

import pytest
Expand Down Expand Up @@ -671,6 +672,7 @@ def test_no_redirect_handler_raises_httperror_without_following():
sandboxed_web_e2e.NoRedirectHandler().redirect_request(request, None, 302, "Found", {}, "http://127.0.0.1")

assert exc_info.value.code == 302
exc_info.value.close()


def test_wait_for_url_returns_false_after_timeout(monkeypatch, tmp_path):
Expand All @@ -695,6 +697,36 @@ def open(self, url, timeout):
assert sandboxed_web_e2e.wait_for_url("http://127.0.0.1:8000/health", 1, service) is False


def test_wait_for_url_closes_http_error_response(monkeypatch, tmp_path):
class RunningProcess:
def poll(self):
return None

body = BytesIO(b"redirect")
error = sandboxed_web_e2e.urllib.error.HTTPError(
"http://127.0.0.1:8000/health", 302, "Found", {}, body
)
ticks = iter([0, 0, 2])
monkeypatch.setattr(sandboxed_web_e2e.time, "monotonic", lambda: next(ticks))
monkeypatch.setattr(sandboxed_web_e2e.time, "sleep", lambda _seconds: None)

class FailingOpener:
def open(self, _url, timeout):
raise error

monkeypatch.setattr(
sandboxed_web_e2e.urllib.request, "build_opener", lambda *_args: FailingOpener()
)
service = sandboxed_web_e2e.Service(
"web", "serve", RunningProcess(), tmp_path / "web.log"
)

assert not sandboxed_web_e2e.wait_for_url(
"http://127.0.0.1:8000/health", 1, service
)
assert body.closed


def test_main_runs_with_stubbed_services(monkeypatch, tmp_path, capsys):
"""Main records success evidence without requiring real POSIX services."""
repo = tmp_path / "repo"
Expand Down
Loading