Skip to content

Commit b9bdeee

Browse files
committed
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>
1 parent e57b49e commit b9bdeee

2 files changed

Lines changed: 127 additions & 15 deletions

File tree

‎examples/value-connectors-github-public-probe-smoke.py‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import tempfile
1313
from pathlib import Path
1414
from typing import Any
15+
from urllib.error import HTTPError, URLError
1516

1617

1718
REPO_ROOT = Path(__file__).resolve().parents[1]
@@ -21,6 +22,7 @@
2122
from loopx.capabilities.issue_fix.github_public import ( # noqa: E402
2223
GITHUB_PUBLIC_CHANNEL_PROBE_PACKET_SCHEMA_VERSION,
2324
GITHUB_PUBLIC_REPLY_MONITOR_PACKET_SCHEMA_VERSION,
25+
github_public_read_failure_row,
2426
)
2527
from loopx.capabilities.value_connectors.install_check import ( # noqa: E402
2628
VALUE_CONNECTOR_INSTALL_CHECK_PACKET_SCHEMA_VERSION,
@@ -593,6 +595,30 @@ def main() -> int:
593595
assert "recommended_action: `wait_no_bump`" in reply_markdown, reply_markdown
594596
assert_public_safe(reply_markdown)
595597

598+
# A failed public read must surface as a typed row instead of the provider's
599+
# own text: steward answers used to quote "Cache miss" and
600+
# "invalid_arguments" instead of naming what was unread and how to repair
601+
# it. The provider message is replaced by its digest.
602+
failure = github_public_read_failure_row(RuntimeError("gh request failed: Cache miss"))
603+
assert failure["code"] == "provider_tool_unavailable", failure
604+
assert failure["source_id"] == "github_public_channel", failure
605+
assert "unread" in failure["coverage_effect"], failure
606+
assert failure["next_action"], failure
607+
assert failure["provider_message_digest"].startswith("sha256:"), failure
608+
assert "Cache miss" not in json.dumps(failure), failure
609+
assert_public_safe(failure)
610+
assert github_public_read_failure_row(subprocess.TimeoutExpired("gh", 5))[
611+
"code"
612+
] == "provider_timeout"
613+
assert github_public_read_failure_row(
614+
URLError("nodename nor servname provided")
615+
)["code"] == "provider_network_unavailable"
616+
http_failure = github_public_read_failure_row(
617+
HTTPError("https://api.github.com/repos/o/r/issues/1", 403, "Forbidden", None, None)
618+
)
619+
assert http_failure["code"] == "provider_response_rejected", http_failure
620+
assert http_failure["provider_status"] == 403, http_failure
621+
596622
print("value-connectors-github-public-probe-smoke: ok")
597623
return 0
598624

‎loopx/capabilities/issue_fix/github_public.py‎

Lines changed: 101 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import hashlib
34
import json
45
import shutil
56
import subprocess
@@ -20,6 +21,20 @@
2021
"github_public_reply_monitor_packet_v0"
2122
)
2223
GITHUB_PUBLIC_REPLY_SIGNAL_SCHEMA_VERSION = "github_public_reply_signal_v0"
24+
GITHUB_PUBLIC_READ_FAILURE_SCHEMA_VERSION = "github_public_read_failure_v0"
25+
26+
# A public read that failed is reported as one of these reasons, never as the
27+
# provider's own text: the steward answer contract forbids quoting a provider
28+
# failure into an answer, and a manager that repeats "Cache miss" instead of a
29+
# typed reason cannot say what is unread or how to repair it.
30+
GITHUB_PUBLIC_READ_FAILURE_CODES = (
31+
"provider_timeout",
32+
"provider_network_unavailable",
33+
"provider_response_rejected",
34+
"provider_result_unreadable",
35+
"provider_tool_unavailable",
36+
"provider_read_failed",
37+
)
2338

2439
ALLOWED_GITHUB_REF_TYPES = {"issue", "pull", "discussion"}
2540
MAINTAINER_ASSOCIATIONS = {"COLLABORATOR", "MEMBER", "OWNER"}
@@ -116,9 +131,48 @@ def _normalise_github_issue_comment_url(url: str) -> tuple[str, dict[str, Any]]:
116131
}
117132

118133

119-
def _compact_error(exc: BaseException) -> str:
120-
text = " ".join(str(exc).split())
121-
return text[:180]
134+
def github_public_read_failure_row(exc: BaseException) -> dict[str, Any]:
135+
"""One failed public read as a typed row the answer can carry.
136+
137+
The row names the source, a typed reason, the coverage effect of the failed
138+
read and the next action. The provider's message is replaced by its digest:
139+
it stays correlatable with the provider log without letting an answer quote
140+
it, which is what "Cache miss" and "invalid_arguments" leaking into steward
141+
answers required.
142+
"""
143+
144+
if isinstance(exc, (subprocess.TimeoutExpired, TimeoutError)):
145+
code = "provider_timeout"
146+
elif isinstance(exc, HTTPError):
147+
code = "provider_response_rejected"
148+
elif isinstance(exc, URLError):
149+
code = "provider_network_unavailable"
150+
elif isinstance(exc, json.JSONDecodeError):
151+
code = "provider_result_unreadable"
152+
elif isinstance(exc, RuntimeError):
153+
code = "provider_tool_unavailable"
154+
else:
155+
code = "provider_read_failed"
156+
compact = " ".join(str(exc).split())
157+
row: dict[str, Any] = {
158+
"schema_version": GITHUB_PUBLIC_READ_FAILURE_SCHEMA_VERSION,
159+
"source_id": "github_public_channel",
160+
"code": code,
161+
"coverage_effect": (
162+
"the public GitHub metadata for this target was not read in this "
163+
"call; the target is unread rather than inactive"
164+
),
165+
"next_action": (
166+
"retry with the GitHub CLI or authenticated tooling, or pass the "
167+
"metadata explicitly; do not report the target as having no activity"
168+
),
169+
"provider_message_digest": "sha256:"
170+
+ hashlib.sha256(compact.encode("utf-8")).hexdigest(),
171+
}
172+
status = getattr(exc, "code", None)
173+
if isinstance(status, int) and not isinstance(status, bool):
174+
row["provider_status"] = status
175+
return row
122176

123177

124178
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(
392446
)
393447
if not same_target:
394448
raise ValueError("--issue-url and --after-comment-url must point at the same GitHub thread")
395-
read_error: str | None = None
449+
read_failure: dict[str, Any] | None = None
396450
live_payload: Mapping[str, Any] | None = None
397451
if fetch_metadata:
398452
try:
@@ -402,7 +456,7 @@ def build_github_public_reply_monitor_packet(
402456
json.JSONDecodeError,
403457
subprocess.TimeoutExpired,
404458
) as exc:
405-
read_error = _compact_error(exc)
459+
read_failure = github_public_read_failure_row(exc)
406460

407461
payload_source = live_payload if live_payload is not None else provider_payload
408462
comments = _provider_comments(payload_source)
@@ -428,7 +482,7 @@ def build_github_public_reply_monitor_packet(
428482
metadata_collected = bool(comments)
429483
validation_errors: list[str] = []
430484
validation_warnings: list[str] = []
431-
if read_error:
485+
if read_failure:
432486
validation_errors.append("metadata read failed; retry with gh auth/tooling or use --metadata-json")
433487
if metadata_collected and anchor_created_at is None:
434488
validation_errors.append("anchor LoopX comment was not found in comment metadata")
@@ -487,7 +541,7 @@ def build_github_public_reply_monitor_packet(
487541
"money_signal": money_signal,
488542
"recommended_action": recommended_action,
489543
"stop_condition": "do not bump or draft a triage note until public maintainer interest appears",
490-
"read_error": read_error,
544+
"read_failure": read_failure,
491545
"validation": {
492546
"ok": not validation_errors,
493547
"errors": validation_errors,
@@ -509,15 +563,15 @@ def build_github_public_channel_probe_packet(
509563
) -> dict[str, Any]:
510564
normalised_url, ref = _normalise_github_url(url)
511565
metadata: dict[str, Any] | None = None
512-
read_error: str | None = None
566+
read_failure: dict[str, Any] | None = None
513567
if fetch_metadata:
514568
try:
515569
if ref["ref_type"] in {"issue", "pull"}:
516570
metadata = _fetch_issue_or_pull_metadata(ref, timeout_seconds=timeout_seconds)
517571
else:
518572
metadata = _fetch_discussion_metadata(ref, timeout_seconds=timeout_seconds)
519573
except (HTTPError, URLError, TimeoutError, RuntimeError, json.JSONDecodeError, subprocess.TimeoutExpired) as exc:
520-
read_error = _compact_error(exc)
574+
read_failure = github_public_read_failure_row(exc)
521575
connector_call = {
522576
"schema_version": "connector_call_intent_v0",
523577
"call_id": f"github_{ref['ref_type']}_{ref['number']}_metadata_probe",
@@ -540,7 +594,7 @@ def build_github_public_channel_probe_packet(
540594
"promotion_target": "value_connector_plan_v0",
541595
}
542596
validation_errors: list[str] = []
543-
if read_error:
597+
if read_failure:
544598
validation_errors.append("metadata read failed; retry with auth material/tooling or use no-fetch mode")
545599
return {
546600
"ok": not validation_errors,
@@ -550,7 +604,7 @@ def build_github_public_channel_probe_packet(
550604
"ref": ref,
551605
"connector_call": connector_call,
552606
"metadata": metadata,
553-
"read_error": read_error,
607+
"read_failure": read_failure,
554608
"external_reads_performed": bool(fetch_metadata),
555609
"external_writes_performed": False,
556610
"raw_body_captured": False,
@@ -605,8 +659,24 @@ def render_github_public_reply_monitor_markdown(payload: dict[str, Any]) -> str:
605659
f"maintainer_signal=`{signal.get('is_maintainer_signal')}` "
606660
f"url={signal.get('url')}"
607661
)
608-
if payload.get("read_error"):
609-
lines.extend(["", "## Read Error", "", str(payload.get("read_error"))])
662+
failure = (
663+
payload.get("read_failure")
664+
if isinstance(payload.get("read_failure"), Mapping)
665+
else None
666+
)
667+
if failure:
668+
lines.extend(
669+
[
670+
"",
671+
"## Unread Source",
672+
"",
673+
f"- source_id: `{failure.get('source_id')}`",
674+
f"- code: `{failure.get('code')}`",
675+
f"- coverage_effect: {failure.get('coverage_effect')}",
676+
f"- next_action: {failure.get('next_action')}",
677+
f"- provider_message_digest: `{failure.get('provider_message_digest')}`",
678+
]
679+
)
610680
errors = validation.get("errors") if isinstance(validation.get("errors"), list) else []
611681
warnings = validation.get("warnings") if isinstance(validation.get("warnings"), list) else []
612682
if errors:
@@ -650,8 +720,24 @@ def render_github_public_channel_probe_markdown(payload: dict[str, Any]) -> str:
650720
"",
651721
]
652722
)
653-
if payload.get("read_error"):
654-
lines.extend(["## Read Error", "", str(payload.get("read_error")), ""])
723+
failure = (
724+
payload.get("read_failure")
725+
if isinstance(payload.get("read_failure"), Mapping)
726+
else None
727+
)
728+
if failure:
729+
lines.extend(
730+
[
731+
"## Unread Source",
732+
"",
733+
f"- source_id: `{failure.get('source_id')}`",
734+
f"- code: `{failure.get('code')}`",
735+
f"- coverage_effect: {failure.get('coverage_effect')}",
736+
f"- next_action: {failure.get('next_action')}",
737+
f"- provider_message_digest: `{failure.get('provider_message_digest')}`",
738+
"",
739+
]
740+
)
655741
if payload.get("error"):
656742
lines.extend(["## Error", "", str(payload.get("error")), ""])
657743
errors = validation.get("errors") if isinstance(validation.get("errors"), list) else []

0 commit comments

Comments
 (0)