From 21e45ba6bfb95e8ed5ec4828da6b06befe906c84 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Sun, 20 Sep 2026 13:14:17 +0800 Subject: [PATCH] fix(stepper): report a failed public read as a typed row, not provider text A failed public GitHub read used to place the provider's own message, truncated to 180 characters, into the packet's read_error field. Steward answers then quoted that text instead of naming what was unread: the group shows "Cache miss" twice on 2026-09-13 and "invalid_arguments" on 2026-09-14, and the manager evidence row (loopx-meta todo_386976ec9aae) records that each failure must instead carry source id, typed reason, coverage effect and next action. Replace the raw text with github_public_read_failure_row: a typed row carrying schema_version, source_id, one of six reason codes classified from the exception type (timeout, network unavailable, response rejected with the provider status, result unreadable, tool unavailable, or a bounded fallback), the coverage effect that the target is unread rather than inactive, the repair next_action, and a digest of the provider message for log correlation. Both the channel-probe and reply-monitor packets and both markdown renderers now carry the typed row; the raw provider string no longer reaches a payload an answer can quote. The dead _compact_error helper goes with it. Scope note: this covers the public GitHub read path the row's evidence names. The Lark im read that returned invalid_arguments is a different source and stays open on the same row. Evidence: value-connectors-github-public-probe-smoke ok with new assertions that the row is typed, that the coverage effect says unread, and that "Cache miss" cannot appear in the row; 69 issue-fix/value-connector/github capability tests passed; ruff clean; py_compile clean; loopx canary premerge --from-git-diff passed (the single red canary is the pre-existing maintainability-ratchet finding for files this change does not touch). Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- ...ue-connectors-github-public-probe-smoke.py | 26 ++++ loopx/capabilities/issue_fix/github_public.py | 116 +++++++++++++++--- 2 files changed, 127 insertions(+), 15 deletions(-) diff --git a/examples/value-connectors-github-public-probe-smoke.py b/examples/value-connectors-github-public-probe-smoke.py index ba16117c1c..4651a47c4e 100644 --- a/examples/value-connectors-github-public-probe-smoke.py +++ b/examples/value-connectors-github-public-probe-smoke.py @@ -12,6 +12,7 @@ import tempfile from pathlib import Path from typing import Any +from urllib.error import HTTPError, URLError REPO_ROOT = Path(__file__).resolve().parents[1] @@ -21,6 +22,7 @@ from loopx.capabilities.issue_fix.github_public import ( # noqa: E402 GITHUB_PUBLIC_CHANNEL_PROBE_PACKET_SCHEMA_VERSION, GITHUB_PUBLIC_REPLY_MONITOR_PACKET_SCHEMA_VERSION, + github_public_read_failure_row, ) from loopx.capabilities.value_connectors.install_check import ( # noqa: E402 VALUE_CONNECTOR_INSTALL_CHECK_PACKET_SCHEMA_VERSION, @@ -593,6 +595,30 @@ def main() -> int: assert "recommended_action: `wait_no_bump`" in reply_markdown, reply_markdown assert_public_safe(reply_markdown) + # A failed public read must surface as a typed row instead of the provider's + # own text: steward answers used to quote "Cache miss" and + # "invalid_arguments" instead of naming what was unread and how to repair + # it. The provider message is replaced by its digest. + failure = github_public_read_failure_row(RuntimeError("gh request failed: Cache miss")) + assert failure["code"] == "provider_tool_unavailable", failure + assert failure["source_id"] == "github_public_channel", failure + assert "unread" in failure["coverage_effect"], failure + assert failure["next_action"], failure + assert failure["provider_message_digest"].startswith("sha256:"), failure + assert "Cache miss" not in json.dumps(failure), failure + assert_public_safe(failure) + assert github_public_read_failure_row(subprocess.TimeoutExpired("gh", 5))[ + "code" + ] == "provider_timeout" + assert github_public_read_failure_row( + URLError("nodename nor servname provided") + )["code"] == "provider_network_unavailable" + http_failure = github_public_read_failure_row( + HTTPError("https://api.github.com/repos/o/r/issues/1", 403, "Forbidden", None, None) + ) + assert http_failure["code"] == "provider_response_rejected", http_failure + assert http_failure["provider_status"] == 403, http_failure + print("value-connectors-github-public-probe-smoke: ok") return 0 diff --git a/loopx/capabilities/issue_fix/github_public.py b/loopx/capabilities/issue_fix/github_public.py index f9597316b1..2e4ba8a005 100644 --- a/loopx/capabilities/issue_fix/github_public.py +++ b/loopx/capabilities/issue_fix/github_public.py @@ -1,5 +1,6 @@ from __future__ import annotations +import hashlib import json import shutil import subprocess @@ -20,6 +21,20 @@ "github_public_reply_monitor_packet_v0" ) GITHUB_PUBLIC_REPLY_SIGNAL_SCHEMA_VERSION = "github_public_reply_signal_v0" +GITHUB_PUBLIC_READ_FAILURE_SCHEMA_VERSION = "github_public_read_failure_v0" + +# A public read that failed is reported as one of these reasons, never as the +# provider's own text: the steward answer contract forbids quoting a provider +# failure into an answer, and a manager that repeats "Cache miss" instead of a +# typed reason cannot say what is unread or how to repair it. +GITHUB_PUBLIC_READ_FAILURE_CODES = ( + "provider_timeout", + "provider_network_unavailable", + "provider_response_rejected", + "provider_result_unreadable", + "provider_tool_unavailable", + "provider_read_failed", +) ALLOWED_GITHUB_REF_TYPES = {"issue", "pull", "discussion"} MAINTAINER_ASSOCIATIONS = {"COLLABORATOR", "MEMBER", "OWNER"} @@ -116,9 +131,48 @@ def _normalise_github_issue_comment_url(url: str) -> tuple[str, dict[str, Any]]: } -def _compact_error(exc: BaseException) -> str: - text = " ".join(str(exc).split()) - return text[:180] +def github_public_read_failure_row(exc: BaseException) -> dict[str, Any]: + """One failed public read as a typed row the answer can carry. + + The row names the source, a typed reason, the coverage effect of the failed + read and the next action. The provider's message is replaced by its digest: + it stays correlatable with the provider log without letting an answer quote + it, which is what "Cache miss" and "invalid_arguments" leaking into steward + answers required. + """ + + if isinstance(exc, (subprocess.TimeoutExpired, TimeoutError)): + code = "provider_timeout" + elif isinstance(exc, HTTPError): + code = "provider_response_rejected" + elif isinstance(exc, URLError): + code = "provider_network_unavailable" + elif isinstance(exc, json.JSONDecodeError): + code = "provider_result_unreadable" + elif isinstance(exc, RuntimeError): + code = "provider_tool_unavailable" + else: + code = "provider_read_failed" + compact = " ".join(str(exc).split()) + row: dict[str, Any] = { + "schema_version": GITHUB_PUBLIC_READ_FAILURE_SCHEMA_VERSION, + "source_id": "github_public_channel", + "code": code, + "coverage_effect": ( + "the public GitHub metadata for this target was not read in this " + "call; the target is unread rather than inactive" + ), + "next_action": ( + "retry with the GitHub CLI or authenticated tooling, or pass the " + "metadata explicitly; do not report the target as having no activity" + ), + "provider_message_digest": "sha256:" + + hashlib.sha256(compact.encode("utf-8")).hexdigest(), + } + status = getattr(exc, "code", None) + if isinstance(status, int) and not isinstance(status, bool): + row["provider_status"] = status + return row def _fetch_issue_or_pull_metadata(ref: Mapping[str, Any], *, timeout_seconds: float) -> dict[str, Any]: @@ -392,7 +446,7 @@ def build_github_public_reply_monitor_packet( ) if not same_target: raise ValueError("--issue-url and --after-comment-url must point at the same GitHub thread") - read_error: str | None = None + read_failure: dict[str, Any] | None = None live_payload: Mapping[str, Any] | None = None if fetch_metadata: try: @@ -402,7 +456,7 @@ def build_github_public_reply_monitor_packet( json.JSONDecodeError, subprocess.TimeoutExpired, ) as exc: - read_error = _compact_error(exc) + read_failure = github_public_read_failure_row(exc) payload_source = live_payload if live_payload is not None else provider_payload comments = _provider_comments(payload_source) @@ -428,7 +482,7 @@ def build_github_public_reply_monitor_packet( metadata_collected = bool(comments) validation_errors: list[str] = [] validation_warnings: list[str] = [] - if read_error: + if read_failure: validation_errors.append("metadata read failed; retry with gh auth/tooling or use --metadata-json") if metadata_collected and anchor_created_at is None: validation_errors.append("anchor LoopX comment was not found in comment metadata") @@ -487,7 +541,7 @@ def build_github_public_reply_monitor_packet( "money_signal": money_signal, "recommended_action": recommended_action, "stop_condition": "do not bump or draft a triage note until public maintainer interest appears", - "read_error": read_error, + "read_failure": read_failure, "validation": { "ok": not validation_errors, "errors": validation_errors, @@ -509,7 +563,7 @@ def build_github_public_channel_probe_packet( ) -> dict[str, Any]: normalised_url, ref = _normalise_github_url(url) metadata: dict[str, Any] | None = None - read_error: str | None = None + read_failure: dict[str, Any] | None = None if fetch_metadata: try: if ref["ref_type"] in {"issue", "pull"}: @@ -517,7 +571,7 @@ def build_github_public_channel_probe_packet( else: metadata = _fetch_discussion_metadata(ref, timeout_seconds=timeout_seconds) except (HTTPError, URLError, TimeoutError, RuntimeError, json.JSONDecodeError, subprocess.TimeoutExpired) as exc: - read_error = _compact_error(exc) + read_failure = github_public_read_failure_row(exc) connector_call = { "schema_version": "connector_call_intent_v0", "call_id": f"github_{ref['ref_type']}_{ref['number']}_metadata_probe", @@ -540,7 +594,7 @@ def build_github_public_channel_probe_packet( "promotion_target": "value_connector_plan_v0", } validation_errors: list[str] = [] - if read_error: + if read_failure: validation_errors.append("metadata read failed; retry with auth material/tooling or use no-fetch mode") return { "ok": not validation_errors, @@ -550,7 +604,7 @@ def build_github_public_channel_probe_packet( "ref": ref, "connector_call": connector_call, "metadata": metadata, - "read_error": read_error, + "read_failure": read_failure, "external_reads_performed": bool(fetch_metadata), "external_writes_performed": False, "raw_body_captured": False, @@ -605,8 +659,24 @@ def render_github_public_reply_monitor_markdown(payload: dict[str, Any]) -> str: f"maintainer_signal=`{signal.get('is_maintainer_signal')}` " f"url={signal.get('url')}" ) - if payload.get("read_error"): - lines.extend(["", "## Read Error", "", str(payload.get("read_error"))]) + failure = ( + payload.get("read_failure") + if isinstance(payload.get("read_failure"), Mapping) + else None + ) + if failure: + lines.extend( + [ + "", + "## Unread Source", + "", + f"- source_id: `{failure.get('source_id')}`", + f"- code: `{failure.get('code')}`", + f"- coverage_effect: {failure.get('coverage_effect')}", + f"- next_action: {failure.get('next_action')}", + f"- provider_message_digest: `{failure.get('provider_message_digest')}`", + ] + ) errors = validation.get("errors") if isinstance(validation.get("errors"), list) else [] warnings = validation.get("warnings") if isinstance(validation.get("warnings"), list) else [] if errors: @@ -650,8 +720,24 @@ def render_github_public_channel_probe_markdown(payload: dict[str, Any]) -> str: "", ] ) - if payload.get("read_error"): - lines.extend(["## Read Error", "", str(payload.get("read_error")), ""]) + failure = ( + payload.get("read_failure") + if isinstance(payload.get("read_failure"), Mapping) + else None + ) + if failure: + lines.extend( + [ + "## Unread Source", + "", + f"- source_id: `{failure.get('source_id')}`", + f"- code: `{failure.get('code')}`", + f"- coverage_effect: {failure.get('coverage_effect')}", + f"- next_action: {failure.get('next_action')}", + f"- provider_message_digest: `{failure.get('provider_message_digest')}`", + "", + ] + ) if payload.get("error"): lines.extend(["## Error", "", str(payload.get("error")), ""]) errors = validation.get("errors") if isinstance(validation.get("errors"), list) else []