From 5b59a384d89f76df600db6f6e73e43ac839a9ca9 Mon Sep 17 00:00:00 2001 From: Daniel Padrino Date: Mon, 31 Aug 2026 15:25:16 -0300 Subject: [PATCH 01/43] Add a template_fingerprint that also masks known blockchain names. --- src/agent_cli/errors.py | 56 +++++++++++++++++++++++++++++++++++++++++ tests/test_errors.py | 42 +++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+) diff --git a/src/agent_cli/errors.py b/src/agent_cli/errors.py index 85d1147..3a1e86b 100644 --- a/src/agent_cli/errors.py +++ b/src/agent_cli/errors.py @@ -42,6 +42,25 @@ _UUID = re.compile( r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}" ) +# Blockchain names from DFX's own asset/blockchain enum (prod DB, checked +# 2026-08-31) — masked in the template signature so a per-chain error +# variant ("Timeout updating balances for Ethereum" vs "...for Polygon") groups +# under one issue-filing template instead of fragmenting one issue per chain. +# Refresh from prod if this drifts; there is no automated sync. +_KNOWN_CHAINS = frozenset( + { + "DeFiChain", "Ethereum", "Arbitrum", "Polygon", "BinanceSmartChain", "Binance", + "Kraken", "Base", "Optimism", "Citrea", "MEXC", "XT", "Railgun", "Sumixx", + "InternetComputer", "Scrypt", "Sepolia", "MaerkiBaumann", "Checkout", "Kaleido", + "Solana", "Zano", "Frick", "Tron", "Yapeal", "Talium", "OlkyFrozen", "Haqq", + "Bitcoin", "CitreaTestnet", "Gnosis", "KucoinPay", "Spark", "Lightning", "Firo", + "Olkypay", "BitcoinTestnet4", "BinancePay", "Arkade", "Cardano", "Monero", + } +) +# Longest-first so e.g. "Bitcoin" cannot shadow-match a prefix of "BitcoinTestnet4". +_CHAIN_TOKEN = re.compile( + "|".join(re.escape(name) for name in sorted(_KNOWN_CHAINS, key=len, reverse=True)) +) _DIGITS = re.compile(r"\d+") _SPACE = re.compile(r"\s+") _CREDENTIAL_KEYS = frozenset( @@ -208,6 +227,35 @@ def stack_sig(line: str) -> str: return hashlib.sha256(norm.encode("utf-8")).hexdigest()[:16] +def template_signature(line: str) -> str: + """Coarser than stack_sig: also masks known blockchain names (see + _KNOWN_CHAINS), so a per-chain error variant groups under one issue-filing + template instead of fragmenting into one fingerprint per chain. Used only for + grouping which GitHub issue a variant belongs to — error.seen identity keeps + using the finer-grained fingerprint()/stack_sig() so per-variant count/last_seen + tracking stays exact.""" + norm = redact(strip_ansi(line)) + norm = _UUID.sub("", norm) + norm = _CHAIN_TOKEN.sub("", norm) + norm = _DIGITS.sub("", norm) + norm = _SPACE.sub(" ", norm).strip().lower() + return hashlib.sha256(norm.encode("utf-8")).hexdigest()[:16] + + +def template_fingerprint( + *, service: str, error_class: str, template_sig: str, environment: str +) -> str: + return f"{service}|{error_class}|{template_sig}|{environment}" + + +def known_chain_in(line: str) -> str | None: + """The first known blockchain name present in the line, if any — used to + label which concrete variant a template_fingerprint incident belongs to. + Most error lines don't name a chain; those return None.""" + match = _CHAIN_TOKEN.search(line) + return match.group(0) if match is not None else None + + def _strip(row: dict[str, Any]) -> dict[str, Any]: return {k: v for k, v in row.items() if not k.startswith("_")} @@ -472,6 +520,12 @@ def _apply_lines( stack_sig=stack_sig(redacted), environment=environment, ) + template_fp = template_fingerprint( + service=service, + error_class=cls, + template_sig=template_signature(redacted), + environment=environment, + ) server = item.get("server") container = item.get("container") line_fp = None @@ -490,6 +544,7 @@ def _apply_lines( payload_obj["count"] = count + 1 payload_obj["last_seen"] = ts payload_obj["excerpt"] = excerpt + payload_obj["template_fingerprint"] = template_fp if line_fp is not None: payload_obj["line_fingerprint"] = line_fp else: @@ -503,6 +558,7 @@ def _apply_lines( aid = str(uuid.uuid4()) payload_obj = { "fingerprint": fp, + "template_fingerprint": template_fp, "service": service, "environment": environment, "class": cls, diff --git a/tests/test_errors.py b/tests/test_errors.py index 04b0b6c..421ae92 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -12,11 +12,14 @@ error_class, fingerprint, is_incident_line, + known_chain_in, line_fingerprint, load_config, redact, scan_errors, stack_sig, + template_fingerprint, + template_signature, ) from agent_cli.store import Store, StoreError @@ -170,6 +173,43 @@ def test_redact_and_fingerprint() -> None: assert fp.endswith("|prod") +def test_template_signature_masks_known_chains() -> None: + ethereum = "Timeout updating balances for Ethereum: Error: Timeout" + polygon = "Timeout updating balances for Polygon: Error: Timeout" + assert template_signature(ethereum) == template_signature(polygon) + # stack_sig stays fine-grained: chain name is not masked there, so the two + # lines keep separate error.seen identity even though they share a template. + assert stack_sig(ethereum) != stack_sig(polygon) + + +def test_chain_token_regex_masks_longest_match_first() -> None: + from agent_cli.errors import _CHAIN_TOKEN + + # "Bitcoin" is a prefix of "BitcoinTestnet4" — an unsorted alternation would + # match "Bitcoin" first and leave "Testnet4" dangling in the masked output. + assert _CHAIN_TOKEN.sub("", "check failed for BitcoinTestnet4 today") == ( + "check failed for today" + ) + assert _CHAIN_TOKEN.sub("", "check failed for Bitcoin today") == ( + "check failed for today" + ) + + +def test_known_chain_in_finds_and_omits() -> None: + assert known_chain_in("Timeout updating balances for Ethereum") == "Ethereum" + assert known_chain_in("balance check failed for BitcoinTestnet4") == "BitcoinTestnet4" + assert known_chain_in("Failed to check Bank Frick order status") == "Frick" + assert known_chain_in("Failed to get price for token tether -> usd") is None + + +def test_template_fingerprint_format() -> None: + sig = template_signature("Timeout updating balances for Ethereum") + tfp = template_fingerprint( + service="api", error_class="error", template_sig=sig, environment="prod" + ) + assert tfp == f"api|error|{sig}|prod" + + def test_scan_inserts_once_then_enriches(tmp_path: Path) -> None: store = Store(tmp_path) _runner_session(store) @@ -195,6 +235,7 @@ def fetch(_cfg: dict, _cursor: str | None) -> tuple[list[dict], str | None]: assert payload["evidence"] is None assert "SECRETTOKENVALUE0123456789" not in payload["excerpt"] assert "fingerprint" in payload + assert "template_fingerprint" in payload wakes = store.pending_wakes() assert any(w["activity_id"] == created[0] for w in wakes) @@ -204,6 +245,7 @@ def fetch(_cfg: dict, _cursor: str | None) -> tuple[list[dict], str | None]: again = store.row("activity", created[0]) assert again is not None assert again["payload"]["count"] == 2 + assert again["payload"]["template_fingerprint"] == payload["template_fingerprint"] assert len([w for w in store.pending_wakes() if w["activity_id"] == created[0]]) == 1 assert "line_fingerprint" not in payload From 7c47eb910cfc459f256f97486b226c691fbd976b Mon Sep 17 00:00:00 2001 From: Daniel Padrino Date: Mon, 31 Aug 2026 15:25:17 -0300 Subject: [PATCH 02/43] Add error_issue_act: file or update a GitHub issue per error template. --- src/agent_cli/error_issue_act.py | 357 ++++++++++++++++++++++++++++++ tests/test_error_issue_act.py | 364 +++++++++++++++++++++++++++++++ 2 files changed, 721 insertions(+) create mode 100644 src/agent_cli/error_issue_act.py create mode 100644 tests/test_error_issue_act.py diff --git a/src/agent_cli/error_issue_act.py b/src/agent_cli/error_issue_act.py new file mode 100644 index 0000000..82631c9 --- /dev/null +++ b/src/agent_cli/error_issue_act.py @@ -0,0 +1,357 @@ +"""Apply pending error.issue activities: file or update a GitHub issue for a +normalized error template, grouped by template_fingerprint rather than the +finer-grained per-variant fingerprint — or, under dry_run, just log the intended +action instead of calling gh for real.""" + +from __future__ import annotations + +import json +from collections.abc import Callable +from typing import Any + +from .errors import known_chain_in +from .runtime import Completed +from .store import Store, StoreError, utcnow + +Runner = Callable[[list[str]], Completed] + +ISSUE_LABEL = "error-log-agent" +_MARKER_PREFIX = "" +_VARIANTS_START = "" +_VARIANTS_END = "" + + +def _strip(row: dict[str, Any]) -> dict[str, Any]: + return {k: v for k, v in row.items() if not k.startswith("_")} + + +def _mark( + store: Store, + row: dict[str, Any], + *, + status: str, + error: str | None = None, + result: dict[str, Any] | None = None, +) -> None: + updated = _strip(row) + updated["execution_status"] = status + if error is None: + updated.pop("execution_error", None) + else: + updated["execution_error"] = str(error)[:500] + if result is not None: + updated["result"] = result + store.write("activity", "update", updated["id"], updated) + + +def _nonempty_str(raw: Any) -> str | None: + if isinstance(raw, str) and raw != "": + return raw + return None + + +def _error_seen(store: Store, session_id: str, error_id: str) -> dict[str, Any]: + row = store.row("activity", error_id) + if ( + row is None + or row.get("_origin_device_id") != store.device_id() + or row.get("session_id") != session_id + or row.get("type") != "error.seen" + ): + raise StoreError("error.seen not found") + return row + + +def marker_for(template_fingerprint: str) -> str: + return f"{_MARKER_PREFIX}{template_fingerprint}{_MARKER_SUFFIX}" + + +def extract_variant(excerpt: str) -> str: + """Best-effort concrete detail for the variant table: the known chain name + present in the excerpt, or "generic" if none. Most error lines don't name a + chain — those never fragment, so there is only ever one variant.""" + return known_chain_in(excerpt) or "generic" + + +def render_variants_section(variants: dict[str, dict[str, str]]) -> str: + lines = [_VARIANTS_START, "", "| variant | first seen | last seen |", "|---|---|---|"] + for name in sorted(variants): + entry = variants[name] + lines.append(f"| {name} | {entry.get('first_seen', '')} | {entry.get('last_seen', '')} |") + lines.append("") + lines.append(_VARIANTS_END) + return "\n".join(lines) + + +def parse_variants_section(body: str) -> dict[str, dict[str, str]]: + """Parse the existing delimited variants table back out of an issue body.""" + start = body.find(_VARIANTS_START) + end = body.find(_VARIANTS_END) + variants: dict[str, dict[str, str]] = {} + if start == -1 or end == -1 or end < start: + return variants + for raw_line in body[start:end].splitlines(): + line = raw_line.strip() + if not line.startswith("|") or line.startswith("|---") or line.startswith("| variant"): + continue + parts = [p.strip() for p in line.strip("|").split("|")] + if len(parts) != 3 or parts[0] == "": + continue + name, first_seen, last_seen = parts + variants[name] = {"first_seen": first_seen, "last_seen": last_seen} + return variants + + +def splice_variants(body: str, variants: dict[str, dict[str, str]]) -> str: + """Replace only the delimited variants section; never touch the rest of the + body — that is human territory.""" + start = body.find(_VARIANTS_START) + end = body.find(_VARIANTS_END) + section = render_variants_section(variants) + if start == -1 or end == -1 or end < start: + sep = "" if body == "" else "\n\n" + return f"{body}{sep}{section}\n" + return body[:start] + section + body[end + len(_VARIANTS_END) :] + + +def _pending_issue(store: Store, row: dict[str, Any]) -> tuple[str, str, dict[str, Any]]: + payload = row.get("payload") + if not isinstance(payload, dict): + raise StoreError("payload must be an object") + error_id = _nonempty_str(payload.get("error_id")) + if error_id is None: + raise StoreError("error_id is required") + session_id = _nonempty_str(row.get("session_id")) + if session_id is None: + raise StoreError("session_id is required") + seen = _error_seen(store, session_id, error_id) + seen_payload = seen.get("payload") + if not isinstance(seen_payload, dict): + raise StoreError("error.seen payload is invalid") + template_fp = _nonempty_str(seen_payload.get("template_fingerprint")) + if template_fp is None: + raise StoreError("template_fingerprint is required") + return error_id, template_fp, seen_payload + + +def find_issue_number(runner: Runner, issue_repo: str, template_fingerprint: str) -> int | None: + """Search issue_repo for an open, labeled issue carrying this template's + hidden marker. None if no such issue exists yet.""" + completed = runner( + [ + "gh", + "issue", + "list", + "--repo", + issue_repo, + "--label", + ISSUE_LABEL, + "--state", + "open", + "--search", + marker_for(template_fingerprint), + "--json", + "number", + ] + ) + if completed.returncode != 0: + raise StoreError((completed.stderr or completed.stdout or "gh issue list failed").strip()) + try: + found = json.loads(completed.stdout) + except ValueError as exc: + raise StoreError("gh issue list returned invalid JSON") from exc + if not isinstance(found, list) or not found: + return None + first = found[0] + number = first.get("number") if isinstance(first, dict) else None + if isinstance(number, bool) or not isinstance(number, int): + raise StoreError("gh issue list returned a non-integer number") + return number + + +def _issue_body(runner: Runner, issue_repo: str, number: int) -> str: + completed = runner(["gh", "issue", "view", str(number), "--repo", issue_repo, "--json", "body"]) + if completed.returncode != 0: + raise StoreError((completed.stderr or completed.stdout or "gh issue view failed").strip()) + try: + data = json.loads(completed.stdout) + except ValueError as exc: + raise StoreError("gh issue view returned invalid JSON") from exc + body = data.get("body") if isinstance(data, dict) else None + return body if isinstance(body, str) else "" + + +def _create_issue( + runner: Runner, + *, + issue_repo: str, + title: str, + template_fingerprint: str, + excerpt: str, + variant: str, + now: str, +) -> str: + body = ( + f"{marker_for(template_fingerprint)}\n\n" + "Automated error-log finding.\n\n" + f"```\n{excerpt}\n```\n\n" + + render_variants_section({variant: {"first_seen": now, "last_seen": now}}) + + "\n" + ) + completed = runner( + [ + "gh", + "issue", + "create", + "--repo", + issue_repo, + "--label", + ISSUE_LABEL, + "--title", + title, + "--body", + body, + ] + ) + if completed.returncode != 0: + raise StoreError((completed.stderr or completed.stdout or "gh issue create failed").strip()) + return completed.stdout.strip() + + +def _update_issue( + runner: Runner, *, issue_repo: str, number: int, variant: str, now: str +) -> bool: + """Splice the variant into the issue's tracked table; comment only if it is + new. Returns whether this was a new variant.""" + body = _issue_body(runner, issue_repo, number) + variants = parse_variants_section(body) + is_new = variant not in variants + if is_new: + variants[variant] = {"first_seen": now, "last_seen": now} + else: + variants[variant]["last_seen"] = now + edit = runner( + ["gh", "issue", "edit", str(number), "--repo", issue_repo, "--body", splice_variants(body, variants)] + ) + if edit.returncode != 0: + raise StoreError((edit.stderr or edit.stdout or "gh issue edit failed").strip()) + if is_new: + comment = runner( + [ + "gh", + "issue", + "comment", + str(number), + "--repo", + issue_repo, + "--body", + f"Also seen on: {variant} ({now}).", + ] + ) + if comment.returncode != 0: + raise StoreError((comment.stderr or comment.stdout or "gh issue comment failed").strip()) + return is_new + + +def scan_error_issue( + store: Store, runner: Runner, *, issue_repo: str, dry_run: bool +) -> list[str]: + with store.exclusive("error-issue-act:" + store.device_id()): + return _scan_error_issue(store, runner, issue_repo=issue_repo, dry_run=dry_run) + + +def _scan_error_issue( + store: Store, runner: Runner, *, issue_repo: str, dry_run: bool +) -> list[str]: + rows = [ + row + for row in store.rows("activity") + if row.get("_origin_device_id") == store.device_id() + and row.get("type") == "error.issue" + and row.get("execution_status") == "pending" + ] + rows.sort(key=lambda row: str(row.get("id") or "")) + lines: list[str] = [] + for row in rows: + rid = str(row.get("id") or "?") + try: + _error_id, template_fp, seen_payload = _pending_issue(store, row) + except StoreError as exc: + _mark(store, row, status="error", error=str(exc)) + lines.append(f"error.issue {rid} error") + continue + + excerpt = seen_payload.get("excerpt") + excerpt = excerpt if isinstance(excerpt, str) else "" + variant = extract_variant(excerpt) + now = utcnow() + + if dry_run: + result = { + "mode": "dry-run", + "issue_repo": issue_repo, + "template_fingerprint": template_fp, + "variant": variant, + "excerpt": excerpt[:300], + } + _mark(store, row, status="done", result=result) + lines.append(f"error.issue {rid} dry-run variant={variant}") + continue + + try: + number = find_issue_number(runner, issue_repo, template_fp) + except StoreError as exc: + _mark(store, row, status="error", error=str(exc)) + lines.append(f"error.issue {rid} error") + continue + + if number is None: + service = seen_payload.get("service") + service = service if isinstance(service, str) and service else "unknown" + cls = seen_payload.get("class") + cls = cls if isinstance(cls, str) and cls else "error" + try: + url = _create_issue( + runner, + issue_repo=issue_repo, + title=f"{service}: {cls}", + template_fingerprint=template_fp, + excerpt=excerpt[:1000], + variant=variant, + now=now, + ) + except StoreError as exc: + _mark(store, row, status="error", error=str(exc)) + lines.append(f"error.issue {rid} error") + continue + result = { + "issue_repo": issue_repo, + "url": url, + "variant": variant, + "created": True, + } + _mark(store, row, status="done", result=result) + lines.append(f"error.issue {rid} created variant={variant}") + continue + + try: + is_new_variant = _update_issue( + runner, issue_repo=issue_repo, number=number, variant=variant, now=now + ) + except StoreError as exc: + _mark(store, row, status="error", error=str(exc)) + lines.append(f"error.issue {rid} error") + continue + result = { + "issue_repo": issue_repo, + "number": number, + "variant": variant, + "created": False, + "new_variant": is_new_variant, + } + _mark(store, row, status="done", result=result) + lines.append( + f"error.issue {rid} updated number={number} variant={variant} new_variant={is_new_variant}" + ) + return lines diff --git a/tests/test_error_issue_act.py b/tests/test_error_issue_act.py new file mode 100644 index 0000000..c39bf5b --- /dev/null +++ b/tests/test_error_issue_act.py @@ -0,0 +1,364 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from agent_cli.error_issue_act import ( + ISSUE_LABEL, + extract_variant, + find_issue_number, + marker_for, + parse_variants_section, + render_variants_section, + scan_error_issue, + splice_variants, +) +from agent_cli.runtime import Completed +from agent_cli.store import Store, StoreError + + +def _runner_session(store: Store) -> None: + store.write( + "session", + "insert", + "runner-1", + { + "id": "runner-1", + "kind": "runner", + "status": "active", + "skills": ["spine", "error-fix"], + }, + ) + + +def _seen( + store: Store, + *, + template_fingerprint: str | None = "api|error|abc123|prod", + excerpt: str = "Timeout updating balances for Ethereum: Error: Timeout", + service: str = "api", + cls: str = "error", + activity_id: str = "error-seen-1", +) -> None: + payload: dict[str, object] = { + "fingerprint": "api|error|def456|prod", + "excerpt": excerpt, + "service": service, + "class": cls, + } + if template_fingerprint is not None: + payload["template_fingerprint"] = template_fingerprint + store.write( + "activity", + "insert", + activity_id, + { + "id": activity_id, + "session_id": "runner-1", + "type": "error.seen", + "payload": payload, + "execution_status": "done", + }, + ) + + +def _issue(store: Store, error_id: str = "error-seen-1", activity_id: str = "issue-1") -> None: + store.write( + "activity", + "insert", + activity_id, + { + "id": activity_id, + "session_id": "runner-1", + "type": "error.issue", + "payload": {"error_id": error_id}, + "execution_status": "pending", + }, + ) + + +# ---- pure helpers ---- + + +def test_extract_variant_finds_known_chain_or_falls_back_to_generic() -> None: + assert extract_variant("Timeout updating balances for Ethereum") == "Ethereum" + assert extract_variant("Failed to check Bank Frick order status") == "Frick" + assert extract_variant("Failed to get price for token tether -> usd") == "generic" + + +def test_marker_for_embeds_template_fingerprint() -> None: + marker = marker_for("api|error|abc123|prod") + assert marker == "" + + +def test_variants_section_round_trips() -> None: + variants = { + "Ethereum": {"first_seen": "2026-08-31T10:00:00Z", "last_seen": "2026-08-31T10:00:00Z"}, + "Polygon": {"first_seen": "2026-08-31T11:00:00Z", "last_seen": "2026-08-31T11:30:00Z"}, + } + section = render_variants_section(variants) + assert "Ethereum" in section + assert "Polygon" in section + parsed = parse_variants_section(section) + assert parsed == variants + + +def test_splice_variants_only_touches_delimited_section() -> None: + body = "Human-written context above.\n\nMore human notes.\n" + with_section = splice_variants(body, {"Ethereum": {"first_seen": "t1", "last_seen": "t1"}}) + assert "Human-written context above." in with_section + assert "More human notes." in with_section + assert "Ethereum" in with_section + + updated = splice_variants( + with_section, + { + "Ethereum": {"first_seen": "t1", "last_seen": "t2"}, + "Polygon": {"first_seen": "t2", "last_seen": "t2"}, + }, + ) + assert "Human-written context above." in updated + assert "More human notes." in updated + assert "Polygon" in updated + # Splicing again must not duplicate the human-written prose above the section. + assert updated.count("Human-written context above.") == 1 + + +def test_find_issue_number_none_when_empty(tmp_path: Path) -> None: + def runner(argv: list[str]) -> Completed: + assert argv[:4] == ["gh", "issue", "list", "--repo"] + return Completed(0, "[]", "") + + assert find_issue_number(runner, "org/intern", "api|error|abc|prod") is None + + +def test_find_issue_number_parses_first_match() -> None: + def runner(argv: list[str]) -> Completed: + return Completed(0, '[{"number": 42}, {"number": 43}]', "") + + assert find_issue_number(runner, "org/intern", "api|error|abc|prod") == 42 + + +def test_find_issue_number_raises_on_gh_failure() -> None: + def runner(argv: list[str]) -> Completed: + return Completed(1, "", "not found") + + with pytest.raises(StoreError, match="not found"): + find_issue_number(runner, "org/intern", "api|error|abc|prod") + + +# ---- scan_error_issue: dry run ---- + + +def test_scan_dry_run_never_calls_gh(tmp_path: Path) -> None: + store = Store(tmp_path) + _runner_session(store) + _seen(store) + _issue(store) + calls: list[list[str]] = [] + + def runner(argv: list[str]) -> Completed: + calls.append(list(argv)) + return Completed(0, "", "") + + lines = scan_error_issue(store, runner, issue_repo="org/intern", dry_run=True) + assert calls == [] + assert lines == ["error.issue issue-1 dry-run variant=Ethereum"] + row = store.row("activity", "issue-1") + assert row is not None + assert row["execution_status"] == "done" + assert row["result"]["mode"] == "dry-run" + assert row["result"]["variant"] == "Ethereum" + assert row["result"]["template_fingerprint"] == "api|error|abc123|prod" + + +def test_scan_dry_run_is_a_noop_on_rerun(tmp_path: Path) -> None: + store = Store(tmp_path) + _runner_session(store) + _seen(store) + _issue(store) + calls: list[list[str]] = [] + + def runner(argv: list[str]) -> Completed: + calls.append(list(argv)) + return Completed(0, "", "") + + scan_error_issue(store, runner, issue_repo="org/intern", dry_run=True) + assert scan_error_issue(store, runner, issue_repo="org/intern", dry_run=True) == [] + assert calls == [] + + +# ---- scan_error_issue: create path ---- + + +def test_scan_creates_issue_when_none_exists(tmp_path: Path) -> None: + store = Store(tmp_path) + _runner_session(store) + _seen(store) + _issue(store) + calls: list[list[str]] = [] + + def runner(argv: list[str]) -> Completed: + calls.append(list(argv)) + if argv[:3] == ["gh", "issue", "list"]: + return Completed(0, "[]", "") + if argv[:3] == ["gh", "issue", "create"]: + return Completed(0, "https://github.com/org/intern/issues/7\n", "") + raise AssertionError(f"unexpected call: {argv}") + + lines = scan_error_issue(store, runner, issue_repo="org/intern", dry_run=False) + assert lines == ["error.issue issue-1 created variant=Ethereum"] + create_call = next(c for c in calls if c[:3] == ["gh", "issue", "create"]) + assert "--repo" in create_call and "org/intern" in create_call + assert "--label" in create_call and ISSUE_LABEL in create_call + body = create_call[create_call.index("--body") + 1] + assert marker_for("api|error|abc123|prod") in body + assert "Ethereum" in body + row = store.row("activity", "issue-1") + assert row is not None + assert row["execution_status"] == "done" + assert row["result"]["created"] is True + assert row["result"]["url"] == "https://github.com/org/intern/issues/7" + + +# ---- scan_error_issue: update path ---- + + +def test_scan_updates_existing_issue_same_variant_no_comment(tmp_path: Path) -> None: + store = Store(tmp_path) + _runner_session(store) + _seen(store) + _issue(store) + existing_body = ( + marker_for("api|error|abc123|prod") + + "\n\nAutomated error-log finding.\n\n" + + render_variants_section({"Ethereum": {"first_seen": "t0", "last_seen": "t0"}}) + + "\n" + ) + calls: list[list[str]] = [] + + def runner(argv: list[str]) -> Completed: + calls.append(list(argv)) + if argv[:3] == ["gh", "issue", "list"]: + return Completed(0, '[{"number": 9}]', "") + if argv[:3] == ["gh", "issue", "view"]: + import json + + return Completed(0, json.dumps({"body": existing_body}), "") + if argv[:3] == ["gh", "issue", "edit"]: + return Completed(0, "", "") + if argv[:3] == ["gh", "issue", "comment"]: + raise AssertionError("must not comment when the variant already existed") + raise AssertionError(f"unexpected call: {argv}") + + lines = scan_error_issue(store, runner, issue_repo="org/intern", dry_run=False) + assert lines == [ + "error.issue issue-1 updated number=9 variant=Ethereum new_variant=False" + ] + edit_call = next(c for c in calls if c[:3] == ["gh", "issue", "edit"]) + body = edit_call[edit_call.index("--body") + 1] + assert "Ethereum" in body + row = store.row("activity", "issue-1") + assert row is not None + assert row["result"]["new_variant"] is False + + +def test_scan_updates_existing_issue_new_variant_posts_comment(tmp_path: Path) -> None: + store = Store(tmp_path) + _runner_session(store) + _seen( + store, + template_fingerprint="api|error|abc123|prod", + excerpt="Timeout updating balances for Polygon: Error: Timeout", + ) + _issue(store) + existing_body = ( + marker_for("api|error|abc123|prod") + + "\n\nAutomated error-log finding.\n\n" + + render_variants_section({"Ethereum": {"first_seen": "t0", "last_seen": "t0"}}) + + "\n" + ) + calls: list[list[str]] = [] + + def runner(argv: list[str]) -> Completed: + calls.append(list(argv)) + if argv[:3] == ["gh", "issue", "list"]: + return Completed(0, '[{"number": 9}]', "") + if argv[:3] == ["gh", "issue", "view"]: + import json + + return Completed(0, json.dumps({"body": existing_body}), "") + if argv[:3] in (["gh", "issue", "edit"], ["gh", "issue", "comment"]): + return Completed(0, "", "") + raise AssertionError(f"unexpected call: {argv}") + + lines = scan_error_issue(store, runner, issue_repo="org/intern", dry_run=False) + assert lines == [ + "error.issue issue-1 updated number=9 variant=Polygon new_variant=True" + ] + edit_call = next(c for c in calls if c[:3] == ["gh", "issue", "edit"]) + body = edit_call[edit_call.index("--body") + 1] + assert "Ethereum" in body + assert "Polygon" in body + comment_call = next(c for c in calls if c[:3] == ["gh", "issue", "comment"]) + comment_body = comment_call[comment_call.index("--body") + 1] + assert "Polygon" in comment_body + + +# ---- error handling ---- + + +def test_scan_marks_error_when_template_fingerprint_missing(tmp_path: Path) -> None: + store = Store(tmp_path) + _runner_session(store) + _seen(store, template_fingerprint=None) + _issue(store) + + lines = scan_error_issue( + store, lambda _argv: Completed(0, "[]", ""), issue_repo="org/intern", dry_run=False + ) + assert lines == ["error.issue issue-1 error"] + row = store.row("activity", "issue-1") + assert row is not None + assert row["execution_status"] == "error" + assert row["execution_error"] == "template_fingerprint is required" + + +def test_scan_marks_error_when_create_fails(tmp_path: Path) -> None: + store = Store(tmp_path) + _runner_session(store) + _seen(store) + _issue(store) + + def runner(argv: list[str]) -> Completed: + if argv[:3] == ["gh", "issue", "list"]: + return Completed(0, "[]", "") + if argv[:3] == ["gh", "issue", "create"]: + return Completed(1, "", "permission denied") + raise AssertionError(f"unexpected call: {argv}") + + lines = scan_error_issue(store, runner, issue_repo="org/intern", dry_run=False) + assert lines == ["error.issue issue-1 error"] + row = store.row("activity", "issue-1") + assert row is not None + assert row["execution_status"] == "error" + assert row["execution_error"] == "permission denied" + + +def test_scan_leaves_non_pending_rows_alone(tmp_path: Path) -> None: + store = Store(tmp_path) + _runner_session(store) + _seen(store) + _issue(store) + scan_error_issue( + store, lambda _argv: Completed(0, "[]", ""), issue_repo="org/intern", dry_run=True + ) + calls: list[list[str]] = [] + + def fail_if_called(argv: list[str]) -> Completed: + calls.append(list(argv)) + return Completed(0, "", "") + + assert scan_error_issue(store, fail_if_called, issue_repo="org/intern", dry_run=False) == [] + assert calls == [] From c0e77dd4958ac636095e0b5c71241ad689f6247c Mon Sep 17 00:00:00 2001 From: Daniel Padrino Date: Mon, 31 Aug 2026 16:19:00 -0300 Subject: [PATCH 03/43] Also mask known asset tickers in the template signature. --- src/agent_cli/error_issue_act.py | 15 +++++++---- src/agent_cli/errors.py | 46 ++++++++++++++++++++++++++++---- tests/test_error_issue_act.py | 5 ++++ tests/test_errors.py | 22 +++++++++++++++ 4 files changed, 78 insertions(+), 10 deletions(-) diff --git a/src/agent_cli/error_issue_act.py b/src/agent_cli/error_issue_act.py index 82631c9..20eb001 100644 --- a/src/agent_cli/error_issue_act.py +++ b/src/agent_cli/error_issue_act.py @@ -9,7 +9,7 @@ from collections.abc import Callable from typing import Any -from .errors import known_chain_in +from .errors import known_asset_in, known_chain_in from .runtime import Completed from .store import Store, StoreError, utcnow @@ -68,10 +68,15 @@ def marker_for(template_fingerprint: str) -> str: def extract_variant(excerpt: str) -> str: - """Best-effort concrete detail for the variant table: the known chain name - present in the excerpt, or "generic" if none. Most error lines don't name a - chain — those never fragment, so there is only ever one variant.""" - return known_chain_in(excerpt) or "generic" + """Best-effort concrete detail for the variant table: "Chain/Asset" if both + are present in the excerpt, whichever one is present if only one is, or + "generic" if neither. Most error lines don't name either — those never + fragment, so there is only ever one variant.""" + chain = known_chain_in(excerpt) + asset = known_asset_in(excerpt) + if chain is not None and asset is not None: + return f"{chain}/{asset}" + return chain or asset or "generic" def render_variants_section(variants: dict[str, dict[str, str]]) -> str: diff --git a/src/agent_cli/errors.py b/src/agent_cli/errors.py index 3a1e86b..8e0c9a8 100644 --- a/src/agent_cli/errors.py +++ b/src/agent_cli/errors.py @@ -61,6 +61,33 @@ _CHAIN_TOKEN = re.compile( "|".join(re.escape(name) for name in sorted(_KNOWN_CHAINS, key=len, reverse=True)) ) +# Asset tickers from DFX's own asset enum (prod DB, checked 2026-08-31), masked the +# same way as chains — e.g. "Balance for Arbitrum/USDC went..." vs ".../WBTC went..." +# would otherwise stay separate templates. Kept to tickers seen on 2+ chains (a +# defensible cut against one-off/legacy DeFiChain stock-tokenization artifacts like +# "dAAPL" or internal numeric-ID-prefixed rows), plus two single-chain tickers +# (GMX, TGT) confirmed present in real production balance-check errors that day — +# the 2+-chains cut alone would otherwise have missed them. Refresh from prod if +# this drifts; there is no automated sync. +_KNOWN_ASSETS = frozenset( + { + "1INCH", "AAVE", "ADA", "APE", "ARB", "AXS", + "BAT", "BNB", "BTC", "CHF", "CHZ", "COMP", + "CRV", "DAI", "DEPS", "DFI", "ENJ", "ETH", + "EUR", "EURC", "EURS", "EURt", "GRT", "JUSD", + "LINK", "MANA", "MATIC", "MKR", "ONDO", "POL", + "QNT", "REALU", "RPL", "SAND", "SNX", "SOL", + "SUSHI", "TRX", "TUSD", "UNI", "USD", "USDC", + "USDC.e", "USDT", "WBTC", "WETH", "WFPS", "XCHF", + "XMR", "ZANO", "ZCHF", "cBTC", "dEURO", "GMX", + "TGT", + } +) +# Longest-first for the same reason as _CHAIN_TOKEN — e.g. "USD" is a literal +# prefix of "USDC"/"USDT", "EUR" of "EURC"/"EURS"/"EURt". +_ASSET_TOKEN = re.compile( + "|".join(re.escape(name) for name in sorted(_KNOWN_ASSETS, key=len, reverse=True)) +) _DIGITS = re.compile(r"\d+") _SPACE = re.compile(r"\s+") _CREDENTIAL_KEYS = frozenset( @@ -228,15 +255,17 @@ def stack_sig(line: str) -> str: def template_signature(line: str) -> str: - """Coarser than stack_sig: also masks known blockchain names (see - _KNOWN_CHAINS), so a per-chain error variant groups under one issue-filing - template instead of fragmenting into one fingerprint per chain. Used only for - grouping which GitHub issue a variant belongs to — error.seen identity keeps - using the finer-grained fingerprint()/stack_sig() so per-variant count/last_seen + """Coarser than stack_sig: also masks known blockchain names and asset + tickers (see _KNOWN_CHAINS/_KNOWN_ASSETS), so a per-chain or per-token error + variant groups under one issue-filing template instead of fragmenting into + one fingerprint per chain/token pair. Used only for grouping which GitHub + issue a variant belongs to — error.seen identity keeps using the + finer-grained fingerprint()/stack_sig() so per-variant count/last_seen tracking stays exact.""" norm = redact(strip_ansi(line)) norm = _UUID.sub("", norm) norm = _CHAIN_TOKEN.sub("", norm) + norm = _ASSET_TOKEN.sub("", norm) norm = _DIGITS.sub("", norm) norm = _SPACE.sub(" ", norm).strip().lower() return hashlib.sha256(norm.encode("utf-8")).hexdigest()[:16] @@ -256,6 +285,13 @@ def known_chain_in(line: str) -> str | None: return match.group(0) if match is not None else None +def known_asset_in(line: str) -> str | None: + """The first known asset ticker present in the line, if any — same purpose + as known_chain_in, for the token half of a chain/token variant label.""" + match = _ASSET_TOKEN.search(line) + return match.group(0) if match is not None else None + + def _strip(row: dict[str, Any]) -> dict[str, Any]: return {k: v for k, v in row.items() if not k.startswith("_")} diff --git a/tests/test_error_issue_act.py b/tests/test_error_issue_act.py index c39bf5b..cf50081 100644 --- a/tests/test_error_issue_act.py +++ b/tests/test_error_issue_act.py @@ -87,6 +87,11 @@ def test_extract_variant_finds_known_chain_or_falls_back_to_generic() -> None: assert extract_variant("Failed to get price for token tether -> usd") == "generic" +def test_extract_variant_combines_chain_and_asset() -> None: + assert extract_variant("Balance for Arbitrum/USDC went low") == "Arbitrum/USDC" + assert extract_variant("Balance for Base/WBTC went low") == "Base/WBTC" + + def test_marker_for_embeds_template_fingerprint() -> None: marker = marker_for("api|error|abc123|prod") assert marker == "" diff --git a/tests/test_errors.py b/tests/test_errors.py index 421ae92..109ad54 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -12,6 +12,7 @@ error_class, fingerprint, is_incident_line, + known_asset_in, known_chain_in, line_fingerprint, load_config, @@ -182,6 +183,27 @@ def test_template_signature_masks_known_chains() -> None: assert stack_sig(ethereum) != stack_sig(polygon) +def test_template_signature_masks_known_assets() -> None: + usdc = "Balance for Arbitrum/USDC went low" + wbtc = "Balance for Arbitrum/WBTC went low" + assert template_signature(usdc) == template_signature(wbtc) + assert stack_sig(usdc) != stack_sig(wbtc) + + +def test_asset_token_regex_masks_longest_match_first() -> None: + from agent_cli.errors import _ASSET_TOKEN + + # "USD" is a literal prefix of "USDC" — an unsorted alternation would match + # "USD" first and leave "C" dangling in the masked output. + assert _ASSET_TOKEN.sub("", "balance in USDC today") == "balance in today" + assert _ASSET_TOKEN.sub("", "balance in USD today") == "balance in today" + + +def test_known_asset_in_finds_and_omits() -> None: + assert known_asset_in("Balance for Arbitrum/USDC went low") == "USDC" + assert known_asset_in("Failed to get price for token tether -> usd") is None + + def test_chain_token_regex_masks_longest_match_first() -> None: from agent_cli.errors import _CHAIN_TOKEN From 9edb8681997a3a7a7932a95a087926ec8564eedf Mon Sep 17 00:00:00 2001 From: Daniel Padrino Date: Mon, 31 Aug 2026 16:52:28 -0300 Subject: [PATCH 04/43] Add burst detection and a per-template cooldown to error_issue_act. --- src/agent_cli/error_issue_act.py | 253 ++++++++++++++++++++++++-- tests/test_error_issue_act.py | 296 ++++++++++++++++++++++++++++++- 2 files changed, 537 insertions(+), 12 deletions(-) diff --git a/src/agent_cli/error_issue_act.py b/src/agent_cli/error_issue_act.py index 20eb001..986b64f 100644 --- a/src/agent_cli/error_issue_act.py +++ b/src/agent_cli/error_issue_act.py @@ -1,12 +1,27 @@ """Apply pending error.issue activities: file or update a GitHub issue for a normalized error template, grouped by template_fingerprint rather than the finer-grained per-variant fingerprint — or, under dry_run, just log the intended -action instead of calling gh for real.""" +action instead of calling gh for real. + +Two throttles sit in front of the per-template logic: + +- Burst detection: if one run resolves more distinct new templates than + storm_threshold, that is treated as one anomaly (a likely shared root cause) + rather than N unrelated problems — all of them fold into a single, reused + "burst" issue instead of N individual ones. +- Cooldown: a template that was already touched (created, updated, or folded + into a burst) within cooldown_minutes is skipped entirely — checked against + local history, not a live gh call, so a fast-recurring error does not cost a + round trip or an issue edit every time it repeats. + +Both are plain comparisons against local state; neither involves model +judgment.""" from __future__ import annotations import json from collections.abc import Callable +from datetime import datetime, timedelta, timezone from typing import Any from .errors import known_asset_in, known_chain_in @@ -20,6 +35,10 @@ _MARKER_SUFFIX = " -->" _VARIANTS_START = "" _VARIANTS_END = "" +STORM_MARKER = "" + +DEFAULT_COOLDOWN_MINUTES = 60 +DEFAULT_STORM_THRESHOLD = 8 def _strip(row: dict[str, Any]) -> dict[str, Any]: @@ -120,6 +139,47 @@ def splice_variants(body: str, variants: dict[str, dict[str, str]]) -> str: return body[:start] + section + body[end + len(_VARIANTS_END) :] +def _parse_iso(ts: str) -> datetime: + return datetime.strptime(ts, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc) + + +def _recently_touched( + store: Store, template_fingerprint: str, now: str, cooldown_minutes: int +) -> bool: + """Whether this template's issue was already touched (created, updated, or + folded into a burst — but not merely skipped by a prior cooldown check, + which must not renew its own window) within cooldown_minutes. Checked + against local activity history, not a live gh call, so a fast-recurring + error costs no round trip and no issue edit on every repeat.""" + if cooldown_minutes <= 0: + return False + try: + now_dt = _parse_iso(now) + except ValueError: + return False + origin = store.device_id() + for row in store.rows("activity"): + if row.get("_origin_device_id") != origin or row.get("type") != "error.issue": + continue + if row.get("execution_status") != "done": + continue + result = row.get("result") + if not isinstance(result, dict) or result.get("skipped"): + continue + if result.get("template_fingerprint") != template_fingerprint: + continue + touched_at = result.get("at") + if not isinstance(touched_at, str): + continue + try: + touched_dt = _parse_iso(touched_at) + except ValueError: + continue + if now_dt - touched_dt < timedelta(minutes=cooldown_minutes): + return True + return False + + def _pending_issue(store: Store, row: dict[str, Any]) -> tuple[str, str, dict[str, Any]]: payload = row.get("payload") if not isinstance(payload, dict): @@ -140,9 +200,9 @@ def _pending_issue(store: Store, row: dict[str, Any]) -> tuple[str, str, dict[st return error_id, template_fp, seen_payload -def find_issue_number(runner: Runner, issue_repo: str, template_fingerprint: str) -> int | None: - """Search issue_repo for an open, labeled issue carrying this template's - hidden marker. None if no such issue exists yet.""" +def find_issue_number(runner: Runner, issue_repo: str, marker: str) -> int | None: + """Search issue_repo for an open, labeled issue carrying this marker. None + if no such issue exists yet.""" completed = runner( [ "gh", @@ -155,7 +215,7 @@ def find_issue_number(runner: Runner, issue_repo: str, template_fingerprint: str "--state", "open", "--search", - marker_for(template_fingerprint), + marker, "--json", "number", ] @@ -259,15 +319,155 @@ def _update_issue( return is_new +def _storm_label(template_fingerprint: str, seen_payload: dict[str, Any]) -> str: + service = seen_payload.get("service") + service = service if isinstance(service, str) and service else "unknown" + cls = seen_payload.get("class") + cls = cls if isinstance(cls, str) and cls else "error" + parts = template_fingerprint.split("|") + short = parts[2][:8] if len(parts) >= 3 and parts[2] else template_fingerprint[:8] + return f"{service}: {cls} ({short})" + + +def _create_storm_issue( + runner: Runner, *, issue_repo: str, templates: dict[str, dict[str, str]], now: str +) -> str: + body = ( + f"{STORM_MARKER}\n\n" + "Automated burst finding: this run saw more distinct new error templates " + "than usual in one pass. That is more likely one shared root cause than " + "many unrelated bugs — investigate the cause, not each row below " + "individually.\n\n" + + render_variants_section(templates) + + "\n" + ) + completed = runner( + [ + "gh", + "issue", + "create", + "--repo", + issue_repo, + "--label", + ISSUE_LABEL, + "--title", + f"Error-log burst: {len(templates)} new templates in one run", + "--body", + body, + ] + ) + if completed.returncode != 0: + raise StoreError((completed.stderr or completed.stdout or "gh issue create failed").strip()) + return completed.stdout.strip() + + +def _update_storm_issue( + runner: Runner, *, issue_repo: str, number: int, templates: dict[str, dict[str, str]] +) -> None: + body = _issue_body(runner, issue_repo, number) + existing = parse_variants_section(body) + for name, entry in templates.items(): + if name in existing: + existing[name]["last_seen"] = entry["last_seen"] + else: + existing[name] = dict(entry) + edit = runner( + ["gh", "issue", "edit", str(number), "--repo", issue_repo, "--body", splice_variants(body, existing)] + ) + if edit.returncode != 0: + raise StoreError((edit.stderr or edit.stdout or "gh issue edit failed").strip()) + + +def _process_storm( + store: Store, + runner: Runner, + *, + issue_repo: str, + dry_run: bool, + resolved: list[tuple[dict[str, Any], str, str, dict[str, Any]]], + now: str, +) -> list[str]: + templates: dict[str, dict[str, str]] = {} + for _row, _error_id, template_fp, seen_payload in resolved: + label = _storm_label(template_fp, seen_payload) + if label in templates: + templates[label]["last_seen"] = now + else: + templates[label] = {"first_seen": now, "last_seen": now} + + lines: list[str] = [] + + if dry_run: + for row, _error_id, template_fp, _seen_payload in resolved: + rid = str(row.get("id") or "?") + result = { + "mode": "storm-dry-run", + "issue_repo": issue_repo, + "template_fingerprint": template_fp, + "at": now, + "storm_size": len(templates), + } + _mark(store, row, status="done", result=result) + lines.append(f"error.issue {rid} storm-dry-run size={len(templates)}") + return lines + + try: + number = find_issue_number(runner, issue_repo, STORM_MARKER) + if number is None: + url = _create_storm_issue(runner, issue_repo=issue_repo, templates=templates, now=now) + extra: dict[str, Any] = {"url": url, "created": True} + else: + _update_storm_issue(runner, issue_repo=issue_repo, number=number, templates=templates) + extra = {"number": number, "created": False} + except StoreError as exc: + for row, _error_id, _template_fp, _seen_payload in resolved: + rid = str(row.get("id") or "?") + _mark(store, row, status="error", error=str(exc)) + lines.append(f"error.issue {rid} error") + return lines + + for row, _error_id, template_fp, _seen_payload in resolved: + rid = str(row.get("id") or "?") + result = { + "mode": "storm", + "issue_repo": issue_repo, + "template_fingerprint": template_fp, + "at": now, + **extra, + } + _mark(store, row, status="done", result=result) + lines.append(f"error.issue {rid} storm size={len(templates)}") + return lines + + def scan_error_issue( - store: Store, runner: Runner, *, issue_repo: str, dry_run: bool + store: Store, + runner: Runner, + *, + issue_repo: str, + dry_run: bool, + cooldown_minutes: int = DEFAULT_COOLDOWN_MINUTES, + storm_threshold: int = DEFAULT_STORM_THRESHOLD, ) -> list[str]: with store.exclusive("error-issue-act:" + store.device_id()): - return _scan_error_issue(store, runner, issue_repo=issue_repo, dry_run=dry_run) + return _scan_error_issue( + store, + runner, + issue_repo=issue_repo, + dry_run=dry_run, + cooldown_minutes=cooldown_minutes, + storm_threshold=storm_threshold, + ) def _scan_error_issue( - store: Store, runner: Runner, *, issue_repo: str, dry_run: bool + store: Store, + runner: Runner, + *, + issue_repo: str, + dry_run: bool, + cooldown_minutes: int, + storm_threshold: int, ) -> list[str]: rows = [ row @@ -277,26 +477,53 @@ def _scan_error_issue( and row.get("execution_status") == "pending" ] rows.sort(key=lambda row: str(row.get("id") or "")) + lines: list[str] = [] + resolved: list[tuple[dict[str, Any], str, str, dict[str, Any]]] = [] for row in rows: rid = str(row.get("id") or "?") try: - _error_id, template_fp, seen_payload = _pending_issue(store, row) + error_id, template_fp, seen_payload = _pending_issue(store, row) except StoreError as exc: _mark(store, row, status="error", error=str(exc)) lines.append(f"error.issue {rid} error") continue + resolved.append((row, error_id, template_fp, seen_payload)) + if not resolved: + return lines + + now = utcnow() + distinct_templates = {template_fp for _row, _error_id, template_fp, _seen_payload in resolved} + if len(distinct_templates) > storm_threshold: + lines.extend( + _process_storm(store, runner, issue_repo=issue_repo, dry_run=dry_run, resolved=resolved, now=now) + ) + return lines + + for row, _error_id, template_fp, seen_payload in resolved: + rid = str(row.get("id") or "?") excerpt = seen_payload.get("excerpt") excerpt = excerpt if isinstance(excerpt, str) else "" variant = extract_variant(excerpt) - now = utcnow() + + if _recently_touched(store, template_fp, now, cooldown_minutes): + result = { + "issue_repo": issue_repo, + "template_fingerprint": template_fp, + "at": now, + "skipped": "cooldown", + } + _mark(store, row, status="done", result=result) + lines.append(f"error.issue {rid} skipped-cooldown") + continue if dry_run: result = { "mode": "dry-run", "issue_repo": issue_repo, "template_fingerprint": template_fp, + "at": now, "variant": variant, "excerpt": excerpt[:300], } @@ -305,7 +532,7 @@ def _scan_error_issue( continue try: - number = find_issue_number(runner, issue_repo, template_fp) + number = find_issue_number(runner, issue_repo, marker_for(template_fp)) except StoreError as exc: _mark(store, row, status="error", error=str(exc)) lines.append(f"error.issue {rid} error") @@ -332,6 +559,8 @@ def _scan_error_issue( continue result = { "issue_repo": issue_repo, + "template_fingerprint": template_fp, + "at": now, "url": url, "variant": variant, "created": True, @@ -350,6 +579,8 @@ def _scan_error_issue( continue result = { "issue_repo": issue_repo, + "template_fingerprint": template_fp, + "at": now, "number": number, "variant": variant, "created": False, diff --git a/tests/test_error_issue_act.py b/tests/test_error_issue_act.py index cf50081..351d68a 100644 --- a/tests/test_error_issue_act.py +++ b/tests/test_error_issue_act.py @@ -6,6 +6,8 @@ from agent_cli.error_issue_act import ( ISSUE_LABEL, + STORM_MARKER, + _recently_touched, extract_variant, find_issue_number, marker_for, @@ -15,7 +17,7 @@ splice_variants, ) from agent_cli.runtime import Completed -from agent_cli.store import Store, StoreError +from agent_cli.store import Store, StoreError, utcnow def _runner_session(store: Store) -> None: @@ -367,3 +369,295 @@ def fail_if_called(argv: list[str]) -> Completed: assert scan_error_issue(store, fail_if_called, issue_repo="org/intern", dry_run=False) == [] assert calls == [] + + +def _prior_touch( + store: Store, + *, + template_fingerprint: str, + at: str, + activity_id: str, + skipped: bool = False, +) -> None: + result: dict[str, object] = { + "issue_repo": "org/intern", + "template_fingerprint": template_fingerprint, + "at": at, + } + if skipped: + result["skipped"] = "cooldown" + store.write( + "activity", + "insert", + activity_id, + { + "id": activity_id, + "session_id": "runner-1", + "type": "error.issue", + "payload": {"error_id": "irrelevant"}, + "execution_status": "done", + "result": result, + }, + ) + + +def _seen_and_issue( + store: Store, *, index: int, template_fingerprint: str, service: str = "api", cls: str = "error" +) -> None: + seen_id = f"seen-{index}" + issue_id = f"storm-issue-{index}" + store.write( + "activity", + "insert", + seen_id, + { + "id": seen_id, + "session_id": "runner-1", + "type": "error.seen", + "payload": { + "fingerprint": f"fp-{index}", + "template_fingerprint": template_fingerprint, + "excerpt": f"Some error number {index}", + "service": service, + "class": cls, + }, + "execution_status": "done", + }, + ) + store.write( + "activity", + "insert", + issue_id, + { + "id": issue_id, + "session_id": "runner-1", + "type": "error.issue", + "payload": {"error_id": seen_id}, + "execution_status": "pending", + }, + ) + + +# ---- cooldown ---- + + +def test_recently_touched_false_with_no_history(tmp_path: Path) -> None: + store = Store(tmp_path) + _runner_session(store) + assert _recently_touched(store, "api|error|abc|prod", utcnow(), 60) is False + + +def test_recently_touched_true_within_window(tmp_path: Path) -> None: + store = Store(tmp_path) + _runner_session(store) + _prior_touch( + store, + template_fingerprint="api|error|abc|prod", + at="2026-08-31T10:00:00Z", + activity_id="prior-1", + ) + assert _recently_touched(store, "api|error|abc|prod", "2026-08-31T10:30:00Z", 60) is True + + +def test_recently_touched_false_after_expiry(tmp_path: Path) -> None: + store = Store(tmp_path) + _runner_session(store) + _prior_touch( + store, + template_fingerprint="api|error|abc|prod", + at="2026-08-31T10:00:00Z", + activity_id="prior-1", + ) + assert _recently_touched(store, "api|error|abc|prod", "2026-08-31T11:30:00Z", 60) is False + + +def test_recently_touched_ignores_skipped_results(tmp_path: Path) -> None: + store = Store(tmp_path) + _runner_session(store) + _prior_touch( + store, + template_fingerprint="api|error|abc|prod", + at="2026-08-31T10:29:00Z", + activity_id="prior-1", + skipped=True, + ) + # A skip-only history must not itself extend the cooldown window. + assert _recently_touched(store, "api|error|abc|prod", "2026-08-31T10:30:00Z", 60) is False + + +def test_scan_skips_recently_touched_template_without_gh_calls(tmp_path: Path) -> None: + store = Store(tmp_path) + _runner_session(store) + _seen(store) + _issue(store) + _prior_touch( + store, template_fingerprint="api|error|abc123|prod", at=utcnow(), activity_id="prior-1" + ) + calls: list[list[str]] = [] + + def fail_if_called(argv: list[str]) -> Completed: + calls.append(list(argv)) + return Completed(0, "", "") + + lines = scan_error_issue( + store, fail_if_called, issue_repo="org/intern", dry_run=False, cooldown_minutes=60 + ) + assert lines == ["error.issue issue-1 skipped-cooldown"] + assert calls == [] + row = store.row("activity", "issue-1") + assert row is not None + assert row["execution_status"] == "done" + assert row["result"]["skipped"] == "cooldown" + + +def test_scan_processes_normally_after_cooldown_expires(tmp_path: Path) -> None: + store = Store(tmp_path) + _runner_session(store) + _seen(store) + _issue(store) + _prior_touch( + store, + template_fingerprint="api|error|abc123|prod", + at="2020-01-01T00:00:00Z", + activity_id="prior-1", + ) + + def runner(argv: list[str]) -> Completed: + if argv[:3] == ["gh", "issue", "list"]: + return Completed(0, "[]", "") + if argv[:3] == ["gh", "issue", "create"]: + return Completed(0, "https://github.com/org/intern/issues/1\n", "") + raise AssertionError(f"unexpected call: {argv}") + + lines = scan_error_issue( + store, runner, issue_repo="org/intern", dry_run=False, cooldown_minutes=60 + ) + assert lines == ["error.issue issue-1 created variant=Ethereum"] + + +# ---- burst / storm detection ---- + + +def test_scan_does_not_storm_at_or_below_threshold(tmp_path: Path) -> None: + store = Store(tmp_path) + _runner_session(store) + for i in range(2): + _seen_and_issue(store, index=i, template_fingerprint=f"api|error|t{i}|prod") + calls: list[list[str]] = [] + + def runner(argv: list[str]) -> Completed: + calls.append(list(argv)) + if argv[:3] == ["gh", "issue", "list"]: + return Completed(0, "[]", "") + if argv[:3] == ["gh", "issue", "create"]: + return Completed(0, "https://github.com/org/intern/issues/1\n", "") + raise AssertionError(f"unexpected call: {argv}") + + lines = scan_error_issue(store, runner, issue_repo="org/intern", dry_run=False, storm_threshold=2) + assert len(lines) == 2 + assert all("created" in line for line in lines) + create_calls = [c for c in calls if c[:3] == ["gh", "issue", "create"]] + assert len(create_calls) == 2 # two separate issues, not folded + + +def test_scan_folds_burst_into_one_storm_issue(tmp_path: Path) -> None: + store = Store(tmp_path) + _runner_session(store) + for i in range(3): + _seen_and_issue(store, index=i, template_fingerprint=f"api|error|t{i}|prod") + calls: list[list[str]] = [] + + def runner(argv: list[str]) -> Completed: + calls.append(list(argv)) + if argv[:3] == ["gh", "issue", "list"]: + return Completed(0, "[]", "") + if argv[:3] == ["gh", "issue", "create"]: + return Completed(0, "https://github.com/org/intern/issues/99\n", "") + raise AssertionError(f"unexpected call: {argv}") + + lines = scan_error_issue(store, runner, issue_repo="org/intern", dry_run=False, storm_threshold=2) + assert len(lines) == 3 + assert all("storm" in line for line in lines) + create_calls = [c for c in calls if c[:3] == ["gh", "issue", "create"]] + assert len(create_calls) == 1 + body = create_calls[0][create_calls[0].index("--body") + 1] + assert STORM_MARKER in body + for i in range(3): + row = store.row("activity", f"storm-issue-{i}") + assert row is not None + assert row["execution_status"] == "done" + assert row["result"]["mode"] == "storm" + + +def test_scan_storm_dry_run_never_calls_gh(tmp_path: Path) -> None: + store = Store(tmp_path) + _runner_session(store) + for i in range(3): + _seen_and_issue(store, index=i, template_fingerprint=f"api|error|t{i}|prod") + calls: list[list[str]] = [] + + def runner(argv: list[str]) -> Completed: + calls.append(list(argv)) + return Completed(0, "", "") + + lines = scan_error_issue(store, runner, issue_repo="org/intern", dry_run=True, storm_threshold=2) + assert calls == [] + assert len(lines) == 3 + assert all("storm-dry-run" in line for line in lines) + + +def test_scan_storm_reuses_existing_open_storm_issue(tmp_path: Path) -> None: + store = Store(tmp_path) + _runner_session(store) + for i in range(3): + _seen_and_issue(store, index=i, template_fingerprint=f"api|error|t{i}|prod") + existing_body = ( + STORM_MARKER + + "\n\n" + + render_variants_section({"api: error (oldhash)": {"first_seen": "t0", "last_seen": "t0"}}) + + "\n" + ) + calls: list[list[str]] = [] + + def runner(argv: list[str]) -> Completed: + calls.append(list(argv)) + if argv[:3] == ["gh", "issue", "list"]: + return Completed(0, '[{"number": 55}]', "") + if argv[:3] == ["gh", "issue", "view"]: + import json + + return Completed(0, json.dumps({"body": existing_body}), "") + if argv[:3] == ["gh", "issue", "edit"]: + return Completed(0, "", "") + raise AssertionError(f"unexpected call: {argv}") + + lines = scan_error_issue(store, runner, issue_repo="org/intern", dry_run=False, storm_threshold=2) + assert len(lines) == 3 + assert [c for c in calls if c[:3] == ["gh", "issue", "create"]] == [] + edit_calls = [c for c in calls if c[:3] == ["gh", "issue", "edit"]] + assert len(edit_calls) == 1 + body = edit_calls[0][edit_calls[0].index("--body") + 1] + assert "api: error (oldhash)" in body + assert body.count(STORM_MARKER) == 1 + + +def test_scan_storm_marks_all_rows_error_on_create_failure(tmp_path: Path) -> None: + store = Store(tmp_path) + _runner_session(store) + for i in range(3): + _seen_and_issue(store, index=i, template_fingerprint=f"api|error|t{i}|prod") + + def runner(argv: list[str]) -> Completed: + if argv[:3] == ["gh", "issue", "list"]: + return Completed(0, "[]", "") + if argv[:3] == ["gh", "issue", "create"]: + return Completed(1, "", "permission denied") + raise AssertionError(f"unexpected call: {argv}") + + lines = scan_error_issue(store, runner, issue_repo="org/intern", dry_run=False, storm_threshold=2) + assert len(lines) == 3 + assert all(line.endswith("error") for line in lines) + for i in range(3): + row = store.row("activity", f"storm-issue-{i}") + assert row is not None + assert row["execution_status"] == "error" From d4f7b00259b5272664fccbec8f9e825b5ddc5e77 Mon Sep 17 00:00:00 2001 From: Danswar <48102227+Danswar@users.noreply.github.com> Date: Mon, 31 Aug 2026 21:54:41 -0300 Subject: [PATCH 05/43] Match known chain and asset names only as whole tokens. --- src/agent_cli/errors.py | 27 ++++++++++++++++++--------- tests/test_errors.py | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 9 deletions(-) diff --git a/src/agent_cli/errors.py b/src/agent_cli/errors.py index 8e0c9a8..774e392 100644 --- a/src/agent_cli/errors.py +++ b/src/agent_cli/errors.py @@ -57,10 +57,23 @@ "Olkypay", "BitcoinTestnet4", "BinancePay", "Arkade", "Cardano", "Monero", } ) -# Longest-first so e.g. "Bitcoin" cannot shadow-match a prefix of "BitcoinTestnet4". -_CHAIN_TOKEN = re.compile( - "|".join(re.escape(name) for name in sorted(_KNOWN_CHAINS, key=len, reverse=True)) -) + + +def _token_pattern(names: frozenset[str]) -> re.Pattern[str]: + """Alternation over known names, longest-first so e.g. "Bitcoin" cannot + shadow-match a prefix of "BitcoinTestnet4" and "USD" cannot shadow-match + "USDC". Anchored on both sides so a name only matches as a whole token: + without that, "Base" matches inside "Based", "SOL" inside "RESOLVE", "COMP" + inside "COMPLETE" and "DAI" inside "DAILY", which would mask unrelated words + and label an unrelated error as a chain/asset variant. The anchors are + explicit look-arounds rather than \b because several names are not + word-character-only ("USDC.e"). Matching stays case-sensitive on purpose: + lowercase prose words like "usd" in "token tether -> usd" are not tickers.""" + alternation = "|".join(re.escape(name) for name in sorted(names, key=len, reverse=True)) + return re.compile(rf"(? tuple[list[dict], str | None]: assert enriched == [] assert len(created) == 1 assert "keep" in store.row("activity", created[0])["payload"]["excerpt"] + + +def test_token_masking_only_matches_whole_tokens() -> None: + """Without boundary anchors "Base" matches inside "Based", "SOL" inside + "RESOLVE" and "COMP" inside "COMPLETE" — unrelated errors would then be + masked as chain/asset variants and labelled with a token that has nothing to + do with them.""" + for line in ( + "Based on the previous failure the job aborted", + "RESOLVE failed for host", + "COMPLETE checkout failed", + "DAILY reconciliation failed", + "UNIQUE constraint violated on table users", + "POLICY denied the request", + "BATCH job failed", + "MANAGEMENT api unreachable", + "LINKING accounts failed", + "SANDBOX unavailable", + ): + assert known_chain_in(line) is None, line + assert known_asset_in(line) is None, line + + +def test_token_masking_still_matches_real_names_next_to_punctuation() -> None: + assert known_chain_in("Balance for Arbitrum/USDC went low") == "Arbitrum" + assert known_asset_in("Balance for Arbitrum/USDC went low") == "USDC" + # Tickers that are not word-character-only, or start with a digit, still match. + assert known_asset_in("USDC.e drift on Arbitrum") == "USDC.e" + assert known_asset_in("low balance 1INCH on Ethereum") == "1INCH" + + +def test_template_signature_does_not_group_unrelated_words_with_assets() -> None: + # "UNIQUE" must not collapse onto the same template as a real ticker just + # because "UNI" is a prefix of it. + assert template_signature("UNIQUE constraint failed") != template_signature( + "UNI constraint failed" + ) From 17ba2142b89ac1116f28d0c96f3bb0ee1da3e1ba Mon Sep 17 00:00:00 2001 From: Danswar <48102227+Danswar@users.noreply.github.com> Date: Mon, 31 Aug 2026 21:54:44 -0300 Subject: [PATCH 06/43] Handle every pending row of one error template in one pass. --- src/agent_cli/error_issue_act.py | 407 +++++++++++++++++++++---------- tests/test_error_issue_act.py | 252 +++++++++++++++++-- 2 files changed, 504 insertions(+), 155 deletions(-) diff --git a/src/agent_cli/error_issue_act.py b/src/agent_cli/error_issue_act.py index 986b64f..8dba922 100644 --- a/src/agent_cli/error_issue_act.py +++ b/src/agent_cli/error_issue_act.py @@ -5,14 +5,21 @@ Two throttles sit in front of the per-template logic: -- Burst detection: if one run resolves more distinct new templates than - storm_threshold, that is treated as one anomaly (a likely shared root cause) - rather than N unrelated problems — all of them fold into a single, reused - "burst" issue instead of N individual ones. +- Burst detection: if one run resolves more templates that were never filed + before than storm_threshold, that is treated as one anomaly (a likely shared + root cause) rather than N unrelated problems — those fold into a single, + reused "burst" issue instead of N individual ones. Templates that already have + history keep updating their own issue: a backlog draining after downtime is a + volume spike, not a burst of new problems. - Cooldown: a template that was already touched (created, updated, or folded into a burst) within cooldown_minutes is skipped entirely — checked against local history, not a live gh call, so a fast-recurring error does not cost a - round trip or an issue edit every time it repeats. + round trip or an issue edit every time it repeats. A dry run is a preview and + never opens that window. + +All pending rows of one template are handled together, so two variants seen in +the same run land in one issue instead of the first becoming a cooldown touch +that drops the second. Both are plain comparisons against local state; neither involves model judgment.""" @@ -39,6 +46,13 @@ DEFAULT_COOLDOWN_MINUTES = 60 DEFAULT_STORM_THRESHOLD = 8 +# GitHub rejects an issue body over 65536 characters. Refuse a little earlier and +# loudly, so a long-lived burst issue reports the ceiling instead of every later +# edit failing at the API with a generic error. +MAX_ISSUE_BODY = 60000 +# Result modes that never touched GitHub, so they must not start a cooldown +# window: a dry run is a preview, not a touch. +_DRY_RUN_MODES = frozenset({"dry-run", "storm-dry-run"}) def _strip(row: dict[str, Any]) -> dict[str, Any]: @@ -129,34 +143,40 @@ def parse_variants_section(body: str) -> dict[str, dict[str, str]]: def splice_variants(body: str, variants: dict[str, dict[str, str]]) -> str: """Replace only the delimited variants section; never touch the rest of the - body — that is human territory.""" + body — that is human territory. A body carrying exactly one of the two + markers, or them in the wrong order, is damaged (a hand edit truncated the + section): appending a second section there would silently strand the + variants already recorded above, so fail loud instead.""" start = body.find(_VARIANTS_START) end = body.find(_VARIANTS_END) section = render_variants_section(variants) - if start == -1 or end == -1 or end < start: + if start == -1 and end == -1: sep = "" if body == "" else "\n\n" - return f"{body}{sep}{section}\n" - return body[:start] + section + body[end + len(_VARIANTS_END) :] + spliced = f"{body}{sep}{section}\n" + elif start == -1 or end == -1 or end < start: + raise StoreError("issue body has a damaged variants section") + else: + spliced = body[:start] + section + body[end + len(_VARIANTS_END) :] + if len(spliced) > MAX_ISSUE_BODY: + raise StoreError("issue body would exceed the GitHub body limit") + return spliced def _parse_iso(ts: str) -> datetime: return datetime.strptime(ts, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc) -def _recently_touched( - store: Store, template_fingerprint: str, now: str, cooldown_minutes: int -) -> bool: - """Whether this template's issue was already touched (created, updated, or - folded into a burst — but not merely skipped by a prior cooldown check, - which must not renew its own window) within cooldown_minutes. Checked - against local activity history, not a live gh call, so a fast-recurring - error costs no round trip and no issue edit on every repeat.""" - if cooldown_minutes <= 0: - return False - try: - now_dt = _parse_iso(now) - except ValueError: - return False +def touch_history(store: Store) -> dict[str, datetime]: + """Most recent real touch per template, read once from local activity + history. A touch is a completed error.issue row that actually created, + updated or folded the template's issue — not one merely skipped by an + earlier cooldown check (which must not renew its own window) and not a dry + run (a preview must not suppress the real run that follows it). + + Read as a snapshot before a scan mutates anything: rows written earlier in + the same scan are not touches for the rows behind them, otherwise the second + variant of one template would be skipped against the first.""" + history: dict[str, datetime] = {} origin = store.device_id() for row in store.rows("activity"): if row.get("_origin_device_id") != origin or row.get("type") != "error.issue": @@ -166,7 +186,10 @@ def _recently_touched( result = row.get("result") if not isinstance(result, dict) or result.get("skipped"): continue - if result.get("template_fingerprint") != template_fingerprint: + if result.get("mode") in _DRY_RUN_MODES: + continue + template_fingerprint = result.get("template_fingerprint") + if not isinstance(template_fingerprint, str): continue touched_at = result.get("at") if not isinstance(touched_at, str): @@ -175,9 +198,37 @@ def _recently_touched( touched_dt = _parse_iso(touched_at) except ValueError: continue - if now_dt - touched_dt < timedelta(minutes=cooldown_minutes): - return True - return False + previous = history.get(template_fingerprint) + if previous is None or touched_dt > previous: + history[template_fingerprint] = touched_dt + return history + + +def _within_cooldown( + history: dict[str, datetime], template_fingerprint: str, now: str, cooldown_minutes: int +) -> bool: + """Whether this template's issue was touched within cooldown_minutes, against + a history snapshot. No gh call, so a fast-recurring error costs no round trip + and no issue edit on every repeat.""" + if cooldown_minutes <= 0: + return False + touched_dt = history.get(template_fingerprint) + if touched_dt is None: + return False + try: + now_dt = _parse_iso(now) + except ValueError: + return False + return now_dt - touched_dt < timedelta(minutes=cooldown_minutes) + + +def _recently_touched( + store: Store, template_fingerprint: str, now: str, cooldown_minutes: int +) -> bool: + """Single-template convenience over touch_history/_within_cooldown.""" + return _within_cooldown( + touch_history(store), template_fingerprint, now, cooldown_minutes + ) def _pending_issue(store: Store, row: dict[str, Any]) -> tuple[str, str, dict[str, Any]]: @@ -254,14 +305,14 @@ def _create_issue( title: str, template_fingerprint: str, excerpt: str, - variant: str, + variants: list[str], now: str, ) -> str: body = ( f"{marker_for(template_fingerprint)}\n\n" "Automated error-log finding.\n\n" f"```\n{excerpt}\n```\n\n" - + render_variants_section({variant: {"first_seen": now, "last_seen": now}}) + + render_variants_section({v: {"first_seen": now, "last_seen": now} for v in variants}) + "\n" ) completed = runner( @@ -285,38 +336,50 @@ def _create_issue( def _update_issue( - runner: Runner, *, issue_repo: str, number: int, variant: str, now: str -) -> bool: - """Splice the variant into the issue's tracked table; comment only if it is - new. Returns whether this was a new variant.""" + runner: Runner, *, issue_repo: str, number: int, variants: list[str], now: str +) -> tuple[list[str], str | None]: + """Splice these variants into the issue's tracked table; comment once for the + ones that are genuinely new. Returns the new variants and, if the comment + failed, its error. + + The edit runs before the comment so the durable record (the variant table) + is written first and a retry can never double-file a variant. That makes the + comment a best-effort notification: failing the whole row on a comment error + would strand a row whose table entry already landed, and no retry could ever + send that comment again (the variant is no longer new). So a comment failure + is reported on the row instead of discarding the successful edit.""" body = _issue_body(runner, issue_repo, number) - variants = parse_variants_section(body) - is_new = variant not in variants - if is_new: - variants[variant] = {"first_seen": now, "last_seen": now} - else: - variants[variant]["last_seen"] = now + tracked = parse_variants_section(body) + new_variants = [v for v in variants if v not in tracked] + for variant in variants: + if variant in tracked: + tracked[variant]["last_seen"] = now + else: + tracked[variant] = {"first_seen": now, "last_seen": now} edit = runner( - ["gh", "issue", "edit", str(number), "--repo", issue_repo, "--body", splice_variants(body, variants)] + ["gh", "issue", "edit", str(number), "--repo", issue_repo, "--body", splice_variants(body, tracked)] ) if edit.returncode != 0: raise StoreError((edit.stderr or edit.stdout or "gh issue edit failed").strip()) - if is_new: - comment = runner( - [ - "gh", - "issue", - "comment", - str(number), - "--repo", - issue_repo, - "--body", - f"Also seen on: {variant} ({now}).", - ] - ) - if comment.returncode != 0: - raise StoreError((comment.stderr or comment.stdout or "gh issue comment failed").strip()) - return is_new + if not new_variants: + return new_variants, None + comment = runner( + [ + "gh", + "issue", + "comment", + str(number), + "--repo", + issue_repo, + "--body", + f"Also seen on: {', '.join(new_variants)} ({now}).", + ] + ) + if comment.returncode != 0: + return new_variants, ( + comment.stderr or comment.stdout or "gh issue comment failed" + ).strip() + return new_variants, None def _storm_label(template_fingerprint: str, seen_payload: dict[str, Any]) -> str: @@ -494,100 +557,188 @@ def _scan_error_issue( return lines now = utcnow() - distinct_templates = {template_fp for _row, _error_id, template_fp, _seen_payload in resolved} - if len(distinct_templates) > storm_threshold: + # One snapshot of local history for the whole scan. Taken before any row is + # marked, so rows written by this scan cannot start a cooldown window + # against the rows behind them. + history = touch_history(store) + + groups: dict[str, list[tuple[dict[str, Any], dict[str, Any]]]] = {} + for row, _error_id, template_fp, seen_payload in resolved: + groups.setdefault(template_fp, []).append((row, seen_payload)) + + # Burst detection counts only templates never filed before. A backlog of + # already-tracked templates draining after downtime is a volume spike, not a + # burst of new problems, and must keep updating its own issues normally. + new_templates = [fp for fp in groups if fp not in history] + if len(new_templates) > storm_threshold: + storm_rows = [ + (row, "", template_fp, seen_payload) + for template_fp in new_templates + for row, seen_payload in groups[template_fp] + ] lines.extend( - _process_storm(store, runner, issue_repo=issue_repo, dry_run=dry_run, resolved=resolved, now=now) + _process_storm( + store, runner, issue_repo=issue_repo, dry_run=dry_run, resolved=storm_rows, now=now + ) ) - return lines + for template_fp in new_templates: + del groups[template_fp] - for row, _error_id, template_fp, seen_payload in resolved: - rid = str(row.get("id") or "?") - excerpt = seen_payload.get("excerpt") - excerpt = excerpt if isinstance(excerpt, str) else "" - variant = extract_variant(excerpt) + for template_fp, members in groups.items(): + lines.extend( + _process_template( + store, + runner, + issue_repo=issue_repo, + dry_run=dry_run, + template_fp=template_fp, + members=members, + now=now, + history=history, + cooldown_minutes=cooldown_minutes, + ) + ) + return lines - if _recently_touched(store, template_fp, now, cooldown_minutes): - result = { - "issue_repo": issue_repo, - "template_fingerprint": template_fp, - "at": now, - "skipped": "cooldown", - } - _mark(store, row, status="done", result=result) + +def _process_template( + store: Store, + runner: Runner, + *, + issue_repo: str, + dry_run: bool, + template_fp: str, + members: list[tuple[dict[str, Any], dict[str, Any]]], + now: str, + history: dict[str, datetime], + cooldown_minutes: int, +) -> list[str]: + """Handle every pending row of one template together. Rows are grouped + because two variants of the same template in one scan belong in one issue: + processing them one by one would make the first a cooldown touch for the + second and silently drop that variant.""" + lines: list[str] = [] + excerpts: list[str] = [] + for _row, seen_payload in members: + excerpt = seen_payload.get("excerpt") + excerpts.append(excerpt if isinstance(excerpt, str) else "") + row_variants = [extract_variant(excerpt) for excerpt in excerpts] + variants: list[str] = [] + for variant in row_variants: + if variant not in variants: + variants.append(variant) + + if _within_cooldown(history, template_fp, now, cooldown_minutes): + for row, _seen_payload in members: + rid = str(row.get("id") or "?") + _mark( + store, + row, + status="done", + result={ + "issue_repo": issue_repo, + "template_fingerprint": template_fp, + "at": now, + "skipped": "cooldown", + }, + ) lines.append(f"error.issue {rid} skipped-cooldown") - continue + return lines - if dry_run: - result = { - "mode": "dry-run", - "issue_repo": issue_repo, - "template_fingerprint": template_fp, - "at": now, - "variant": variant, - "excerpt": excerpt[:300], - } - _mark(store, row, status="done", result=result) + if dry_run: + for index, (row, _seen_payload) in enumerate(members): + rid = str(row.get("id") or "?") + variant = row_variants[index] + _mark( + store, + row, + status="done", + result={ + "mode": "dry-run", + "issue_repo": issue_repo, + "template_fingerprint": template_fp, + "at": now, + "variant": variant, + "excerpt": excerpts[index][:300], + }, + ) lines.append(f"error.issue {rid} dry-run variant={variant}") - continue + return lines - try: - number = find_issue_number(runner, issue_repo, marker_for(template_fp)) - except StoreError as exc: + def fail_all(exc: StoreError) -> list[str]: + for row, _seen_payload in members: + rid = str(row.get("id") or "?") _mark(store, row, status="error", error=str(exc)) lines.append(f"error.issue {rid} error") - continue - - if number is None: - service = seen_payload.get("service") - service = service if isinstance(service, str) and service else "unknown" - cls = seen_payload.get("class") - cls = cls if isinstance(cls, str) and cls else "error" - try: - url = _create_issue( - runner, - issue_repo=issue_repo, - title=f"{service}: {cls}", - template_fingerprint=template_fp, - excerpt=excerpt[:1000], - variant=variant, - now=now, - ) - except StoreError as exc: - _mark(store, row, status="error", error=str(exc)) - lines.append(f"error.issue {rid} error") - continue - result = { - "issue_repo": issue_repo, - "template_fingerprint": template_fp, - "at": now, - "url": url, - "variant": variant, - "created": True, - } - _mark(store, row, status="done", result=result) - lines.append(f"error.issue {rid} created variant={variant}") - continue + return lines + try: + number = find_issue_number(runner, issue_repo, marker_for(template_fp)) + except StoreError as exc: + return fail_all(exc) + + first_payload = members[0][1] + if number is None: + service = first_payload.get("service") + service = service if isinstance(service, str) and service else "unknown" + cls = first_payload.get("class") + cls = cls if isinstance(cls, str) and cls else "error" try: - is_new_variant = _update_issue( - runner, issue_repo=issue_repo, number=number, variant=variant, now=now + url = _create_issue( + runner, + issue_repo=issue_repo, + title=f"{service}: {cls}", + template_fingerprint=template_fp, + excerpt=excerpts[0][:1000], + variants=variants, + now=now, ) except StoreError as exc: - _mark(store, row, status="error", error=str(exc)) - lines.append(f"error.issue {rid} error") - continue - result = { + return fail_all(exc) + for index, (row, _seen_payload) in enumerate(members): + rid = str(row.get("id") or "?") + variant = row_variants[index] + _mark( + store, + row, + status="done", + result={ + "issue_repo": issue_repo, + "template_fingerprint": template_fp, + "at": now, + "url": url, + "variant": variant, + "created": True, + }, + ) + lines.append(f"error.issue {rid} created variant={variant}") + return lines + + try: + new_variants, comment_error = _update_issue( + runner, issue_repo=issue_repo, number=number, variants=variants, now=now + ) + except StoreError as exc: + return fail_all(exc) + + for index, (row, _seen_payload) in enumerate(members): + rid = str(row.get("id") or "?") + variant = row_variants[index] + result: dict[str, Any] = { "issue_repo": issue_repo, "template_fingerprint": template_fp, "at": now, "number": number, "variant": variant, "created": False, - "new_variant": is_new_variant, + "new_variant": variant in new_variants, } + if comment_error is not None: + result["comment_error"] = comment_error[:500] _mark(store, row, status="done", result=result) + suffix = " comment-failed" if comment_error is not None else "" lines.append( - f"error.issue {rid} updated number={number} variant={variant} new_variant={is_new_variant}" + f"error.issue {rid} updated number={number} variant={variant} " + f"new_variant={variant in new_variants}{suffix}" ) return lines diff --git a/tests/test_error_issue_act.py b/tests/test_error_issue_act.py index 351d68a..39ceead 100644 --- a/tests/test_error_issue_act.py +++ b/tests/test_error_issue_act.py @@ -137,14 +137,14 @@ def runner(argv: list[str]) -> Completed: assert argv[:4] == ["gh", "issue", "list", "--repo"] return Completed(0, "[]", "") - assert find_issue_number(runner, "org/intern", "api|error|abc|prod") is None + assert find_issue_number(runner, "org/tracker", "api|error|abc|prod") is None def test_find_issue_number_parses_first_match() -> None: def runner(argv: list[str]) -> Completed: return Completed(0, '[{"number": 42}, {"number": 43}]', "") - assert find_issue_number(runner, "org/intern", "api|error|abc|prod") == 42 + assert find_issue_number(runner, "org/tracker", "api|error|abc|prod") == 42 def test_find_issue_number_raises_on_gh_failure() -> None: @@ -152,7 +152,7 @@ def runner(argv: list[str]) -> Completed: return Completed(1, "", "not found") with pytest.raises(StoreError, match="not found"): - find_issue_number(runner, "org/intern", "api|error|abc|prod") + find_issue_number(runner, "org/tracker", "api|error|abc|prod") # ---- scan_error_issue: dry run ---- @@ -169,7 +169,7 @@ def runner(argv: list[str]) -> Completed: calls.append(list(argv)) return Completed(0, "", "") - lines = scan_error_issue(store, runner, issue_repo="org/intern", dry_run=True) + lines = scan_error_issue(store, runner, issue_repo="org/tracker", dry_run=True) assert calls == [] assert lines == ["error.issue issue-1 dry-run variant=Ethereum"] row = store.row("activity", "issue-1") @@ -191,8 +191,8 @@ def runner(argv: list[str]) -> Completed: calls.append(list(argv)) return Completed(0, "", "") - scan_error_issue(store, runner, issue_repo="org/intern", dry_run=True) - assert scan_error_issue(store, runner, issue_repo="org/intern", dry_run=True) == [] + scan_error_issue(store, runner, issue_repo="org/tracker", dry_run=True) + assert scan_error_issue(store, runner, issue_repo="org/tracker", dry_run=True) == [] assert calls == [] @@ -211,13 +211,13 @@ def runner(argv: list[str]) -> Completed: if argv[:3] == ["gh", "issue", "list"]: return Completed(0, "[]", "") if argv[:3] == ["gh", "issue", "create"]: - return Completed(0, "https://github.com/org/intern/issues/7\n", "") + return Completed(0, "https://github.com/org/tracker/issues/7\n", "") raise AssertionError(f"unexpected call: {argv}") - lines = scan_error_issue(store, runner, issue_repo="org/intern", dry_run=False) + lines = scan_error_issue(store, runner, issue_repo="org/tracker", dry_run=False) assert lines == ["error.issue issue-1 created variant=Ethereum"] create_call = next(c for c in calls if c[:3] == ["gh", "issue", "create"]) - assert "--repo" in create_call and "org/intern" in create_call + assert "--repo" in create_call and "org/tracker" in create_call assert "--label" in create_call and ISSUE_LABEL in create_call body = create_call[create_call.index("--body") + 1] assert marker_for("api|error|abc123|prod") in body @@ -226,7 +226,7 @@ def runner(argv: list[str]) -> Completed: assert row is not None assert row["execution_status"] == "done" assert row["result"]["created"] is True - assert row["result"]["url"] == "https://github.com/org/intern/issues/7" + assert row["result"]["url"] == "https://github.com/org/tracker/issues/7" # ---- scan_error_issue: update path ---- @@ -259,7 +259,7 @@ def runner(argv: list[str]) -> Completed: raise AssertionError("must not comment when the variant already existed") raise AssertionError(f"unexpected call: {argv}") - lines = scan_error_issue(store, runner, issue_repo="org/intern", dry_run=False) + lines = scan_error_issue(store, runner, issue_repo="org/tracker", dry_run=False) assert lines == [ "error.issue issue-1 updated number=9 variant=Ethereum new_variant=False" ] @@ -300,7 +300,7 @@ def runner(argv: list[str]) -> Completed: return Completed(0, "", "") raise AssertionError(f"unexpected call: {argv}") - lines = scan_error_issue(store, runner, issue_repo="org/intern", dry_run=False) + lines = scan_error_issue(store, runner, issue_repo="org/tracker", dry_run=False) assert lines == [ "error.issue issue-1 updated number=9 variant=Polygon new_variant=True" ] @@ -323,7 +323,7 @@ def test_scan_marks_error_when_template_fingerprint_missing(tmp_path: Path) -> N _issue(store) lines = scan_error_issue( - store, lambda _argv: Completed(0, "[]", ""), issue_repo="org/intern", dry_run=False + store, lambda _argv: Completed(0, "[]", ""), issue_repo="org/tracker", dry_run=False ) assert lines == ["error.issue issue-1 error"] row = store.row("activity", "issue-1") @@ -345,7 +345,7 @@ def runner(argv: list[str]) -> Completed: return Completed(1, "", "permission denied") raise AssertionError(f"unexpected call: {argv}") - lines = scan_error_issue(store, runner, issue_repo="org/intern", dry_run=False) + lines = scan_error_issue(store, runner, issue_repo="org/tracker", dry_run=False) assert lines == ["error.issue issue-1 error"] row = store.row("activity", "issue-1") assert row is not None @@ -359,7 +359,7 @@ def test_scan_leaves_non_pending_rows_alone(tmp_path: Path) -> None: _seen(store) _issue(store) scan_error_issue( - store, lambda _argv: Completed(0, "[]", ""), issue_repo="org/intern", dry_run=True + store, lambda _argv: Completed(0, "[]", ""), issue_repo="org/tracker", dry_run=True ) calls: list[list[str]] = [] @@ -367,7 +367,7 @@ def fail_if_called(argv: list[str]) -> Completed: calls.append(list(argv)) return Completed(0, "", "") - assert scan_error_issue(store, fail_if_called, issue_repo="org/intern", dry_run=False) == [] + assert scan_error_issue(store, fail_if_called, issue_repo="org/tracker", dry_run=False) == [] assert calls == [] @@ -380,7 +380,7 @@ def _prior_touch( skipped: bool = False, ) -> None: result: dict[str, object] = { - "issue_repo": "org/intern", + "issue_repo": "org/tracker", "template_fingerprint": template_fingerprint, "at": at, } @@ -500,7 +500,7 @@ def fail_if_called(argv: list[str]) -> Completed: return Completed(0, "", "") lines = scan_error_issue( - store, fail_if_called, issue_repo="org/intern", dry_run=False, cooldown_minutes=60 + store, fail_if_called, issue_repo="org/tracker", dry_run=False, cooldown_minutes=60 ) assert lines == ["error.issue issue-1 skipped-cooldown"] assert calls == [] @@ -526,11 +526,11 @@ def runner(argv: list[str]) -> Completed: if argv[:3] == ["gh", "issue", "list"]: return Completed(0, "[]", "") if argv[:3] == ["gh", "issue", "create"]: - return Completed(0, "https://github.com/org/intern/issues/1\n", "") + return Completed(0, "https://github.com/org/tracker/issues/1\n", "") raise AssertionError(f"unexpected call: {argv}") lines = scan_error_issue( - store, runner, issue_repo="org/intern", dry_run=False, cooldown_minutes=60 + store, runner, issue_repo="org/tracker", dry_run=False, cooldown_minutes=60 ) assert lines == ["error.issue issue-1 created variant=Ethereum"] @@ -550,10 +550,10 @@ def runner(argv: list[str]) -> Completed: if argv[:3] == ["gh", "issue", "list"]: return Completed(0, "[]", "") if argv[:3] == ["gh", "issue", "create"]: - return Completed(0, "https://github.com/org/intern/issues/1\n", "") + return Completed(0, "https://github.com/org/tracker/issues/1\n", "") raise AssertionError(f"unexpected call: {argv}") - lines = scan_error_issue(store, runner, issue_repo="org/intern", dry_run=False, storm_threshold=2) + lines = scan_error_issue(store, runner, issue_repo="org/tracker", dry_run=False, storm_threshold=2) assert len(lines) == 2 assert all("created" in line for line in lines) create_calls = [c for c in calls if c[:3] == ["gh", "issue", "create"]] @@ -572,10 +572,10 @@ def runner(argv: list[str]) -> Completed: if argv[:3] == ["gh", "issue", "list"]: return Completed(0, "[]", "") if argv[:3] == ["gh", "issue", "create"]: - return Completed(0, "https://github.com/org/intern/issues/99\n", "") + return Completed(0, "https://github.com/org/tracker/issues/99\n", "") raise AssertionError(f"unexpected call: {argv}") - lines = scan_error_issue(store, runner, issue_repo="org/intern", dry_run=False, storm_threshold=2) + lines = scan_error_issue(store, runner, issue_repo="org/tracker", dry_run=False, storm_threshold=2) assert len(lines) == 3 assert all("storm" in line for line in lines) create_calls = [c for c in calls if c[:3] == ["gh", "issue", "create"]] @@ -600,7 +600,7 @@ def runner(argv: list[str]) -> Completed: calls.append(list(argv)) return Completed(0, "", "") - lines = scan_error_issue(store, runner, issue_repo="org/intern", dry_run=True, storm_threshold=2) + lines = scan_error_issue(store, runner, issue_repo="org/tracker", dry_run=True, storm_threshold=2) assert calls == [] assert len(lines) == 3 assert all("storm-dry-run" in line for line in lines) @@ -631,7 +631,7 @@ def runner(argv: list[str]) -> Completed: return Completed(0, "", "") raise AssertionError(f"unexpected call: {argv}") - lines = scan_error_issue(store, runner, issue_repo="org/intern", dry_run=False, storm_threshold=2) + lines = scan_error_issue(store, runner, issue_repo="org/tracker", dry_run=False, storm_threshold=2) assert len(lines) == 3 assert [c for c in calls if c[:3] == ["gh", "issue", "create"]] == [] edit_calls = [c for c in calls if c[:3] == ["gh", "issue", "edit"]] @@ -654,10 +654,208 @@ def runner(argv: list[str]) -> Completed: return Completed(1, "", "permission denied") raise AssertionError(f"unexpected call: {argv}") - lines = scan_error_issue(store, runner, issue_repo="org/intern", dry_run=False, storm_threshold=2) + lines = scan_error_issue(store, runner, issue_repo="org/tracker", dry_run=False, storm_threshold=2) assert len(lines) == 3 assert all(line.endswith("error") for line in lines) for i in range(3): row = store.row("activity", f"storm-issue-{i}") assert row is not None assert row["execution_status"] == "error" + + +# ---- one template, several pending rows in one scan ---- + + +def test_scan_merges_two_variants_of_one_template_into_one_issue(tmp_path: Path) -> None: + """Two variants of the same template in one scan belong in one issue. Handled + row by row, the first would become a cooldown touch for the second and that + variant would be dropped.""" + store = Store(tmp_path) + _runner_session(store) + _seen( + store, + template_fingerprint="api|error|same|prod", + excerpt="Balance for Arbitrum/USDC went low", + activity_id="seen-a", + ) + _seen( + store, + template_fingerprint="api|error|same|prod", + excerpt="Balance for Arbitrum/WBTC went low", + activity_id="seen-b", + ) + _issue(store, error_id="seen-a", activity_id="issue-a") + _issue(store, error_id="seen-b", activity_id="issue-b") + calls: list[list[str]] = [] + + def runner(argv: list[str]) -> Completed: + calls.append(list(argv)) + if argv[:3] == ["gh", "issue", "list"]: + return Completed(0, "[]", "") + if argv[:3] == ["gh", "issue", "create"]: + return Completed(0, "https://github.com/org/tracker/issues/7\n", "") + raise AssertionError(f"unexpected call: {argv}") + + lines = scan_error_issue( + store, runner, issue_repo="org/tracker", dry_run=False, cooldown_minutes=60 + ) + assert lines == [ + "error.issue issue-a created variant=Arbitrum/USDC", + "error.issue issue-b created variant=Arbitrum/WBTC", + ] + create_calls = [c for c in calls if c[:3] == ["gh", "issue", "create"]] + assert len(create_calls) == 1 + body = create_calls[0][create_calls[0].index("--body") + 1] + assert "Arbitrum/USDC" in body + assert "Arbitrum/WBTC" in body + + +def test_scan_comments_once_for_several_new_variants(tmp_path: Path) -> None: + store = Store(tmp_path) + _runner_session(store) + _seen( + store, + template_fingerprint="api|error|same|prod", + excerpt="Balance for Arbitrum/USDC went low", + activity_id="seen-a", + ) + _seen( + store, + template_fingerprint="api|error|same|prod", + excerpt="Balance for Base/WBTC went low", + activity_id="seen-b", + ) + _issue(store, error_id="seen-a", activity_id="issue-a") + _issue(store, error_id="seen-b", activity_id="issue-b") + + def runner(argv: list[str]) -> Completed: + if argv[:3] == ["gh", "issue", "list"]: + return Completed(0, '[{"number": 12}]', "") + if argv[:3] == ["gh", "issue", "view"]: + return Completed(0, '{"body": "text\\n"}', "") + return Completed(0, "", "") + + calls: list[list[str]] = [] + + def recording(argv: list[str]) -> Completed: + calls.append(list(argv)) + return runner(argv) + + scan_error_issue(store, recording, issue_repo="org/tracker", dry_run=False) + comments = [c for c in calls if c[:3] == ["gh", "issue", "comment"]] + assert len(comments) == 1 + body = comments[0][comments[0].index("--body") + 1] + assert "Arbitrum/USDC" in body + assert "Base/WBTC" in body + + +def test_scan_keeps_the_edit_when_the_comment_fails(tmp_path: Path) -> None: + """The edit is the durable record. Failing the row on a comment error would + strand a variant that is already in the table and can never be re-announced, + because a retry no longer sees it as new.""" + store = Store(tmp_path) + _runner_session(store) + _seen(store) + _issue(store) + + def runner(argv: list[str]) -> Completed: + if argv[:3] == ["gh", "issue", "list"]: + return Completed(0, '[{"number": 12}]', "") + if argv[:3] == ["gh", "issue", "view"]: + return Completed(0, '{"body": "text\\n"}', "") + if argv[:3] == ["gh", "issue", "comment"]: + return Completed(1, "", "rate limited") + return Completed(0, "", "") + + lines = scan_error_issue(store, runner, issue_repo="org/tracker", dry_run=False) + assert lines == [ + "error.issue issue-1 updated number=12 variant=Ethereum new_variant=True comment-failed" + ] + row = store.row("activity", "issue-1") + assert row is not None + assert row["execution_status"] == "done" + assert row["result"]["comment_error"] == "rate limited" + + +# ---- dry run must not open a cooldown window ---- + + +def test_dry_run_does_not_start_a_cooldown_window(tmp_path: Path) -> None: + store = Store(tmp_path) + _runner_session(store) + _seen(store) + _issue(store) + assert scan_error_issue( + store, lambda _argv: Completed(0, "[]", ""), issue_repo="org/tracker", dry_run=True + ) == ["error.issue issue-1 dry-run variant=Ethereum"] + + _issue(store, activity_id="issue-2") + calls: list[list[str]] = [] + + def runner(argv: list[str]) -> Completed: + calls.append(list(argv)) + if argv[:3] == ["gh", "issue", "list"]: + return Completed(0, "[]", "") + return Completed(0, "https://github.com/org/tracker/issues/1\n", "") + + lines = scan_error_issue( + store, runner, issue_repo="org/tracker", dry_run=False, cooldown_minutes=60 + ) + assert lines == ["error.issue issue-2 created variant=Ethereum"] + assert calls != [] + + +# ---- burst detection counts only templates never filed before ---- + + +def test_storm_threshold_ignores_already_tracked_templates(tmp_path: Path) -> None: + """A backlog of known templates draining after downtime is a volume spike, + not a burst of new problems, and must keep updating its own issues.""" + store = Store(tmp_path) + _runner_session(store) + for i in range(3): + _seen_and_issue(store, index=i, template_fingerprint=f"api|error|t{i}|prod") + for i in range(3): + _prior_touch( + store, + template_fingerprint=f"api|error|t{i}|prod", + at="2026-08-30T10:00:00Z", + activity_id=f"prior-{i}", + ) + + def runner(argv: list[str]) -> Completed: + if argv[:3] == ["gh", "issue", "list"]: + return Completed(0, "[]", "") + if argv[:3] == ["gh", "issue", "create"]: + return Completed(0, "https://github.com/org/tracker/issues/1\n", "") + raise AssertionError(f"unexpected call: {argv}") + + lines = scan_error_issue( + store, + runner, + issue_repo="org/tracker", + dry_run=False, + storm_threshold=2, + cooldown_minutes=0, + ) + assert len(lines) == 3 + assert all(line.endswith("created variant=generic") for line in lines) + for i in range(3): + row = store.row("activity", f"storm-issue-{i}") + assert row is not None + assert row["result"].get("mode") != "storm" + + +# ---- damaged variants section ---- + + +def test_splice_variants_refuses_a_half_open_section() -> None: + damaged = "Human notes.\n\n\n\n| variant | first seen | last seen |\n" + with pytest.raises(StoreError): + splice_variants(damaged, {"Ethereum": {"first_seen": "t1", "last_seen": "t1"}}) + + +def test_splice_variants_refuses_a_body_over_the_github_limit() -> None: + variants = {f"chain-{i}": {"first_seen": "t1", "last_seen": "t1"} for i in range(6000)} + with pytest.raises(StoreError): + splice_variants("Human notes.\n", variants) From be5c0c4bc39093a2c51a8d78273ba6b90e58b3aa Mon Sep 17 00:00:00 2001 From: Danswar <48102227+Danswar@users.noreply.github.com> Date: Mon, 31 Aug 2026 21:54:47 -0300 Subject: [PATCH 07/43] Document the error.issue activity type and its deferred dispatch. --- DESIGN.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/DESIGN.md b/DESIGN.md index 661c739..16fbea1 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -332,6 +332,7 @@ v1 types (mechanism only): | `error.seen` | `error` | script | — (`NOTIFY` `agent_inbox`; error-fix skill, §21) | | `error.skip` | `error` | AI | — | | `error.fix` | `error` | AI | script + spine implement (draft pull request) | +| `error.issue` | `error` | AI | script (`error_issue_act`, §21.6) — scaffolded only: not yet a member of the `agent activity add` allowlist and not yet dispatched by a watch command | | `supervise.event` | `supervise` | script | — (supervise follow bookkeeping / approve; optional closed-question path in tests; skip rows may carry a truncated pane excerpt; no TUI knock) | `investigate` is the thick log: hypothesis, check, result, ruled out, still open — each a new row, at once. Other sessions can query or subscribe and see what was already tried. @@ -668,6 +669,25 @@ The model never receives production credentials. Analysis that only reads the ex ### 21.6 Not in this revision - A second hub state machine, leases, or autonomous merge +- Dispatch for `error.issue`. The grouping and throttling logic ships as + `error_issue_act` with its tests, but the type is deliberately not yet in the + `agent activity add` allowlist and no watch command calls the scan, so nothing + files an issue yet. Wiring it means adding the type to that allowlist, an + `agent watch error-issue` one-scan command next to `agent watch error-fix`, and + `issue_repo` / `dry_run` / `cooldown_minutes` / `storm_threshold` in + `error-fix.json`. + +`error.issue` groups by `template_fingerprint` (§21.3 `fingerprint` with known +blockchain names and asset tickers masked), not by the per-variant +`fingerprint`, so one issue covers every chain/token variant of one error. Names +are masked only as whole tokens and case-sensitively: "Base" inside "Based" or +"SOL" inside "RESOLVE" is prose, not a chain or a ticker. The concrete variants +live in a machine-owned, delimited section of the issue body; nothing outside +that section is ever rewritten, and a body carrying only one of the two markers +is treated as damaged rather than appended to. Two throttles sit in front: +a burst fold, counted over templates never filed before so a backlog draining +after downtime is not mistaken for a burst, and a per-template cooldown read +from local history — a dry run is a preview and never opens that window. ## 22. Static supervise loop (v1) From e02e66ff3dd71dca4d97d1fac427cfe3c41a508e Mon Sep 17 00:00:00 2001 From: Danswar <48102227+Danswar@users.noreply.github.com> Date: Mon, 31 Aug 2026 22:08:45 -0300 Subject: [PATCH 08/43] Apply the issue body ceiling on the create paths too. --- src/agent_cli/error_issue_act.py | 16 +++++-- tests/test_error_issue_act.py | 79 ++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 5 deletions(-) diff --git a/src/agent_cli/error_issue_act.py b/src/agent_cli/error_issue_act.py index 8dba922..428b98f 100644 --- a/src/agent_cli/error_issue_act.py +++ b/src/agent_cli/error_issue_act.py @@ -141,6 +141,14 @@ def parse_variants_section(body: str) -> dict[str, dict[str, str]]: return variants +def _ensure_issue_body(body: str) -> str: + """One ceiling for every body this module sends to gh, whether it is spliced + into an existing issue or built for a new one.""" + if len(body) > MAX_ISSUE_BODY: + raise StoreError("issue body would exceed the GitHub body limit") + return body + + def splice_variants(body: str, variants: dict[str, dict[str, str]]) -> str: """Replace only the delimited variants section; never touch the rest of the body — that is human territory. A body carrying exactly one of the two @@ -157,9 +165,7 @@ def splice_variants(body: str, variants: dict[str, dict[str, str]]) -> str: raise StoreError("issue body has a damaged variants section") else: spliced = body[:start] + section + body[end + len(_VARIANTS_END) :] - if len(spliced) > MAX_ISSUE_BODY: - raise StoreError("issue body would exceed the GitHub body limit") - return spliced + return _ensure_issue_body(spliced) def _parse_iso(ts: str) -> datetime: @@ -308,7 +314,7 @@ def _create_issue( variants: list[str], now: str, ) -> str: - body = ( + body = _ensure_issue_body( f"{marker_for(template_fingerprint)}\n\n" "Automated error-log finding.\n\n" f"```\n{excerpt}\n```\n\n" @@ -395,7 +401,7 @@ def _storm_label(template_fingerprint: str, seen_payload: dict[str, Any]) -> str def _create_storm_issue( runner: Runner, *, issue_repo: str, templates: dict[str, dict[str, str]], now: str ) -> str: - body = ( + body = _ensure_issue_body( f"{STORM_MARKER}\n\n" "Automated burst finding: this run saw more distinct new error templates " "than usual in one pass. That is more likely one shared root cause than " diff --git a/tests/test_error_issue_act.py b/tests/test_error_issue_act.py index 39ceead..f7f0ad5 100644 --- a/tests/test_error_issue_act.py +++ b/tests/test_error_issue_act.py @@ -6,7 +6,10 @@ from agent_cli.error_issue_act import ( ISSUE_LABEL, + MAX_ISSUE_BODY, STORM_MARKER, + _create_issue, + _create_storm_issue, _recently_touched, extract_variant, find_issue_number, @@ -15,6 +18,7 @@ render_variants_section, scan_error_issue, splice_variants, + touch_history, ) from agent_cli.runtime import Completed from agent_cli.store import Store, StoreError, utcnow @@ -859,3 +863,78 @@ def test_splice_variants_refuses_a_body_over_the_github_limit() -> None: variants = {f"chain-{i}": {"first_seen": "t1", "last_seen": "t1"} for i in range(6000)} with pytest.raises(StoreError): splice_variants("Human notes.\n", variants) + + +def test_create_paths_apply_the_same_body_ceiling() -> None: + """The ceiling is a property of every body sent to gh, not just of a splice + into an existing issue.""" + huge = "x" * (MAX_ISSUE_BODY + 1) + with pytest.raises(StoreError): + _create_issue( + _unreachable_runner, + issue_repo="org/tracker", + title="api: error", + template_fingerprint="api|error|abc|prod", + excerpt=huge, + variants=["generic"], + now="2026-08-31T10:00:00Z", + ) + templates = {f"t-{i}": {"first_seen": "t1", "last_seen": "t1"} for i in range(6000)} + with pytest.raises(StoreError): + _create_storm_issue( + _unreachable_runner, + issue_repo="org/tracker", + templates=templates, + now="2026-08-31T10:00:00Z", + ) + + +def _unreachable_runner(argv: list[str]) -> Completed: + raise AssertionError(f"gh must not be called: {argv}") + + +# ---- touch history ---- + + +def test_touch_history_keeps_the_newest_touch_per_template(tmp_path: Path) -> None: + store = Store(tmp_path) + _runner_session(store) + _prior_touch( + store, + template_fingerprint="api|error|abc|prod", + at="2026-08-31T10:00:00Z", + activity_id="prior-old", + ) + _prior_touch( + store, + template_fingerprint="api|error|abc|prod", + at="2026-08-31T12:00:00Z", + activity_id="prior-new", + ) + history = touch_history(store) + assert history["api|error|abc|prod"].isoformat() == "2026-08-31T12:00:00+00:00" + + +def test_touch_history_ignores_unusable_rows(tmp_path: Path) -> None: + store = Store(tmp_path) + _runner_session(store) + for activity_id, result in ( + ("bad-1", "not-a-dict"), + ("bad-2", {"template_fingerprint": "api|error|abc|prod"}), # no "at" + ("bad-3", {"template_fingerprint": "api|error|abc|prod", "at": "not-a-date"}), + ("bad-4", {"at": "2026-08-31T10:00:00Z"}), # no template_fingerprint + ): + store.write( + "activity", + "insert", + activity_id, + { + "id": activity_id, + "session_id": "runner-1", + "type": "error.issue", + "payload": {"error_id": "irrelevant"}, + "execution_status": "done", + "result": result, + }, + ) + assert touch_history(store) == {} From e54649ca673451e84bbd0df8dc8056fe94abbfbb Mon Sep 17 00:00:00 2001 From: Danswar <48102227+Danswar@users.noreply.github.com> Date: Mon, 31 Aug 2026 22:08:47 -0300 Subject: [PATCH 09/43] Document template_fingerprint in the error.seen payload shape. --- DESIGN.md | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 16fbea1..6d72609 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -622,6 +622,7 @@ Log lines, stack traces, and error messages are untrusted data (§19.2). They ar ```json { "fingerprint": "service|class|stack-sig|env", + "template_fingerprint": "service|class|template-sig|env", "service": "api", "environment": "prod", "class": "TimeoutError", @@ -635,6 +636,13 @@ Log lines, stack traces, and error messages are untrusted data (§19.2). They ar } ``` +`template_fingerprint` is `fingerprint` one step coarser: known blockchain names and +asset tickers are masked before hashing, so per-chain and per-token variants of one +error share it. Names are masked only as whole tokens and case-sensitively, so prose +like "Based" or lowercase "usd" is not mistaken for a chain or a ticker. It groups +which issue a variant belongs to (§21.6); `fingerprint` stays the finer-grained +identity used for `count` / `last_seen`, so per-variant dedup remains exact. + `repo` may be omitted when the adapter cannot map the stream; the session then `error.skip`s with reason `unmapped-repo`. `line_fingerprint` is optional: `sha256(server + newline + container + newline + exact line)` as 64 lowercase hex, computed from the raw line before redaction. Omit it when `server` or `container` is missing. Host adapters may print the hex on `error.fix` stdout; it is not a mandate and not a log-host name. ### 21.4 Analysis and eligibility @@ -677,17 +685,14 @@ The model never receives production credentials. Analysis that only reads the ex `issue_repo` / `dry_run` / `cooldown_minutes` / `storm_threshold` in `error-fix.json`. -`error.issue` groups by `template_fingerprint` (§21.3 `fingerprint` with known -blockchain names and asset tickers masked), not by the per-variant -`fingerprint`, so one issue covers every chain/token variant of one error. Names -are masked only as whole tokens and case-sensitively: "Base" inside "Based" or -"SOL" inside "RESOLVE" is prose, not a chain or a ticker. The concrete variants -live in a machine-owned, delimited section of the issue body; nothing outside -that section is ever rewritten, and a body carrying only one of the two markers -is treated as damaged rather than appended to. Two throttles sit in front: -a burst fold, counted over templates never filed before so a backlog draining -after downtime is not mistaken for a burst, and a per-template cooldown read -from local history — a dry run is a preview and never opens that window. +`error.issue` groups by `template_fingerprint` (§21.3), not by the per-variant +`fingerprint`, so one issue covers every chain/token variant of one error. The +concrete variants live in a machine-owned, delimited section of the issue body; +nothing outside that section is ever rewritten, and a body carrying only one of +the two markers is treated as damaged rather than appended to. Two throttles sit +in front: a burst fold, counted over templates never filed before so a backlog +draining after downtime is not mistaken for a burst, and a per-template cooldown +read from local history — a dry run is a preview and never opens that window. ## 22. Static supervise loop (v1) From 5be2222ff21185e0be1cd7487b44b8a18777470c Mon Sep 17 00:00:00 2001 From: Danswar <48102227+Danswar@users.noreply.github.com> Date: Mon, 31 Aug 2026 22:30:59 -0300 Subject: [PATCH 10/43] Match the issue marker against the body instead of trusting gh search. --- src/agent_cli/error_issue_act.py | 174 +++++++++++++++++++------------ tests/test_error_issue_act.py | 93 +++++++++++++---- 2 files changed, 177 insertions(+), 90 deletions(-) diff --git a/src/agent_cli/error_issue_act.py b/src/agent_cli/error_issue_act.py index 428b98f..deb7480 100644 --- a/src/agent_cli/error_issue_act.py +++ b/src/agent_cli/error_issue_act.py @@ -228,14 +228,6 @@ def _within_cooldown( return now_dt - touched_dt < timedelta(minutes=cooldown_minutes) -def _recently_touched( - store: Store, template_fingerprint: str, now: str, cooldown_minutes: int -) -> bool: - """Single-template convenience over touch_history/_within_cooldown.""" - return _within_cooldown( - touch_history(store), template_fingerprint, now, cooldown_minutes - ) - def _pending_issue(store: Store, row: dict[str, Any]) -> tuple[str, str, dict[str, Any]]: payload = row.get("payload") @@ -257,10 +249,39 @@ def _pending_issue(store: Store, row: dict[str, Any]) -> tuple[str, str, dict[st return error_id, template_fp, seen_payload +def _gh(runner: Runner, argv: list[str], fallback: str) -> str: + """Run one gh command and return its stdout. A missing or broken binary + raises the same StoreError as a non-zero exit, so it stays a per-row failure + instead of aborting the whole scan (same contract as error_fix_act and + github_act).""" + try: + completed = runner(argv) + except OSError as exc: + raise StoreError(f"{fallback}: {exc}") from exc + if completed.returncode != 0: + raise StoreError((completed.stderr or completed.stdout or fallback).strip()) + return completed.stdout + + +def _gh_json(runner: Runner, argv: list[str], fallback: str) -> Any: + stdout = _gh(runner, argv, fallback) + try: + return json.loads(stdout) + except ValueError as exc: + raise StoreError(f"{fallback}: invalid JSON") from exc + + def find_issue_number(runner: Runner, issue_repo: str, marker: str) -> int | None: - """Search issue_repo for an open, labeled issue carrying this marker. None - if no such issue exists yet.""" - completed = runner( + """The open, labeled issue whose body carries this marker, or None if none + exists yet. + + The label bounds the candidates and the marker is matched against the body + here rather than handed to `--search`: GitHub's search is full-text and + tokenizing, so it can both miss the marker and return an issue that does not + carry it, and the returned candidate's body would never be checked. This + mirrors the find-or-create in github_act.""" + listed = _gh_json( + runner, [ "gh", "issue", @@ -271,36 +292,38 @@ def find_issue_number(runner: Runner, issue_repo: str, marker: str) -> int | Non ISSUE_LABEL, "--state", "open", - "--search", - marker, + "--limit", + "100", "--json", - "number", - ] + "number,body", + ], + "gh issue list failed", ) - if completed.returncode != 0: - raise StoreError((completed.stderr or completed.stdout or "gh issue list failed").strip()) - try: - found = json.loads(completed.stdout) - except ValueError as exc: - raise StoreError("gh issue list returned invalid JSON") from exc - if not isinstance(found, list) or not found: - return None - first = found[0] - number = first.get("number") if isinstance(first, dict) else None - if isinstance(number, bool) or not isinstance(number, int): - raise StoreError("gh issue list returned a non-integer number") - return number + if not isinstance(listed, list): + raise StoreError("gh issue list is not an array") + for issue in listed: + if not isinstance(issue, dict): + continue + body = issue.get("body") + if not isinstance(body, str) or marker not in body: + continue + number = issue.get("number") + if isinstance(number, bool) or not isinstance(number, int): + raise StoreError("gh issue list returned a non-integer number") + return number + return None def _issue_body(runner: Runner, issue_repo: str, number: int) -> str: - completed = runner(["gh", "issue", "view", str(number), "--repo", issue_repo, "--json", "body"]) - if completed.returncode != 0: - raise StoreError((completed.stderr or completed.stdout or "gh issue view failed").strip()) - try: - data = json.loads(completed.stdout) - except ValueError as exc: - raise StoreError("gh issue view returned invalid JSON") from exc - body = data.get("body") if isinstance(data, dict) else None + data = _gh_json( + runner, + ["gh", "issue", "view", str(number), "--repo", issue_repo, "--json", "body"], + "gh issue view failed", + ) + if not isinstance(data, dict): + raise StoreError("gh issue view did not return an object") + body = data.get("body") + # An issue that never had a description reports null; that is genuinely empty. return body if isinstance(body, str) else "" @@ -321,7 +344,8 @@ def _create_issue( + render_variants_section({v: {"first_seen": now, "last_seen": now} for v in variants}) + "\n" ) - completed = runner( + return _gh( + runner, [ "gh", "issue", @@ -334,11 +358,9 @@ def _create_issue( title, "--body", body, - ] - ) - if completed.returncode != 0: - raise StoreError((completed.stderr or completed.stdout or "gh issue create failed").strip()) - return completed.stdout.strip() + ], + "gh issue create failed", + ).strip() def _update_issue( @@ -362,29 +384,39 @@ def _update_issue( tracked[variant]["last_seen"] = now else: tracked[variant] = {"first_seen": now, "last_seen": now} - edit = runner( - ["gh", "issue", "edit", str(number), "--repo", issue_repo, "--body", splice_variants(body, tracked)] - ) - if edit.returncode != 0: - raise StoreError((edit.stderr or edit.stdout or "gh issue edit failed").strip()) - if not new_variants: - return new_variants, None - comment = runner( + _gh( + runner, [ "gh", "issue", - "comment", + "edit", str(number), "--repo", issue_repo, "--body", - f"Also seen on: {', '.join(new_variants)} ({now}).", - ] + splice_variants(body, tracked), + ], + "gh issue edit failed", ) - if comment.returncode != 0: - return new_variants, ( - comment.stderr or comment.stdout or "gh issue comment failed" - ).strip() + if not new_variants: + return new_variants, None + try: + _gh( + runner, + [ + "gh", + "issue", + "comment", + str(number), + "--repo", + issue_repo, + "--body", + f"Also seen on: {', '.join(new_variants)} ({now}).", + ], + "gh issue comment failed", + ) + except StoreError as exc: + return new_variants, str(exc) return new_variants, None @@ -410,7 +442,8 @@ def _create_storm_issue( + render_variants_section(templates) + "\n" ) - completed = runner( + return _gh( + runner, [ "gh", "issue", @@ -423,11 +456,9 @@ def _create_storm_issue( f"Error-log burst: {len(templates)} new templates in one run", "--body", body, - ] - ) - if completed.returncode != 0: - raise StoreError((completed.stderr or completed.stdout or "gh issue create failed").strip()) - return completed.stdout.strip() + ], + "gh issue create failed", + ).strip() def _update_storm_issue( @@ -440,11 +471,20 @@ def _update_storm_issue( existing[name]["last_seen"] = entry["last_seen"] else: existing[name] = dict(entry) - edit = runner( - ["gh", "issue", "edit", str(number), "--repo", issue_repo, "--body", splice_variants(body, existing)] + _gh( + runner, + [ + "gh", + "issue", + "edit", + str(number), + "--repo", + issue_repo, + "--body", + splice_variants(body, existing), + ], + "gh issue edit failed", ) - if edit.returncode != 0: - raise StoreError((edit.stderr or edit.stdout or "gh issue edit failed").strip()) def _process_storm( diff --git a/tests/test_error_issue_act.py b/tests/test_error_issue_act.py index f7f0ad5..5beb1b9 100644 --- a/tests/test_error_issue_act.py +++ b/tests/test_error_issue_act.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json from pathlib import Path import pytest @@ -10,7 +11,7 @@ STORM_MARKER, _create_issue, _create_storm_issue, - _recently_touched, + _within_cooldown, extract_variant, find_issue_number, marker_for, @@ -144,11 +145,38 @@ def runner(argv: list[str]) -> Completed: assert find_issue_number(runner, "org/tracker", "api|error|abc|prod") is None -def test_find_issue_number_parses_first_match() -> None: +def test_find_issue_number_matches_the_marker_in_the_body() -> None: def runner(argv: list[str]) -> Completed: - return Completed(0, '[{"number": 42}, {"number": 43}]', "") + assert "--search" not in argv + return Completed( + 0, + json.dumps( + [ + {"number": 42, "body": "another template "}, + {"number": 43, "body": "carries api|error|abc|prod here"}, + ] + ), + "", + ) + + assert find_issue_number(runner, "org/tracker", "api|error|abc|prod") == 43 - assert find_issue_number(runner, "org/tracker", "api|error|abc|prod") == 42 + +def test_find_issue_number_ignores_issues_without_the_marker() -> None: + """A labeled issue that search might surface but that does not carry this + template's marker must not be adopted as its issue.""" + def runner(argv: list[str]) -> Completed: + return Completed(0, json.dumps([{"number": 42, "body": "unrelated"}]), "") + + assert find_issue_number(runner, "org/tracker", "api|error|abc|prod") is None + + +def test_find_issue_number_raises_when_gh_is_missing() -> None: + def runner(argv: list[str]) -> Completed: + raise OSError("No such file or directory: 'gh'") + + with pytest.raises(StoreError, match="gh issue list failed"): + find_issue_number(runner, "org/tracker", "api|error|abc|prod") def test_find_issue_number_raises_on_gh_failure() -> None: @@ -252,10 +280,12 @@ def test_scan_updates_existing_issue_same_variant_no_comment(tmp_path: Path) -> def runner(argv: list[str]) -> Completed: calls.append(list(argv)) if argv[:3] == ["gh", "issue", "list"]: - return Completed(0, '[{"number": 9}]', "") + return Completed( + 0, + json.dumps([{"number": 9, "body": marker_for("api|error|abc123|prod")}]), + "", + ) if argv[:3] == ["gh", "issue", "view"]: - import json - return Completed(0, json.dumps({"body": existing_body}), "") if argv[:3] == ["gh", "issue", "edit"]: return Completed(0, "", "") @@ -295,10 +325,12 @@ def test_scan_updates_existing_issue_new_variant_posts_comment(tmp_path: Path) - def runner(argv: list[str]) -> Completed: calls.append(list(argv)) if argv[:3] == ["gh", "issue", "list"]: - return Completed(0, '[{"number": 9}]', "") + return Completed( + 0, + json.dumps([{"number": 9, "body": marker_for("api|error|abc123|prod")}]), + "", + ) if argv[:3] == ["gh", "issue", "view"]: - import json - return Completed(0, json.dumps({"body": existing_body}), "") if argv[:3] in (["gh", "issue", "edit"], ["gh", "issue", "comment"]): return Completed(0, "", "") @@ -445,13 +477,13 @@ def _seen_and_issue( # ---- cooldown ---- -def test_recently_touched_false_with_no_history(tmp_path: Path) -> None: +def test_cooldown_false_with_no_history(tmp_path: Path) -> None: store = Store(tmp_path) _runner_session(store) - assert _recently_touched(store, "api|error|abc|prod", utcnow(), 60) is False + assert _within_cooldown(touch_history(store), "api|error|abc|prod", utcnow(), 60) is False -def test_recently_touched_true_within_window(tmp_path: Path) -> None: +def test_cooldown_true_within_window(tmp_path: Path) -> None: store = Store(tmp_path) _runner_session(store) _prior_touch( @@ -460,10 +492,13 @@ def test_recently_touched_true_within_window(tmp_path: Path) -> None: at="2026-08-31T10:00:00Z", activity_id="prior-1", ) - assert _recently_touched(store, "api|error|abc|prod", "2026-08-31T10:30:00Z", 60) is True + assert ( + _within_cooldown(touch_history(store), "api|error|abc|prod", "2026-08-31T10:30:00Z", 60) + is True + ) -def test_recently_touched_false_after_expiry(tmp_path: Path) -> None: +def test_cooldown_false_after_expiry(tmp_path: Path) -> None: store = Store(tmp_path) _runner_session(store) _prior_touch( @@ -472,10 +507,13 @@ def test_recently_touched_false_after_expiry(tmp_path: Path) -> None: at="2026-08-31T10:00:00Z", activity_id="prior-1", ) - assert _recently_touched(store, "api|error|abc|prod", "2026-08-31T11:30:00Z", 60) is False + assert ( + _within_cooldown(touch_history(store), "api|error|abc|prod", "2026-08-31T11:30:00Z", 60) + is False + ) -def test_recently_touched_ignores_skipped_results(tmp_path: Path) -> None: +def test_cooldown_ignores_skipped_results(tmp_path: Path) -> None: store = Store(tmp_path) _runner_session(store) _prior_touch( @@ -486,7 +524,10 @@ def test_recently_touched_ignores_skipped_results(tmp_path: Path) -> None: skipped=True, ) # A skip-only history must not itself extend the cooldown window. - assert _recently_touched(store, "api|error|abc|prod", "2026-08-31T10:30:00Z", 60) is False + assert ( + _within_cooldown(touch_history(store), "api|error|abc|prod", "2026-08-31T10:30:00Z", 60) + is False + ) def test_scan_skips_recently_touched_template_without_gh_calls(tmp_path: Path) -> None: @@ -626,10 +667,8 @@ def test_scan_storm_reuses_existing_open_storm_issue(tmp_path: Path) -> None: def runner(argv: list[str]) -> Completed: calls.append(list(argv)) if argv[:3] == ["gh", "issue", "list"]: - return Completed(0, '[{"number": 55}]', "") + return Completed(0, json.dumps([{"number": 55, "body": STORM_MARKER}]), "") if argv[:3] == ["gh", "issue", "view"]: - import json - return Completed(0, json.dumps({"body": existing_body}), "") if argv[:3] == ["gh", "issue", "edit"]: return Completed(0, "", "") @@ -734,7 +773,11 @@ def test_scan_comments_once_for_several_new_variants(tmp_path: Path) -> None: def runner(argv: list[str]) -> Completed: if argv[:3] == ["gh", "issue", "list"]: - return Completed(0, '[{"number": 12}]', "") + return Completed( + 0, + json.dumps([{"number": 12, "body": marker_for("api|error|same|prod")}]), + "", + ) if argv[:3] == ["gh", "issue", "view"]: return Completed(0, '{"body": "text\\n"}', "") return Completed(0, "", "") @@ -764,7 +807,11 @@ def test_scan_keeps_the_edit_when_the_comment_fails(tmp_path: Path) -> None: def runner(argv: list[str]) -> Completed: if argv[:3] == ["gh", "issue", "list"]: - return Completed(0, '[{"number": 12}]', "") + return Completed( + 0, + json.dumps([{"number": 12, "body": marker_for("api|error|abc123|prod")}]), + "", + ) if argv[:3] == ["gh", "issue", "view"]: return Completed(0, '{"body": "text\\n"}', "") if argv[:3] == ["gh", "issue", "comment"]: From 752892db2609719b7939cd3815404f575c6bcb10 Mon Sep 17 00:00:00 2001 From: Danswar <48102227+Danswar@users.noreply.github.com> Date: Mon, 31 Aug 2026 22:31:01 -0300 Subject: [PATCH 11/43] Describe the masked names by what the platform exposes. --- src/agent_cli/errors.py | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/src/agent_cli/errors.py b/src/agent_cli/errors.py index 774e392..2ed440d 100644 --- a/src/agent_cli/errors.py +++ b/src/agent_cli/errors.py @@ -42,11 +42,11 @@ _UUID = re.compile( r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}" ) -# Blockchain names from DFX's own asset/blockchain enum (prod DB, checked -# 2026-08-31) — masked in the template signature so a per-chain error -# variant ("Timeout updating balances for Ethereum" vs "...for Polygon") groups -# under one issue-filing template instead of fragmenting one issue per chain. -# Refresh from prod if this drifts; there is no automated sync. +# The blockchain and payment-rail names the platform already exposes as transfer +# options, current as of 2026-08-31 — masked in the template signature so a +# per-chain error variant ("Timeout updating balances for Ethereum" vs "...for +# Polygon") groups under one issue-filing template instead of fragmenting one +# issue per chain. Refresh when that list changes; there is no automated sync. _KNOWN_CHAINS = frozenset( { "DeFiChain", "Ethereum", "Arbitrum", "Polygon", "BinanceSmartChain", "Binance", @@ -74,14 +74,13 @@ def _token_pattern(names: frozenset[str]) -> re.Pattern[str]: _CHAIN_TOKEN = _token_pattern(_KNOWN_CHAINS) -# Asset tickers from DFX's own asset enum (prod DB, checked 2026-08-31), masked the -# same way as chains — e.g. "Balance for Arbitrum/USDC went..." vs ".../WBTC went..." -# would otherwise stay separate templates. Kept to tickers seen on 2+ chains (a -# defensible cut against one-off/legacy DeFiChain stock-tokenization artifacts like -# "dAAPL" or internal numeric-ID-prefixed rows), plus two single-chain tickers -# (GMX, TGT) confirmed present in real production balance-check errors that day — -# the 2+-chains cut alone would otherwise have missed them. Refresh from prod if -# this drifts; there is no automated sync. +# Asset tickers the platform lists, current as of 2026-08-31, masked the same way +# as chains — e.g. "Balance for Arbitrum/USDC went..." vs ".../WBTC went..." would +# otherwise stay separate templates. Kept to tickers available on 2+ chains (a +# defensible cut against one-off and legacy stock-tokenization artifacts), plus two +# single-chain tickers (GMX, TGT) that the 2+-chains cut would otherwise have +# missed even though real balance-check errors named them. Refresh when the listed +# assets change; there is no automated sync. _KNOWN_ASSETS = frozenset( { "1INCH", "AAVE", "ADA", "APE", "ARB", "AXS", From d2b80d84ac834ee75e2518f41c78de1dc8eb5b89 Mon Sep 17 00:00:00 2001 From: Danswar <48102227+Danswar@users.noreply.github.com> Date: Mon, 31 Aug 2026 22:38:37 -0300 Subject: [PATCH 12/43] Fail loud when the issue list is truncated. --- src/agent_cli/error_issue_act.py | 9 ++++++++- tests/test_error_issue_act.py | 25 +++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/agent_cli/error_issue_act.py b/src/agent_cli/error_issue_act.py index deb7480..f19b799 100644 --- a/src/agent_cli/error_issue_act.py +++ b/src/agent_cli/error_issue_act.py @@ -50,6 +50,9 @@ # loudly, so a long-lived burst issue reports the ceiling instead of every later # edit failing at the API with a generic error. MAX_ISSUE_BODY = 60000 +# One page of candidates, as in github_act. A full page is treated as truncated +# rather than as "no match". +_ISSUE_LIST_LIMIT = 100 # Result modes that never touched GitHub, so they must not start a cooldown # window: a dry run is a preview, not a touch. _DRY_RUN_MODES = frozenset({"dry-run", "storm-dry-run"}) @@ -293,7 +296,7 @@ def find_issue_number(runner: Runner, issue_repo: str, marker: str) -> int | Non "--state", "open", "--limit", - "100", + str(_ISSUE_LIST_LIMIT), "--json", "number,body", ], @@ -311,6 +314,10 @@ def find_issue_number(runner: Runner, issue_repo: str, marker: str) -> int | Non if isinstance(number, bool) or not isinstance(number, int): raise StoreError("gh issue list returned a non-integer number") return number + if len(listed) == _ISSUE_LIST_LIMIT: + # A full page means the marker may sit on an issue we never saw. Failing + # here beats reporting "no issue yet" and filing a duplicate. + raise StoreError("gh issue list truncated") return None diff --git a/tests/test_error_issue_act.py b/tests/test_error_issue_act.py index 5beb1b9..e92ee27 100644 --- a/tests/test_error_issue_act.py +++ b/tests/test_error_issue_act.py @@ -171,6 +171,31 @@ def runner(argv: list[str]) -> Completed: assert find_issue_number(runner, "org/tracker", "api|error|abc|prod") is None +def test_find_issue_number_raises_when_the_list_is_truncated() -> None: + """A full page may hide the marker on an unseen issue. Reporting "none" there + would file a duplicate, so this fails loud instead.""" + def runner(argv: list[str]) -> Completed: + return Completed( + 0, + json.dumps([{"number": n, "body": "unrelated"} for n in range(100)]), + "", + ) + + with pytest.raises(StoreError, match="issue list truncated"): + find_issue_number(runner, "org/tracker", "api|error|abc|prod") + + +def test_find_issue_number_returns_none_on_a_partial_page() -> None: + def runner(argv: list[str]) -> Completed: + return Completed( + 0, + json.dumps([{"number": n, "body": "unrelated"} for n in range(99)]), + "", + ) + + assert find_issue_number(runner, "org/tracker", "api|error|abc|prod") is None + + def test_find_issue_number_raises_when_gh_is_missing() -> None: def runner(argv: list[str]) -> Completed: raise OSError("No such file or directory: 'gh'") From 839ff5b1f0d011e94758f174b711f87baeca6aa7 Mon Sep 17 00:00:00 2001 From: Danswar <48102227+Danswar@users.noreply.github.com> Date: Mon, 31 Aug 2026 22:47:08 -0300 Subject: [PATCH 13/43] Harden the issue body against untrusted text, unbounded growth, and burst retries. --- src/agent_cli/error_issue_act.py | 108 ++++++++++++++++++- tests/test_error_issue_act.py | 172 +++++++++++++++++++++++++++++-- 2 files changed, 266 insertions(+), 14 deletions(-) diff --git a/src/agent_cli/error_issue_act.py b/src/agent_cli/error_issue_act.py index f19b799..1b27335 100644 --- a/src/agent_cli/error_issue_act.py +++ b/src/agent_cli/error_issue_act.py @@ -53,11 +53,30 @@ # One page of candidates, as in github_act. A full page is treated as truncated # rather than as "no match". _ISSUE_LIST_LIMIT = 100 +# Keep the variants table bounded so normal growth can never walk an issue into +# MAX_ISSUE_BODY and wedge every later update on it. +MAX_TRACKED_VARIANTS = 200 # Result modes that never touched GitHub, so they must not start a cooldown # window: a dry run is a preview, not a touch. _DRY_RUN_MODES = frozenset({"dry-run", "storm-dry-run"}) +def _inert_block(text: str) -> str: + """Log lines are untrusted data (DESIGN.md §19.2). A line carrying this + module's own marker would otherwise move the boundary of the machine-owned + section: a later splice would find the excerpt's marker first and rewrite + everything between it and the real one, destroying issue content. Breaking + the comment opener keeps the line readable inside its code fence while + making it inert.""" + return text.replace(""}) + section = render_variants_section({label: {"first_seen": "t1", "last_seen": "t1"}}) + assert parse_variants_section(section) == {label: {"first_seen": "t1", "last_seen": "t1"}} + + +# ---- clock skew ---- + + +def test_cooldown_ignores_a_touch_dated_in_the_future(tmp_path: Path) -> None: + """A future-dated touch would otherwise read as "no time has passed" forever + and skip this template on every later scan.""" + store = Store(tmp_path) + _runner_session(store) + _prior_touch( + store, + template_fingerprint="api|error|abc|prod", + at="2026-09-30T10:00:00Z", + activity_id="prior-future", + ) + assert ( + _within_cooldown(touch_history(store), "api|error|abc|prod", "2026-08-31T10:00:00Z", 60) + is False + ) + + +# ---- interrupted burst, then retry ---- + + +def test_retry_after_a_partially_marked_burst_does_not_split_the_template(tmp_path: Path) -> None: + """A burst writes its issue in one gh call but marks its rows one at a time. + If the process dies mid-loop, the rows left pending belong to templates the + burst issue already lists, so a retry must not file a second issue for them.""" + store = Store(tmp_path) + _runner_session(store) + for i in range(3): + _seen_and_issue(store, index=i, template_fingerprint=f"api|error|t{i}|prod") + + def storm_runner(argv: list[str]) -> Completed: + if argv[:3] == ["gh", "issue", "list"]: + return Completed(0, "[]", "") + return Completed(0, "https://github.com/org/tracker/issues/99\n", "") + + scan_error_issue( + store, storm_runner, issue_repo="org/tracker", dry_run=False, storm_threshold=2 + ) + + # Simulate the crash: put one of the burst's rows back to pending. + row = store.row("activity", "storm-issue-2") + assert row is not None + replayed = {k: v for k, v in row.items() if not k.startswith("_")} + replayed["execution_status"] = "pending" + replayed.pop("result", None) + store.write("activity", "update", "storm-issue-2", replayed) + + # The burst issue is the only record that template t2 was already folded. + burst_body = render_variants_section( + { + _storm_label(f"api|error|t{i}|prod", {"service": "api", "class": "error"}): { + "first_seen": "2026-08-31T10:00:00Z", + "last_seen": "2026-08-31T10:00:00Z", + } + for i in range(3) + } + ) + + def fail_if_created(argv: list[str]) -> Completed: + if argv[:3] == ["gh", "issue", "create"]: + raise AssertionError(f"must not open a second issue: {argv}") + if argv[:3] == ["gh", "issue", "list"]: + return Completed(0, json.dumps([{"number": 99, "body": STORM_MARKER}]), "") + if argv[:3] == ["gh", "issue", "view"]: + return Completed(0, json.dumps({"body": f"{STORM_MARKER}\n\n{burst_body}\n"}), "") + return Completed(0, "", "") + + lines = scan_error_issue( + store, fail_if_created, issue_repo="org/tracker", dry_run=False, storm_threshold=2 + ) + assert lines == ["error.issue storm-issue-2 already-in-burst number=99"] + row = store.row("activity", "storm-issue-2") + assert row is not None + assert row["execution_status"] == "done" + assert row["result"]["mode"] == "storm" From 626cecfed2a34fbf55bf9f861d49b98e0f3f40bd Mon Sep 17 00:00:00 2001 From: Danswar <48102227+Danswar@users.noreply.github.com> Date: Mon, 31 Aug 2026 22:50:07 -0300 Subject: [PATCH 14/43] Keep environments apart in the burst issue label. --- src/agent_cli/error_issue_act.py | 7 ++++++- tests/test_error_issue_act.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/agent_cli/error_issue_act.py b/src/agent_cli/error_issue_act.py index 1b27335..0951590 100644 --- a/src/agent_cli/error_issue_act.py +++ b/src/agent_cli/error_issue_act.py @@ -477,7 +477,12 @@ def _storm_label(template_fingerprint: str, seen_payload: dict[str, Any]) -> str cls = cls if isinstance(cls, str) and cls else "error" parts = template_fingerprint.split("|") short = parts[2][:8] if len(parts) >= 3 and parts[2] else template_fingerprint[:8] - return _inert_cell(f"{service}: {cls} ({short})") + # The environment belongs in the label: a template_fingerprint is + # service|class|template-sig|environment, so the same error in two + # environments is two templates. Without it both would share one row, the + # burst issue would hide one of them and storm_size would undercount. + environment = parts[3] if len(parts) >= 4 and parts[3] else "unknown" + return _inert_cell(f"{service}/{environment}: {cls} ({short})") def _create_storm_issue( diff --git a/tests/test_error_issue_act.py b/tests/test_error_issue_act.py index b4aabe2..2af16dc 100644 --- a/tests/test_error_issue_act.py +++ b/tests/test_error_issue_act.py @@ -1164,3 +1164,33 @@ def fail_if_created(argv: list[str]) -> Completed: assert row is not None assert row["execution_status"] == "done" assert row["result"]["mode"] == "storm" + + +def test_storm_label_separates_environments() -> None: + """service|class|template-sig|environment: the same error in two environments + is two templates, so one shared row would hide one and undercount the burst.""" + payload = {"service": "api", "class": "error"} + assert _storm_label("api|error|abc123def|prod", payload) != _storm_label( + "api|error|abc123def|staging", payload + ) + + +def test_storm_issue_lists_each_environment_separately(tmp_path: Path) -> None: + store = Store(tmp_path) + _runner_session(store) + for index, environment in enumerate(("prod", "staging", "test")): + _seen_and_issue(store, index=index, template_fingerprint=f"api|error|same|{environment}") + created: list[list[str]] = [] + + def runner(argv: list[str]) -> Completed: + created.append(list(argv)) + if argv[:3] == ["gh", "issue", "list"]: + return Completed(0, "[]", "") + return Completed(0, "https://github.com/org/tracker/issues/99\n", "") + + lines = scan_error_issue( + store, runner, issue_repo="org/tracker", dry_run=False, storm_threshold=2 + ) + assert all("storm size=3" in line for line in lines) + body = created[-1][created[-1].index("--body") + 1] + assert len(parse_variants_section(body)) == 3 From 18df18bf8a4886ce32235d51de97d3e42eb368e5 Mon Sep 17 00:00:00 2001 From: Danswar <48102227+Danswar@users.noreply.github.com> Date: Mon, 31 Aug 2026 22:59:43 -0300 Subject: [PATCH 15/43] Remember burst folds locally so a capped table cannot split a template. --- src/agent_cli/error_issue_act.py | 52 ++++++++++++++++++++---- tests/test_error_issue_act.py | 68 ++++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+), 7 deletions(-) diff --git a/src/agent_cli/error_issue_act.py b/src/agent_cli/error_issue_act.py index 0951590..3ca7f03 100644 --- a/src/agent_cli/error_issue_act.py +++ b/src/agent_cli/error_issue_act.py @@ -141,7 +141,12 @@ def render_variants_section(variants: dict[str, dict[str, str]]) -> str: where every later update would fail and the template would be stuck erroring for good. The most recently seen variants are the ones worth keeping; the dropped count stays visible in the section so the table never silently - understates what was seen.""" + understates what was seen. + + Accepted trade-off: an evicted variant that recurs later reads as new again + and is announced a second time. The cooldown keeps that from becoming a + tight loop, and an occasional repeat comment is the cheaper failure than an + issue that grows past the body limit and can never be updated again.""" kept = variants dropped = 0 if len(variants) > MAX_TRACKED_VARIANTS: @@ -250,6 +255,29 @@ def touch_history(store: Store) -> dict[str, datetime]: return history +def burst_folded_templates(store: Store) -> set[str]: + """Templates this device already folded into a burst issue. + + The burst table is capped, so a long-lived burst issue can evict an older + label from its body. Local history still remembers the fold, and that is + what keeps such a template from being filed a second time while its burst + issue is open.""" + folded: set[str] = set() + origin = store.device_id() + for row in store.rows("activity"): + if row.get("_origin_device_id") != origin or row.get("type") != "error.issue": + continue + if row.get("execution_status") != "done": + continue + result = row.get("result") + if not isinstance(result, dict) or result.get("mode") != "storm": + continue + template_fingerprint = result.get("template_fingerprint") + if isinstance(template_fingerprint, str): + folded.add(template_fingerprint) + return folded + + def _within_cooldown( history: dict[str, datetime], template_fingerprint: str, now: str, cooldown_minutes: int ) -> bool: @@ -685,7 +713,7 @@ def _scan_error_issue( for template_fp in new_templates: del groups[template_fp] - open_burst = _OpenBurst(runner, issue_repo) + open_burst = _OpenBurst(runner, issue_repo, burst_folded_templates(store)) for template_fp, members in groups.items(): lines.extend( _process_template( @@ -714,14 +742,20 @@ class _OpenBurst: lookup the retry would file a second, individual issue for a template the burst issue already covers, splitting one template across two issues.""" - def __init__(self, runner: Runner, issue_repo: str) -> None: + def __init__(self, runner: Runner, issue_repo: str, folded: set[str]) -> None: self._runner = runner self._issue_repo = issue_repo + self._folded = folded self._number: int | None = None self._labels: set[str] | None = None - def covers(self, label: str) -> int | None: - """The open burst issue's number when it already lists this label.""" + def covers(self, label: str, template_fingerprint: str) -> int | None: + """The open burst issue's number when this template is already in it. + + The issue body is the primary source, but its table is capped, so an + older fold can be missing from it. Local history covers exactly that + gap. Both only count while a burst issue is actually open: once a human + closes it, a template that recurs has earned its own issue.""" if self._labels is None: self._number = find_issue_number(self._runner, self._issue_repo, STORM_MARKER) if self._number is None: @@ -729,7 +763,9 @@ def covers(self, label: str) -> int | None: else: body = _issue_body(self._runner, self._issue_repo, self._number) self._labels = set(parse_variants_section(body)) - if label in self._labels: + if self._number is None: + return None + if label in self._labels or template_fingerprint in self._folded: return self._number return None @@ -814,7 +850,9 @@ def fail_all(exc: StoreError) -> list[str]: first_payload = members[0][1] if number is None: try: - burst_number = open_burst.covers(_storm_label(template_fp, first_payload)) + burst_number = open_burst.covers( + _storm_label(template_fp, first_payload), template_fp + ) except StoreError as exc: return fail_all(exc) if burst_number is not None: diff --git a/tests/test_error_issue_act.py b/tests/test_error_issue_act.py index 2af16dc..c6e08f8 100644 --- a/tests/test_error_issue_act.py +++ b/tests/test_error_issue_act.py @@ -443,6 +443,7 @@ def _prior_touch( at: str, activity_id: str, skipped: bool = False, + mode: str | None = None, ) -> None: result: dict[str, object] = { "issue_repo": "org/tracker", @@ -451,6 +452,8 @@ def _prior_touch( } if skipped: result["skipped"] = "cooldown" + if mode is not None: + result["mode"] = mode store.write( "activity", "insert", @@ -1194,3 +1197,68 @@ def runner(argv: list[str]) -> Completed: assert all("storm size=3" in line for line in lines) body = created[-1][created[-1].index("--body") + 1] assert len(parse_variants_section(body)) == 3 + + +def test_evicted_burst_label_still_blocks_a_second_issue(tmp_path: Path) -> None: + """The burst table is capped, so an old fold can drop out of the issue body. + Local history has to cover that gap, or the template gets a second issue and + ends up split across two.""" + store = Store(tmp_path) + _runner_session(store) + _seen(store, template_fingerprint="api|error|old|prod") + _issue(store) + _prior_touch( + store, + template_fingerprint="api|error|old|prod", + at="2026-08-01T10:00:00Z", + activity_id="prior-storm", + mode="storm", + ) + + def runner(argv: list[str]) -> Completed: + if argv[:3] == ["gh", "issue", "create"]: + raise AssertionError(f"must not open a second issue: {argv}") + if argv[:3] == ["gh", "issue", "list"]: + marker = STORM_MARKER if "--search" not in argv else "" + return Completed(0, json.dumps([{"number": 99, "body": STORM_MARKER}]), marker) + if argv[:3] == ["gh", "issue", "view"]: + # The burst issue is open, but this template's row was evicted. + body = f"{STORM_MARKER}\n\n" + render_variants_section( + {"other/prod: error (zzzzzzzz)": {"first_seen": "t1", "last_seen": "t1"}} + ) + return Completed(0, json.dumps({"body": body}), "") + return Completed(0, "", "") + + lines = scan_error_issue( + store, runner, issue_repo="org/tracker", dry_run=False, cooldown_minutes=0 + ) + assert lines == ["error.issue issue-1 already-in-burst number=99"] + + +def test_a_closed_burst_lets_the_template_get_its_own_issue(tmp_path: Path) -> None: + """Once a human closes the burst issue, a template that recurs has earned an + issue of its own — history alone must not suppress it forever.""" + store = Store(tmp_path) + _runner_session(store) + _seen(store, template_fingerprint="api|error|old|prod") + _issue(store) + _prior_touch( + store, + template_fingerprint="api|error|old|prod", + at="2026-08-01T10:00:00Z", + activity_id="prior-storm", + mode="storm", + ) + created: list[list[str]] = [] + + def runner(argv: list[str]) -> Completed: + created.append(list(argv)) + if argv[:3] == ["gh", "issue", "list"]: + return Completed(0, "[]", "") + return Completed(0, "https://github.com/org/tracker/issues/5\n", "") + + lines = scan_error_issue( + store, runner, issue_repo="org/tracker", dry_run=False, cooldown_minutes=0 + ) + assert lines == ["error.issue issue-1 created variant=Ethereum"] + assert any(c[:3] == ["gh", "issue", "create"] for c in created) From 26cf1ac41e8eeba2ad3c235785d741f201b1fadb Mon Sep 17 00:00:00 2001 From: Danswar <48102227+Danswar@users.noreply.github.com> Date: Mon, 31 Aug 2026 23:04:10 -0300 Subject: [PATCH 16/43] Bind a remembered burst fold to the issue it was folded into. --- src/agent_cli/error_issue_act.py | 34 ++++++++++------ tests/test_error_issue_act.py | 67 ++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 11 deletions(-) diff --git a/src/agent_cli/error_issue_act.py b/src/agent_cli/error_issue_act.py index 3ca7f03..50529a9 100644 --- a/src/agent_cli/error_issue_act.py +++ b/src/agent_cli/error_issue_act.py @@ -27,6 +27,7 @@ from __future__ import annotations import json +import re from collections.abc import Callable from datetime import datetime, timedelta, timezone from typing import Any @@ -56,6 +57,9 @@ # Keep the variants table bounded so normal growth can never walk an issue into # MAX_ISSUE_BODY and wedge every later update on it. MAX_TRACKED_VARIANTS = 200 +# Same shape github_act parses, so a created issue's number can be recorded next +# to its url. +_ISSUE_URL = re.compile(r"https://github\.com/[^/\s]+/[^/\s]+/issues/(\d+)") # Result modes that never touched GitHub, so they must not start a cooldown # window: a dry run is a preview, not a touch. _DRY_RUN_MODES = frozenset({"dry-run", "storm-dry-run"}) @@ -255,14 +259,15 @@ def touch_history(store: Store) -> dict[str, datetime]: return history -def burst_folded_templates(store: Store) -> set[str]: - """Templates this device already folded into a burst issue. +def burst_folded_templates(store: Store) -> dict[str, set[int]]: + """Which burst issues this device folded each template into. The burst table is capped, so a long-lived burst issue can evict an older - label from its body. Local history still remembers the fold, and that is - what keeps such a template from being filed a second time while its burst - issue is open.""" - folded: set[str] = set() + label from its body; local history still remembers the fold. It is keyed by + issue number on purpose: a template folded into a burst that has since been + closed must not count as covered by whatever burst is open now, or it would + be marked handled while no issue mentions it at all.""" + folded: dict[str, set[int]] = {} origin = store.device_id() for row in store.rows("activity"): if row.get("_origin_device_id") != origin or row.get("type") != "error.issue": @@ -273,8 +278,12 @@ def burst_folded_templates(store: Store) -> set[str]: if not isinstance(result, dict) or result.get("mode") != "storm": continue template_fingerprint = result.get("template_fingerprint") - if isinstance(template_fingerprint, str): - folded.add(template_fingerprint) + number = result.get("number") + if not isinstance(template_fingerprint, str): + continue + if isinstance(number, bool) or not isinstance(number, int): + continue + folded.setdefault(template_fingerprint, set()).add(number) return folded @@ -607,7 +616,10 @@ def _process_storm( number = find_issue_number(runner, issue_repo, STORM_MARKER) if number is None: url = _create_storm_issue(runner, issue_repo=issue_repo, templates=templates, now=now) - extra: dict[str, Any] = {"url": url, "created": True} + match = _ISSUE_URL.search(url) + if match is None: + raise StoreError("gh issue create returned no issue URL") + extra: dict[str, Any] = {"url": url, "number": int(match.group(1)), "created": True} else: _update_storm_issue(runner, issue_repo=issue_repo, number=number, templates=templates) extra = {"number": number, "created": False} @@ -742,7 +754,7 @@ class _OpenBurst: lookup the retry would file a second, individual issue for a template the burst issue already covers, splitting one template across two issues.""" - def __init__(self, runner: Runner, issue_repo: str, folded: set[str]) -> None: + def __init__(self, runner: Runner, issue_repo: str, folded: dict[str, set[int]]) -> None: self._runner = runner self._issue_repo = issue_repo self._folded = folded @@ -765,7 +777,7 @@ def covers(self, label: str, template_fingerprint: str) -> int | None: self._labels = set(parse_variants_section(body)) if self._number is None: return None - if label in self._labels or template_fingerprint in self._folded: + if label in self._labels or self._number in self._folded.get(template_fingerprint, set()): return self._number return None diff --git a/tests/test_error_issue_act.py b/tests/test_error_issue_act.py index c6e08f8..5fde63d 100644 --- a/tests/test_error_issue_act.py +++ b/tests/test_error_issue_act.py @@ -10,6 +10,7 @@ MAX_ISSUE_BODY, MAX_TRACKED_VARIANTS, STORM_MARKER, + burst_folded_templates, _VARIANTS_END, _VARIANTS_START, _storm_label, @@ -444,6 +445,7 @@ def _prior_touch( activity_id: str, skipped: bool = False, mode: str | None = None, + number: int | None = None, ) -> None: result: dict[str, object] = { "issue_repo": "org/tracker", @@ -454,6 +456,8 @@ def _prior_touch( result["skipped"] = "cooldown" if mode is not None: result["mode"] = mode + if number is not None: + result["number"] = number store.write( "activity", "insert", @@ -1213,6 +1217,7 @@ def test_evicted_burst_label_still_blocks_a_second_issue(tmp_path: Path) -> None at="2026-08-01T10:00:00Z", activity_id="prior-storm", mode="storm", + number=99, ) def runner(argv: list[str]) -> Completed: @@ -1248,6 +1253,7 @@ def test_a_closed_burst_lets_the_template_get_its_own_issue(tmp_path: Path) -> N at="2026-08-01T10:00:00Z", activity_id="prior-storm", mode="storm", + number=42, ) created: list[list[str]] = [] @@ -1262,3 +1268,64 @@ def runner(argv: list[str]) -> Completed: ) assert lines == ["error.issue issue-1 created variant=Ethereum"] assert any(c[:3] == ["gh", "issue", "create"] for c in created) + + +def test_a_fold_into_a_closed_burst_does_not_count_for_a_different_one(tmp_path: Path) -> None: + """A template folded into a burst that has since been closed must not be + marked as handled by whatever burst happens to be open now — that burst does + not list it, so the error would be swallowed with no issue mentioning it.""" + store = Store(tmp_path) + _runner_session(store) + _seen(store, template_fingerprint="api|error|old|prod") + _issue(store) + _prior_touch( + store, + template_fingerprint="api|error|old|prod", + at="2026-08-01T10:00:00Z", + activity_id="prior-storm", + mode="storm", + number=42, + ) + created: list[list[str]] = [] + + def runner(argv: list[str]) -> Completed: + created.append(list(argv)) + if argv[:3] == ["gh", "issue", "list"]: + # A different burst issue is open now; it does not list this template. + return Completed(0, json.dumps([{"number": 777, "body": STORM_MARKER}]), "") + if argv[:3] == ["gh", "issue", "view"]: + body = f"{STORM_MARKER}\n\n" + render_variants_section( + {"other/prod: error (unrelated)": {"first_seen": "t1", "last_seen": "t1"}} + ) + return Completed(0, json.dumps({"body": body}), "") + return Completed(0, "https://github.com/org/tracker/issues/5\n", "") + + lines = scan_error_issue( + store, runner, issue_repo="org/tracker", dry_run=False, cooldown_minutes=0 + ) + assert lines == ["error.issue issue-1 created variant=Ethereum"] + assert any(c[:3] == ["gh", "issue", "create"] for c in created) + + +def test_a_created_burst_records_its_issue_number(tmp_path: Path) -> None: + """The number is what later scans match a fold against, so a burst that was + created rather than reused has to record it too.""" + store = Store(tmp_path) + _runner_session(store) + for i in range(3): + _seen_and_issue(store, index=i, template_fingerprint=f"api|error|t{i}|prod") + + def runner(argv: list[str]) -> Completed: + if argv[:3] == ["gh", "issue", "list"]: + return Completed(0, "[]", "") + return Completed(0, "https://github.com/org/tracker/issues/99\n", "") + + scan_error_issue(store, runner, issue_repo="org/tracker", dry_run=False, storm_threshold=2) + for i in range(3): + row = store.row("activity", f"storm-issue-{i}") + assert row is not None + assert row["result"]["created"] is True + assert row["result"]["number"] == 99 + assert burst_folded_templates(store) == { + f"api|error|t{i}|prod": {99} for i in range(3) + } From ad979aa17d6f48f2071b82d27d9949f571d6ac6c Mon Sep 17 00:00:00 2001 From: Danswar <48102227+Danswar@users.noreply.github.com> Date: Mon, 31 Aug 2026 23:24:11 -0300 Subject: [PATCH 17/43] Give every template its own burst row regardless of separators in its text. --- src/agent_cli/error_issue_act.py | 47 ++++++++++++------- tests/test_error_issue_act.py | 77 ++++++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+), 16 deletions(-) diff --git a/src/agent_cli/error_issue_act.py b/src/agent_cli/error_issue_act.py index 50529a9..9405d51 100644 --- a/src/agent_cli/error_issue_act.py +++ b/src/agent_cli/error_issue_act.py @@ -26,6 +26,7 @@ from __future__ import annotations +import hashlib import json import re from collections.abc import Callable @@ -508,18 +509,25 @@ def _update_issue( def _storm_label(template_fingerprint: str, seen_payload: dict[str, Any]) -> str: + """One burst-table row per template: readable text plus a digest that makes + it unique. + + The readable half is display only. service, class and environment are free + text from the log source, so they may contain the separators this module + renders with and that template_fingerprint joins on; splitting the + fingerprint on "|" would then read the wrong fields, and two different + templates could render identical text. The digest is taken over the whole + fingerprint rather than a slice of it, so distinct templates keep distinct + rows whatever the text does — otherwise one of them would be missing from + the burst issue while a row still claimed to cover it.""" service = seen_payload.get("service") service = service if isinstance(service, str) and service else "unknown" cls = seen_payload.get("class") cls = cls if isinstance(cls, str) and cls else "error" - parts = template_fingerprint.split("|") - short = parts[2][:8] if len(parts) >= 3 and parts[2] else template_fingerprint[:8] - # The environment belongs in the label: a template_fingerprint is - # service|class|template-sig|environment, so the same error in two - # environments is two templates. Without it both would share one row, the - # burst issue would hide one of them and storm_size would undercount. - environment = parts[3] if len(parts) >= 4 and parts[3] else "unknown" - return _inert_cell(f"{service}/{environment}: {cls} ({short})") + environment = seen_payload.get("environment") + environment = environment if isinstance(environment, str) and environment else "unknown" + digest = hashlib.sha256(template_fingerprint.encode("utf-8")).hexdigest()[:12] + return _inert_cell(f"{service}/{environment}: {cls} ({digest})") def _create_storm_issue( @@ -745,14 +753,21 @@ def _scan_error_issue( class _OpenBurst: - """The templates the currently open burst issue already lists, fetched once - per scan and only when a template is about to get its own issue. - - A burst writes its issue in one gh call but marks its rows one at a time, so - a process that dies mid-loop leaves rows pending for templates that carry no - local history at all — the fold is recorded only in the issue. Without this - lookup the retry would file a second, individual issue for a template the - burst issue already covers, splitting one template across two issues.""" + """Whether the currently open burst issue already covers a template, from + two sources, fetched once per scan and only when a template is about to get + its own issue. + + The issue body's labels are the primary source. A burst writes its issue in + one gh call but marks its rows one at a time, so a process that dies mid-loop + leaves rows pending for templates that carry no local history at all — the + fold is then recorded only in the issue. Without this lookup the retry would + file a second, individual issue for a template the burst already covers, + splitting one template across two issues. + + Local fold history, keyed by issue number, is the second source: the body's + table is capped, so an older fold can have been evicted from it. Keying by + number is what keeps a fold into a since-closed burst from counting as + coverage by whatever burst happens to be open now.""" def __init__(self, runner: Runner, issue_repo: str, folded: dict[str, set[int]]) -> None: self._runner = runner diff --git a/tests/test_error_issue_act.py b/tests/test_error_issue_act.py index 5fde63d..dae79dd 100644 --- a/tests/test_error_issue_act.py +++ b/tests/test_error_issue_act.py @@ -1329,3 +1329,80 @@ def runner(argv: list[str]) -> Completed: assert burst_folded_templates(store) == { f"api|error|t{i}|prod": {99} for i in range(3) } + + +def test_storm_label_is_unique_per_template_despite_separators(tmp_path: Path) -> None: + """service, class and environment are free text from the log source, so they + can contain the separators the fingerprint joins on and the table renders + with. Two different templates must still get two rows, or one goes missing + from the burst issue while a row claims to cover it.""" + first = _storm_label( + "api|error|abc123def|prod/eu", + {"service": "api", "class": "error", "environment": "prod/eu"}, + ) + second = _storm_label( + "api/prod|error|abc123def|eu", + {"service": "api/prod", "class": "error", "environment": "eu"}, + ) + assert first != second + + # A pipe inside a component must not shift which field the label reports. + shifted = _storm_label( + "api|x|error|sigAAAA|prod", + {"service": "api|x", "class": "error", "environment": "prod"}, + ) + assert shifted.startswith("api/x/prod: error (") + + +def test_storm_issue_keeps_a_row_per_colliding_template(tmp_path: Path) -> None: + store = Store(tmp_path) + _runner_session(store) + for index, (service, environment) in enumerate( + (("api", "prod/eu"), ("api/prod", "eu"), ("api", "eu")) + ): + seen_id, issue_id = f"seen-{index}", f"storm-issue-{index}" + store.write( + "activity", + "insert", + seen_id, + { + "id": seen_id, + "session_id": "runner-1", + "type": "error.seen", + "payload": { + "fingerprint": f"fp-{index}", + "template_fingerprint": f"{service}|error|abc123def|{environment}", + "excerpt": "Some error", + "service": service, + "class": "error", + "environment": environment, + }, + "execution_status": "done", + }, + ) + store.write( + "activity", + "insert", + issue_id, + { + "id": issue_id, + "session_id": "runner-1", + "type": "error.issue", + "payload": {"error_id": seen_id}, + "execution_status": "pending", + }, + ) + created: list[list[str]] = [] + + def runner(argv: list[str]) -> Completed: + created.append(list(argv)) + if argv[:3] == ["gh", "issue", "list"]: + return Completed(0, "[]", "") + return Completed(0, "https://github.com/org/tracker/issues/99\n", "") + + lines = scan_error_issue( + store, runner, issue_repo="org/tracker", dry_run=False, storm_threshold=2 + ) + assert all("storm size=3" in line for line in lines) + body = created[-1][created[-1].index("--body") + 1] + assert len(parse_variants_section(body)) == 3 From 75166948052c948a563f75dfe4c1e9844fc90a9a Mon Sep 17 00:00:00 2001 From: Danswar <48102227+Danswar@users.noreply.github.com> Date: Mon, 31 Aug 2026 23:53:26 -0300 Subject: [PATCH 18/43] Serialize template fingerprint fields unambiguously. --- src/agent_cli/errors.py | 27 +++++++++++++++++++++++---- tests/test_errors.py | 18 ++++++++++++++++++ 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/src/agent_cli/errors.py b/src/agent_cli/errors.py index 2ed440d..321bc09 100644 --- a/src/agent_cli/errors.py +++ b/src/agent_cli/errors.py @@ -279,16 +279,35 @@ def template_signature(line: str) -> str: return hashlib.sha256(norm.encode("utf-8")).hexdigest()[:16] +def _escape_field(value: str) -> str: + """Make a field safe to join with "|". + + service, error_class and environment are free text from the log source, so + an unescaped join is ambiguous: service="a", error_class="b|c" and + service="a|b", error_class="c" would produce the same fingerprint and group + two unrelated errors under one template. Percent-escaping "%" first and then + "|" is reversible, so distinct field tuples stay distinct.""" + return value.replace("%", "%25").replace("|", "%7C") + + def template_fingerprint( *, service: str, error_class: str, template_sig: str, environment: str ) -> str: - return f"{service}|{error_class}|{template_sig}|{environment}" + return "|".join( + ( + _escape_field(service), + _escape_field(error_class), + template_sig, + _escape_field(environment), + ) + ) def known_chain_in(line: str) -> str | None: - """The first known blockchain name present in the line, if any — used to - label which concrete variant a template_fingerprint incident belongs to. - Most error lines don't name a chain; those return None.""" + """The first known chain or payment-rail name present in the line, if any — + used to label which concrete variant a template_fingerprint incident belongs + to. _KNOWN_CHAINS covers both, since the platform exposes them as one set of + transfer options. Most error lines name neither; those return None.""" match = _CHAIN_TOKEN.search(line) return match.group(0) if match is not None else None diff --git a/tests/test_errors.py b/tests/test_errors.py index 76032bf..4c59376 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -977,3 +977,21 @@ def test_template_signature_does_not_group_unrelated_words_with_assets() -> None assert template_signature("UNIQUE constraint failed") != template_signature( "UNI constraint failed" ) + + +def test_template_fingerprint_fields_cannot_collide() -> None: + """service, error_class and environment are free text from the log source. + An unescaped join would let two different field tuples produce one + fingerprint and group unrelated errors under a single template.""" + first = template_fingerprint( + service="a", error_class="b|c", template_sig="sig", environment="e" + ) + second = template_fingerprint( + service="a|b", error_class="c", template_sig="sig", environment="e" + ) + assert first != second + + # The escape itself must not become a new collision route. + assert template_fingerprint( + service="a%7Cb", error_class="c", template_sig="sig", environment="e" + ) != second From 73efb7d520e1e3bfffb742459b30084135cffa22 Mon Sep 17 00:00:00 2001 From: Danswar <48102227+Danswar@users.noreply.github.com> Date: Mon, 31 Aug 2026 23:53:27 -0300 Subject: [PATCH 19/43] Harden the issue body against untrusted markers, fences and cross-repo history. --- src/agent_cli/error_issue_act.py | 105 ++++++++++------ tests/test_error_issue_act.py | 199 +++++++++++++++++++++---------- 2 files changed, 206 insertions(+), 98 deletions(-) diff --git a/src/agent_cli/error_issue_act.py b/src/agent_cli/error_issue_act.py index 9405d51..0146e44 100644 --- a/src/agent_cli/error_issue_act.py +++ b/src/agent_cli/error_issue_act.py @@ -76,6 +76,17 @@ def _inert_block(text: str) -> str: return text.replace("" +def test_marker_is_a_digest_and_never_carries_raw_text() -> None: + """service, class and environment are free text from the log source. A raw + fingerprint in the marker could close the HTML comment early or embed the + section markers, corrupting the body and losing the marker the next lookup + needs.""" + marker = _marker_for("api|error|abc123|prod") + assert marker.startswith("") + assert _marker_for("api|error|abc123|prod") == marker + assert _marker_for("api|error|abc123|staging") != marker + + hostile = _marker_for(f"api --> {_VARIANTS_START}|error|abc|prod") + assert hostile.count("-->") == 1 + assert _VARIANTS_START not in hostile def test_variants_section_round_trips() -> None: @@ -114,21 +125,21 @@ def test_variants_section_round_trips() -> None: "Ethereum": {"first_seen": "2026-08-31T10:00:00Z", "last_seen": "2026-08-31T10:00:00Z"}, "Polygon": {"first_seen": "2026-08-31T11:00:00Z", "last_seen": "2026-08-31T11:30:00Z"}, } - section = render_variants_section(variants) + section = _render_variants_section(variants) assert "Ethereum" in section assert "Polygon" in section - parsed = parse_variants_section(section) + parsed = _parse_variants_section(section) assert parsed == variants def test_splice_variants_only_touches_delimited_section() -> None: body = "Human-written context above.\n\nMore human notes.\n" - with_section = splice_variants(body, {"Ethereum": {"first_seen": "t1", "last_seen": "t1"}}) + with_section = _splice_variants(body, {"Ethereum": {"first_seen": "t1", "last_seen": "t1"}}) assert "Human-written context above." in with_section assert "More human notes." in with_section assert "Ethereum" in with_section - updated = splice_variants( + updated = _splice_variants( with_section, { "Ethereum": {"first_seen": "t1", "last_seen": "t2"}, @@ -142,12 +153,12 @@ def test_splice_variants_only_touches_delimited_section() -> None: assert updated.count("Human-written context above.") == 1 -def test_find_issue_number_none_when_empty(tmp_path: Path) -> None: +def test_find_issue_number_none_when_empty() -> None: def runner(argv: list[str]) -> Completed: assert argv[:4] == ["gh", "issue", "list", "--repo"] return Completed(0, "[]", "") - assert find_issue_number(runner, "org/tracker", "api|error|abc|prod") is None + assert _find_issue_number(runner, "org/tracker", "api|error|abc|prod") is None def test_find_issue_number_matches_the_marker_in_the_body() -> None: @@ -164,7 +175,7 @@ def runner(argv: list[str]) -> Completed: "", ) - assert find_issue_number(runner, "org/tracker", "api|error|abc|prod") == 43 + assert _find_issue_number(runner, "org/tracker", "api|error|abc|prod") == 43 def test_find_issue_number_ignores_issues_without_the_marker() -> None: @@ -173,7 +184,7 @@ def test_find_issue_number_ignores_issues_without_the_marker() -> None: def runner(argv: list[str]) -> Completed: return Completed(0, json.dumps([{"number": 42, "body": "unrelated"}]), "") - assert find_issue_number(runner, "org/tracker", "api|error|abc|prod") is None + assert _find_issue_number(runner, "org/tracker", "api|error|abc|prod") is None def test_find_issue_number_raises_when_the_list_is_truncated() -> None: @@ -187,7 +198,7 @@ def runner(argv: list[str]) -> Completed: ) with pytest.raises(StoreError, match="issue list truncated"): - find_issue_number(runner, "org/tracker", "api|error|abc|prod") + _find_issue_number(runner, "org/tracker", "api|error|abc|prod") def test_find_issue_number_returns_none_on_a_partial_page() -> None: @@ -198,7 +209,7 @@ def runner(argv: list[str]) -> Completed: "", ) - assert find_issue_number(runner, "org/tracker", "api|error|abc|prod") is None + assert _find_issue_number(runner, "org/tracker", "api|error|abc|prod") is None def test_find_issue_number_raises_when_gh_is_missing() -> None: @@ -206,7 +217,7 @@ def runner(argv: list[str]) -> Completed: raise OSError("No such file or directory: 'gh'") with pytest.raises(StoreError, match="gh issue list failed"): - find_issue_number(runner, "org/tracker", "api|error|abc|prod") + _find_issue_number(runner, "org/tracker", "api|error|abc|prod") def test_find_issue_number_raises_on_gh_failure() -> None: @@ -214,7 +225,7 @@ def runner(argv: list[str]) -> Completed: return Completed(1, "", "not found") with pytest.raises(StoreError, match="not found"): - find_issue_number(runner, "org/tracker", "api|error|abc|prod") + _find_issue_number(runner, "org/tracker", "api|error|abc|prod") # ---- scan_error_issue: dry run ---- @@ -282,7 +293,7 @@ def runner(argv: list[str]) -> Completed: assert "--repo" in create_call and "org/tracker" in create_call assert "--label" in create_call and ISSUE_LABEL in create_call body = create_call[create_call.index("--body") + 1] - assert marker_for("api|error|abc123|prod") in body + assert _marker_for("api|error|abc123|prod") in body assert "Ethereum" in body row = store.row("activity", "issue-1") assert row is not None @@ -300,9 +311,9 @@ def test_scan_updates_existing_issue_same_variant_no_comment(tmp_path: Path) -> _seen(store) _issue(store) existing_body = ( - marker_for("api|error|abc123|prod") + _marker_for("api|error|abc123|prod") + "\n\nAutomated error-log finding.\n\n" - + render_variants_section({"Ethereum": {"first_seen": "t0", "last_seen": "t0"}}) + + _render_variants_section({"Ethereum": {"first_seen": "t0", "last_seen": "t0"}}) + "\n" ) calls: list[list[str]] = [] @@ -312,7 +323,7 @@ def runner(argv: list[str]) -> Completed: if argv[:3] == ["gh", "issue", "list"]: return Completed( 0, - json.dumps([{"number": 9, "body": marker_for("api|error|abc123|prod")}]), + json.dumps([{"number": 9, "body": _marker_for("api|error|abc123|prod")}]), "", ) if argv[:3] == ["gh", "issue", "view"]: @@ -345,9 +356,9 @@ def test_scan_updates_existing_issue_new_variant_posts_comment(tmp_path: Path) - ) _issue(store) existing_body = ( - marker_for("api|error|abc123|prod") + _marker_for("api|error|abc123|prod") + "\n\nAutomated error-log finding.\n\n" - + render_variants_section({"Ethereum": {"first_seen": "t0", "last_seen": "t0"}}) + + _render_variants_section({"Ethereum": {"first_seen": "t0", "last_seen": "t0"}}) + "\n" ) calls: list[list[str]] = [] @@ -357,7 +368,7 @@ def runner(argv: list[str]) -> Completed: if argv[:3] == ["gh", "issue", "list"]: return Completed( 0, - json.dumps([{"number": 9, "body": marker_for("api|error|abc123|prod")}]), + json.dumps([{"number": 9, "body": _marker_for("api|error|abc123|prod")}]), "", ) if argv[:3] == ["gh", "issue", "view"]: @@ -516,7 +527,7 @@ def _seen_and_issue( def test_cooldown_false_with_no_history(tmp_path: Path) -> None: store = Store(tmp_path) _runner_session(store) - assert _within_cooldown(touch_history(store), "api|error|abc|prod", utcnow(), 60) is False + assert _within_cooldown(_touch_history(store, "org/tracker"), "api|error|abc|prod", utcnow(), 60) is False def test_cooldown_true_within_window(tmp_path: Path) -> None: @@ -529,7 +540,7 @@ def test_cooldown_true_within_window(tmp_path: Path) -> None: activity_id="prior-1", ) assert ( - _within_cooldown(touch_history(store), "api|error|abc|prod", "2026-08-31T10:30:00Z", 60) + _within_cooldown(_touch_history(store, "org/tracker"), "api|error|abc|prod", "2026-08-31T10:30:00Z", 60) is True ) @@ -544,7 +555,7 @@ def test_cooldown_false_after_expiry(tmp_path: Path) -> None: activity_id="prior-1", ) assert ( - _within_cooldown(touch_history(store), "api|error|abc|prod", "2026-08-31T11:30:00Z", 60) + _within_cooldown(_touch_history(store, "org/tracker"), "api|error|abc|prod", "2026-08-31T11:30:00Z", 60) is False ) @@ -561,7 +572,7 @@ def test_cooldown_ignores_skipped_results(tmp_path: Path) -> None: ) # A skip-only history must not itself extend the cooldown window. assert ( - _within_cooldown(touch_history(store), "api|error|abc|prod", "2026-08-31T10:30:00Z", 60) + _within_cooldown(_touch_history(store, "org/tracker"), "api|error|abc|prod", "2026-08-31T10:30:00Z", 60) is False ) @@ -695,7 +706,7 @@ def test_scan_storm_reuses_existing_open_storm_issue(tmp_path: Path) -> None: existing_body = ( STORM_MARKER + "\n\n" - + render_variants_section({"api: error (oldhash)": {"first_seen": "t0", "last_seen": "t0"}}) + + _render_variants_section({"api: error (oldhash)": {"first_seen": "t0", "last_seen": "t0"}}) + "\n" ) calls: list[list[str]] = [] @@ -811,7 +822,7 @@ def runner(argv: list[str]) -> Completed: if argv[:3] == ["gh", "issue", "list"]: return Completed( 0, - json.dumps([{"number": 12, "body": marker_for("api|error|same|prod")}]), + json.dumps([{"number": 12, "body": _marker_for("api|error|same|prod")}]), "", ) if argv[:3] == ["gh", "issue", "view"]: @@ -845,7 +856,7 @@ def runner(argv: list[str]) -> Completed: if argv[:3] == ["gh", "issue", "list"]: return Completed( 0, - json.dumps([{"number": 12, "body": marker_for("api|error|abc123|prod")}]), + json.dumps([{"number": 12, "body": _marker_for("api|error|abc123|prod")}]), "", ) if argv[:3] == ["gh", "issue", "view"]: @@ -939,7 +950,7 @@ def runner(argv: list[str]) -> Completed: def test_splice_variants_refuses_a_half_open_section() -> None: damaged = "Human notes.\n\n\n\n| variant | first seen | last seen |\n" with pytest.raises(StoreError): - splice_variants(damaged, {"Ethereum": {"first_seen": "t1", "last_seen": "t1"}}) + _splice_variants(damaged, {"Ethereum": {"first_seen": "t1", "last_seen": "t1"}}) def test_splice_variants_refuses_a_body_over_the_github_limit() -> None: @@ -947,7 +958,7 @@ def test_splice_variants_refuses_a_body_over_the_github_limit() -> None: cap cannot control, such as very long human-written prose.""" huge_human_body = "human prose. " * (MAX_ISSUE_BODY // 10) with pytest.raises(StoreError): - splice_variants(huge_human_body, {"Ethereum": {"first_seen": "t1", "last_seen": "t1"}}) + _splice_variants(huge_human_body, {"Ethereum": {"first_seen": "t1", "last_seen": "t1"}}) def test_render_variants_section_caps_the_table() -> None: @@ -958,8 +969,8 @@ def test_render_variants_section_caps_the_table() -> None: f"chain-{i:04d}": {"first_seen": "t1", "last_seen": f"2026-08-{(i % 28) + 1:02d}"} for i in range(MAX_TRACKED_VARIANTS + 50) } - section = render_variants_section(variants) - parsed = parse_variants_section(section) + section = _render_variants_section(variants) + parsed = _parse_variants_section(section) assert len(parsed) == MAX_TRACKED_VARIANTS assert "50 older variants dropped" in section # The dropped-count note must not survive as a phantom variant row. @@ -971,7 +982,7 @@ def test_variants_table_stays_under_the_ceiling_when_saturated() -> None: f"chain-{i:04d}": {"first_seen": "2026-08-31T10:00:00Z", "last_seen": "2026-08-31T10:00:00Z"} for i in range(MAX_TRACKED_VARIANTS * 5) } - assert len(splice_variants("Human notes.\n", variants)) <= MAX_ISSUE_BODY + assert len(_splice_variants("Human notes.\n", variants)) <= MAX_ISSUE_BODY def test_create_paths_apply_the_same_body_ceiling() -> None: @@ -998,7 +1009,7 @@ def runner(argv: list[str]) -> Completed: return Completed(0, "https://github.com/org/tracker/issues/1\n", "") _create_storm_issue( - runner, issue_repo="org/tracker", templates=templates, now="2026-08-31T10:00:00Z" + runner, issue_repo="org/tracker", templates=templates ) body = created[0][created[0].index("--body") + 1] assert len(body) <= MAX_ISSUE_BODY @@ -1026,7 +1037,7 @@ def test_touch_history_keeps_the_newest_touch_per_template(tmp_path: Path) -> No at="2026-08-31T12:00:00Z", activity_id="prior-new", ) - history = touch_history(store) + history = _touch_history(store, "org/tracker") assert history["api|error|abc|prod"].isoformat() == "2026-08-31T12:00:00+00:00" @@ -1052,7 +1063,7 @@ def test_touch_history_ignores_unusable_rows(tmp_path: Path) -> None: "result": result, }, ) - assert touch_history(store) == {} + assert _touch_history(store, "org/tracker") == {} # ---- untrusted excerpt text ---- @@ -1081,7 +1092,7 @@ def runner(argv: list[str]) -> Completed: assert body.count(_VARIANTS_END) == 1 # A later update must keep the excerpt intact rather than splicing over it. - updated = splice_variants(body, parse_variants_section(body)) + updated = _splice_variants(body, _parse_variants_section(body)) assert updated.count(_VARIANTS_START) == 1 assert "injected" in updated @@ -1090,8 +1101,8 @@ def test_storm_label_survives_a_round_trip_through_the_table() -> None: """service/class come from the error payload; a pipe or a marker there would break the row apart so the label would not parse back.""" label = _storm_label("api|error|abc|prod", {"service": "a|b", "class": ""}) - section = render_variants_section({label: {"first_seen": "t1", "last_seen": "t1"}}) - assert parse_variants_section(section) == {label: {"first_seen": "t1", "last_seen": "t1"}} + section = _render_variants_section({label: {"first_seen": "t1", "last_seen": "t1"}}) + assert _parse_variants_section(section) == {label: {"first_seen": "t1", "last_seen": "t1"}} # ---- clock skew ---- @@ -1109,7 +1120,7 @@ def test_cooldown_ignores_a_touch_dated_in_the_future(tmp_path: Path) -> None: activity_id="prior-future", ) assert ( - _within_cooldown(touch_history(store), "api|error|abc|prod", "2026-08-31T10:00:00Z", 60) + _within_cooldown(_touch_history(store, "org/tracker"), "api|error|abc|prod", "2026-08-31T10:00:00Z", 60) is False ) @@ -1144,7 +1155,7 @@ def storm_runner(argv: list[str]) -> Completed: store.write("activity", "update", "storm-issue-2", replayed) # The burst issue is the only record that template t2 was already folded. - burst_body = render_variants_section( + burst_body = _render_variants_section( { _storm_label(f"api|error|t{i}|prod", {"service": "api", "class": "error"}): { "first_seen": "2026-08-31T10:00:00Z", @@ -1200,7 +1211,7 @@ def runner(argv: list[str]) -> Completed: ) assert all("storm size=3" in line for line in lines) body = created[-1][created[-1].index("--body") + 1] - assert len(parse_variants_section(body)) == 3 + assert len(_parse_variants_section(body)) == 3 def test_evicted_burst_label_still_blocks_a_second_issue(tmp_path: Path) -> None: @@ -1228,7 +1239,7 @@ def runner(argv: list[str]) -> Completed: return Completed(0, json.dumps([{"number": 99, "body": STORM_MARKER}]), marker) if argv[:3] == ["gh", "issue", "view"]: # The burst issue is open, but this template's row was evicted. - body = f"{STORM_MARKER}\n\n" + render_variants_section( + body = f"{STORM_MARKER}\n\n" + _render_variants_section( {"other/prod: error (zzzzzzzz)": {"first_seen": "t1", "last_seen": "t1"}} ) return Completed(0, json.dumps({"body": body}), "") @@ -1294,7 +1305,7 @@ def runner(argv: list[str]) -> Completed: # A different burst issue is open now; it does not list this template. return Completed(0, json.dumps([{"number": 777, "body": STORM_MARKER}]), "") if argv[:3] == ["gh", "issue", "view"]: - body = f"{STORM_MARKER}\n\n" + render_variants_section( + body = f"{STORM_MARKER}\n\n" + _render_variants_section( {"other/prod: error (unrelated)": {"first_seen": "t1", "last_seen": "t1"}} ) return Completed(0, json.dumps({"body": body}), "") @@ -1326,7 +1337,7 @@ def runner(argv: list[str]) -> Completed: assert row is not None assert row["result"]["created"] is True assert row["result"]["number"] == 99 - assert burst_folded_templates(store) == { + assert _burst_folded_templates(store, "org/tracker") == { f"api|error|t{i}|prod": {99} for i in range(3) } @@ -1405,4 +1416,72 @@ def runner(argv: list[str]) -> Completed: ) assert all("storm size=3" in line for line in lines) body = created[-1][created[-1].index("--body") + 1] - assert len(parse_variants_section(body)) == 3 + assert len(_parse_variants_section(body)) == 3 + + +# ---- untrusted text cannot escape its quoting ---- + + +def test_excerpt_cannot_break_out_of_its_code_fence(tmp_path: Path) -> None: + """A log line containing a fence would close the quote early and let the + rest render as live Markdown in an issue presented as an inert log quote.""" + store = Store(tmp_path) + _runner_session(store) + _seen(store, excerpt="boom ``` then [a](http://x) and more") + _issue(store) + created: list[list[str]] = [] + + def runner(argv: list[str]) -> Completed: + created.append(list(argv)) + if argv[:3] == ["gh", "issue", "list"]: + return Completed(0, "[]", "") + return Completed(0, "https://github.com/org/tracker/issues/1\n", "") + + scan_error_issue(store, runner, issue_repo="org/tracker", dry_run=False) + body = created[-1][created[-1].index("--body") + 1] + fence = "````" + assert body.count(fence) == 2 + quoted = body.split(fence)[1] + assert "boom ``` then [a](http://x) and more" in quoted + + +def test_splice_refuses_a_duplicated_marker(tmp_path: Path) -> None: + """Two copies of a marker mean the body is damaged; splicing across them + would silently rewrite whatever a human put between the copies.""" + section = _render_variants_section({"Ethereum": {"first_seen": "t1", "last_seen": "t1"}}) + doubled = f"{section}\n\nhuman notes worth keeping\n\n{section}\n" + with pytest.raises(StoreError, match="damaged variants section"): + _splice_variants(doubled, {"Ethereum": {"first_seen": "t1", "last_seen": "t2"}}) + + +# ---- history is per issue_repo ---- + + +def test_cooldown_history_does_not_leak_across_repos(tmp_path: Path) -> None: + store = Store(tmp_path) + _runner_session(store) + _prior_touch( + store, + template_fingerprint="api|error|abc|prod", + at="2026-08-31T10:00:00Z", + activity_id="prior-1", + ) + assert _touch_history(store, "org/tracker") != {} + assert _touch_history(store, "org/other-tracker") == {} + + +def test_burst_folds_do_not_leak_across_repos(tmp_path: Path) -> None: + """Issue numbers are per repo, so a fold recorded against another tracker + must not mark a template as covered here.""" + store = Store(tmp_path) + _runner_session(store) + _prior_touch( + store, + template_fingerprint="api|error|abc|prod", + at="2026-08-31T10:00:00Z", + activity_id="prior-1", + mode="storm", + number=99, + ) + assert _burst_folded_templates(store, "org/tracker") == {"api|error|abc|prod": {99}} + assert _burst_folded_templates(store, "org/other-tracker") == {} From ae02dcfc6ed7971c04f7d590bba784380596df6e Mon Sep 17 00:00:00 2001 From: Danswar <48102227+Danswar@users.noreply.github.com> Date: Mon, 31 Aug 2026 23:53:29 -0300 Subject: [PATCH 20/43] Document the error.issue payload shape. --- DESIGN.md | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 6d72609..70f3afc 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -677,13 +677,14 @@ The model never receives production credentials. Analysis that only reads the ex ### 21.6 Not in this revision - A second hub state machine, leases, or autonomous merge -- Dispatch for `error.issue`. The grouping and throttling logic ships as - `error_issue_act` with its tests, but the type is deliberately not yet in the - `agent activity add` allowlist and no watch command calls the scan, so nothing - files an issue yet. Wiring it means adding the type to that allowlist, an - `agent watch error-issue` one-scan command next to `agent watch error-fix`, and - `issue_repo` / `dry_run` / `cooldown_minutes` / `storm_threshold` in - `error-fix.json`. +- Dispatch for `error.issue`. Its payload is `{ "error_id": "" }`; + the scan reads the template and the excerpt from that `error.seen` row. The + grouping and throttling logic ships as `error_issue_act` with its tests, but + the type is deliberately not yet in the `agent activity add` allowlist and no + watch command calls the scan, so nothing files an issue yet. Wiring it means + adding the type to that allowlist, an `agent watch error-issue` one-scan + command next to `agent watch error-fix`, and `issue_repo` / `dry_run` / + `cooldown_minutes` / `storm_threshold` in `error-fix.json`. `error.issue` groups by `template_fingerprint` (§21.3), not by the per-variant `fingerprint`, so one issue covers every chain/token variant of one error. The From 099b2ff12bda8bb63682c8ab5a3a5394c9461bd9 Mon Sep 17 00:00:00 2001 From: Danswar <48102227+Danswar@users.noreply.github.com> Date: Tue, 1 Sep 2026 00:07:24 -0300 Subject: [PATCH 21/43] Say what the code does: duplicate markers, payment rails, row bounds. --- DESIGN.md | 6 +++--- src/agent_cli/error_issue_act.py | 17 ++++++++++------- src/agent_cli/errors.py | 14 +++++++------- 3 files changed, 20 insertions(+), 17 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 70f3afc..4c3ce04 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -636,9 +636,9 @@ Log lines, stack traces, and error messages are untrusted data (§19.2). They ar } ``` -`template_fingerprint` is `fingerprint` one step coarser: known blockchain names and -asset tickers are masked before hashing, so per-chain and per-token variants of one -error share it. Names are masked only as whole tokens and case-sensitively, so prose +`template_fingerprint` is `fingerprint` one step coarser: known blockchain and +payment-rail names and asset tickers are masked before hashing, so per-chain and +per-token variants of one error share it. Names are masked only as whole tokens and case-sensitively, so prose like "Based" or lowercase "usd" is not mistaken for a chain or a ticker. It groups which issue a variant belongs to (§21.6); `fingerprint` stays the finer-grained identity used for `count` / `last_seen`, so per-variant dedup remains exact. diff --git a/src/agent_cli/error_issue_act.py b/src/agent_cli/error_issue_act.py index 0146e44..f4847b2 100644 --- a/src/agent_cli/error_issue_act.py +++ b/src/agent_cli/error_issue_act.py @@ -164,9 +164,11 @@ def _render_variants_section(variants: dict[str, dict[str, str]]) -> str: The cap bounds how many rows the table can hold, which is what keeps normal growth away from MAX_ISSUE_BODY — where every later update would fail and the template would be stuck erroring for good. It bounds rows, not bytes: - _ensure_issue_body stays the hard limit, since a single cell can be long. The most recently seen variants are the ones worth keeping; the - dropped count stays visible in the section so the table never silently - understates what was seen. + _ensure_issue_body stays the hard limit, since a single cell can be long. + + The most recently seen variants are the ones worth keeping; the dropped + count stays visible in the section so the table never silently understates + what was seen. Accepted trade-off: an evicted variant that recurs later reads as new again and is announced a second time. The cooldown keeps that from becoming a @@ -221,10 +223,11 @@ def _ensure_issue_body(body: str) -> str: def _splice_variants(body: str, variants: dict[str, dict[str, str]]) -> str: """Replace only the delimited variants section; never touch the rest of the - body — that is human territory. A body carrying exactly one of the two - markers, or them in the wrong order, is damaged (a hand edit truncated the - section): appending a second section there would silently strand the - variants already recorded above, so fail loud instead.""" + body — that is human territory. Anything but exactly one well-ordered marker + pair is damage and fails loud: a lone marker means a hand edit truncated the + section, and appending a second section there would strand the variants + already recorded above, while a duplicated marker would make the splice + rewrite whatever sits between the copies.""" starts = body.count(_VARIANTS_START) ends = body.count(_VARIANTS_END) section = _render_variants_section(variants) diff --git a/src/agent_cli/errors.py b/src/agent_cli/errors.py index 321bc09..24e9ada 100644 --- a/src/agent_cli/errors.py +++ b/src/agent_cli/errors.py @@ -263,13 +263,13 @@ def stack_sig(line: str) -> str: def template_signature(line: str) -> str: - """Coarser than stack_sig: also masks known blockchain names and asset - tickers (see _KNOWN_CHAINS/_KNOWN_ASSETS), so a per-chain or per-token error - variant groups under one issue-filing template instead of fragmenting into - one fingerprint per chain/token pair. Used only for grouping which GitHub - issue a variant belongs to — error.seen identity keeps using the - finer-grained fingerprint()/stack_sig() so per-variant count/last_seen - tracking stays exact.""" + """Coarser than stack_sig: also masks known blockchain and payment-rail + names and asset tickers (see _KNOWN_CHAINS/_KNOWN_ASSETS), so a per-chain or + per-token error variant groups under one issue-filing template instead of + fragmenting into one fingerprint per chain/token pair. Used only for + grouping which GitHub issue a variant belongs to — error.seen identity keeps + using the finer-grained fingerprint()/stack_sig() so per-variant + count/last_seen tracking stays exact.""" norm = redact(strip_ansi(line)) norm = _UUID.sub("", norm) norm = _CHAIN_TOKEN.sub("", norm) From 38b1fbe400674c14301827dcf70406e631f4e75b Mon Sep 17 00:00:00 2001 From: Danswar <48102227+Danswar@users.noreply.github.com> Date: Tue, 1 Sep 2026 00:17:16 -0300 Subject: [PATCH 22/43] Exclude the fresh-body case from the damaged-section wording. --- DESIGN.md | 20 +++++++++++--------- src/agent_cli/error_issue_act.py | 20 +++++++++++--------- 2 files changed, 22 insertions(+), 18 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 4c3ce04..17fef7c 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -638,10 +638,11 @@ Log lines, stack traces, and error messages are untrusted data (§19.2). They ar `template_fingerprint` is `fingerprint` one step coarser: known blockchain and payment-rail names and asset tickers are masked before hashing, so per-chain and -per-token variants of one error share it. Names are masked only as whole tokens and case-sensitively, so prose -like "Based" or lowercase "usd" is not mistaken for a chain or a ticker. It groups -which issue a variant belongs to (§21.6); `fingerprint` stays the finer-grained -identity used for `count` / `last_seen`, so per-variant dedup remains exact. +per-token variants of one error share it. Names are masked only as whole tokens +and case-sensitively, so prose like "Based" or lowercase "usd" is not mistaken +for a chain or a ticker. It groups which issue a variant belongs to (§21.6); +`fingerprint` stays the finer-grained identity used for `count` / `last_seen`, +so per-variant dedup remains exact. `repo` may be omitted when the adapter cannot map the stream; the session then `error.skip`s with reason `unmapped-repo`. `line_fingerprint` is optional: `sha256(server + newline + container + newline + exact line)` as 64 lowercase hex, computed from the raw line before redaction. Omit it when `server` or `container` is missing. Host adapters may print the hex on `error.fix` stdout; it is not a mandate and not a log-host name. @@ -689,11 +690,12 @@ The model never receives production credentials. Analysis that only reads the ex `error.issue` groups by `template_fingerprint` (§21.3), not by the per-variant `fingerprint`, so one issue covers every chain/token variant of one error. The concrete variants live in a machine-owned, delimited section of the issue body; -nothing outside that section is ever rewritten, and a body carrying only one of -the two markers is treated as damaged rather than appended to. Two throttles sit -in front: a burst fold, counted over templates never filed before so a backlog -draining after downtime is not mistaken for a burst, and a per-template cooldown -read from local history — a dry run is a preview and never opens that window. +nothing outside that section is ever rewritten. A body carrying only one of the +two markers, or a duplicated marker, is treated as damaged rather than appended +to. Two throttles sit in front: a burst fold, counted over templates never filed +before so a backlog draining after downtime is not mistaken for a burst, and a +per-template cooldown read from local history — a dry run is a preview and never +opens that window. ## 22. Static supervise loop (v1) diff --git a/src/agent_cli/error_issue_act.py b/src/agent_cli/error_issue_act.py index f4847b2..5c4cac4 100644 --- a/src/agent_cli/error_issue_act.py +++ b/src/agent_cli/error_issue_act.py @@ -223,11 +223,14 @@ def _ensure_issue_body(body: str) -> str: def _splice_variants(body: str, variants: dict[str, dict[str, str]]) -> str: """Replace only the delimited variants section; never touch the rest of the - body — that is human territory. Anything but exactly one well-ordered marker - pair is damage and fails loud: a lone marker means a hand edit truncated the - section, and appending a second section there would strand the variants - already recorded above, while a duplicated marker would make the splice - rewrite whatever sits between the copies.""" + body — that is human territory. + + A body with no markers at all is fresh, so the section is appended. Once any + marker is present, though, anything but exactly one well-ordered pair is + damage and fails loud: a lone marker means a hand edit truncated the section, + and appending a second one there would strand the variants already recorded + above, while a duplicated marker would make the splice rewrite whatever sits + between the copies.""" starts = body.count(_VARIANTS_START) ends = body.count(_VARIANTS_END) section = _render_variants_section(variants) @@ -236,9 +239,9 @@ def _splice_variants(body: str, variants: dict[str, dict[str, str]]) -> str: return _ensure_issue_body(f"{body}{sep}{section}\n") start = body.find(_VARIANTS_START) end = body.find(_VARIANTS_END) - # Anything but exactly one well-ordered pair is damage. Splicing across a - # duplicated marker would silently rewrite whatever sits between the copies, - # which is human territory. + # Past the fresh-body case above, anything but exactly one well-ordered pair + # is damage: splicing across a duplicated marker would silently rewrite + # whatever sits between the copies, which is human territory. if starts != 1 or ends != 1 or end < start: raise StoreError("issue body has a damaged variants section") return _ensure_issue_body(body[:start] + section + body[end + len(_VARIANTS_END) :]) @@ -344,7 +347,6 @@ def _within_cooldown( return elapsed < timedelta(minutes=cooldown_minutes) - def _pending_issue(store: Store, row: dict[str, Any]) -> tuple[str, str, dict[str, Any]]: payload = row.get("payload") if not isinstance(payload, dict): From 4ff8f88fb569e60ff316f8cdab238c6787dccaab Mon Sep 17 00:00:00 2001 From: Danswar <48102227+Danswar@users.noreply.github.com> Date: Tue, 1 Sep 2026 00:26:41 -0300 Subject: [PATCH 23/43] Recognise the variants header by its cells, not by a prefix. --- src/agent_cli/error_issue_act.py | 16 +++++++++-- tests/test_error_issue_act.py | 48 ++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/src/agent_cli/error_issue_act.py b/src/agent_cli/error_issue_act.py index 5c4cac4..32461fa 100644 --- a/src/agent_cli/error_issue_act.py +++ b/src/agent_cli/error_issue_act.py @@ -42,6 +42,7 @@ ISSUE_LABEL = "error-log-agent" _MARKER_PREFIX = "" +_VARIANTS_HEADER = ("variant", "first seen", "last seen") _VARIANTS_START = "" _VARIANTS_END = "" STORM_MARKER = "" @@ -182,7 +183,12 @@ def _render_variants_section(variants: dict[str, dict[str, str]]) -> str: ) kept = dict(by_recency[:MAX_TRACKED_VARIANTS]) dropped = len(variants) - MAX_TRACKED_VARIANTS - lines = [_VARIANTS_START, "", "| variant | first seen | last seen |", "|---|---|---|"] + lines = [ + _VARIANTS_START, + "", + "| " + " | ".join(_VARIANTS_HEADER) + " |", + "|---|---|---|", + ] for name in sorted(kept): entry = kept[name] lines.append(f"| {name} | {entry.get('first_seen', '')} | {entry.get('last_seen', '')} |") @@ -203,11 +209,17 @@ def _parse_variants_section(body: str) -> dict[str, dict[str, str]]: return variants for raw_line in body[start:end].splitlines(): line = raw_line.strip() - if not line.startswith("|") or line.startswith("|---") or line.startswith("| variant"): + if not line.startswith("|") or line.startswith("|---"): continue parts = [p.strip() for p in line.strip("|").split("|")] if len(parts) != 3 or parts[0] == "": continue + # Recognise the header by its cells, not by a prefix: a real row whose + # name merely starts with "variant" — a service is free text — would + # otherwise be read as the header and dropped, and a template the burst + # issue already lists would be filed a second time. + if tuple(parts) == _VARIANTS_HEADER: + continue name, first_seen, last_seen = parts variants[name] = {"first_seen": first_seen, "last_seen": last_seen} return variants diff --git a/tests/test_error_issue_act.py b/tests/test_error_issue_act.py index 26bbdab..f33cd10 100644 --- a/tests/test_error_issue_act.py +++ b/tests/test_error_issue_act.py @@ -51,6 +51,7 @@ def _seen( excerpt: str = "Timeout updating balances for Ethereum: Error: Timeout", service: str = "api", cls: str = "error", + environment: str = "prod", activity_id: str = "error-seen-1", ) -> None: payload: dict[str, object] = { @@ -58,6 +59,7 @@ def _seen( "excerpt": excerpt, "service": service, "class": cls, + "environment": environment, } if template_fingerprint is not None: payload["template_fingerprint"] = template_fingerprint @@ -1485,3 +1487,49 @@ def test_burst_folds_do_not_leak_across_repos(tmp_path: Path) -> None: ) assert _burst_folded_templates(store, "org/tracker") == {"api|error|abc|prod": {99}} assert _burst_folded_templates(store, "org/other-tracker") == {} + + +def test_a_row_named_like_the_header_survives_the_round_trip(tmp_path: Path) -> None: + """service is free text, so a label can begin with "variant". Matching the + header by prefix would drop that row, and a template the burst issue already + lists would be filed a second time.""" + label = _storm_label( + "variant|error|abc123def|prod", + {"service": "variant", "class": "error", "environment": "prod"}, + ) + assert label.startswith("variant") + section = _render_variants_section({label: {"first_seen": "t1", "last_seen": "t1"}}) + assert _parse_variants_section(section) == {label: {"first_seen": "t1", "last_seen": "t1"}} + + +def test_a_row_named_like_the_header_still_blocks_a_second_issue(tmp_path: Path) -> None: + store = Store(tmp_path) + _runner_session(store) + _seen( + store, + template_fingerprint="variant|error|abc123def|prod", + service="variant", + excerpt="Some error", + ) + _issue(store) + label = _storm_label( + "variant|error|abc123def|prod", + {"service": "variant", "class": "error", "environment": "prod"}, + ) + burst_body = f"{STORM_MARKER}\n\n" + _render_variants_section( + {label: {"first_seen": "t1", "last_seen": "t1"}} + ) + + def runner(argv: list[str]) -> Completed: + if argv[:3] == ["gh", "issue", "create"]: + raise AssertionError(f"must not open a second issue: {argv}") + if argv[:3] == ["gh", "issue", "list"]: + return Completed(0, json.dumps([{"number": 99, "body": STORM_MARKER}]), "") + if argv[:3] == ["gh", "issue", "view"]: + return Completed(0, json.dumps({"body": burst_body}), "") + return Completed(0, "", "") + + lines = scan_error_issue( + store, runner, issue_repo="org/tracker", dry_run=False, cooldown_minutes=0 + ) + assert lines == ["error.issue issue-1 already-in-burst number=99"] From 1522799fc19a0f99c65fd3de7bc49166f919d698 Mon Sep 17 00:00:00 2001 From: Danswar <48102227+Danswar@users.noreply.github.com> Date: Tue, 1 Sep 2026 00:50:50 -0300 Subject: [PATCH 24/43] Keep a gh argument error on its row and log text inert in a cell. --- src/agent_cli/error_issue_act.py | 78 ++++++++++++++++++++------------ tests/test_error_issue_act.py | 68 +++++++++++++++++++++++++++- 2 files changed, 115 insertions(+), 31 deletions(-) diff --git a/src/agent_cli/error_issue_act.py b/src/agent_cli/error_issue_act.py index 32461fa..e6716d1 100644 --- a/src/agent_cli/error_issue_act.py +++ b/src/agent_cli/error_issue_act.py @@ -18,8 +18,9 @@ never opens that window. All pending rows of one template are handled together, so two variants seen in -the same run land in one issue instead of the first becoming a cooldown touch -that drops the second. +the same run land in one issue with one comment instead of one issue write and +one comment each. The cooldown cannot mistake them for repeats either way: the +history snapshot is taken before the scan marks anything. Both are plain comparisons against local state; neither involves model judgment.""" @@ -89,9 +90,15 @@ def _fenced(text: str) -> str: def _inert_cell(text: str) -> str: - """Same, for text that lands in a table cell: a pipe or a newline there - would break the row apart and the name would not survive a round trip.""" - return _inert_block(text).replace("|", "/").replace("\n", " ").replace("\r", " ") + """Render untrusted text as an inert table cell. + + A pipe or a newline would break the row apart so the name would not survive + a round trip. The code span is what stops the rest: this text comes from the + log source, and a bare "@name" or "[text](url)" in a cell renders as a live + mention or link in the issue. A backtick cannot be kept inside a single + backtick span, so it becomes an apostrophe.""" + flattened = _inert_block(text).replace("|", "/").replace("\n", " ").replace("\r", " ") + return f"`{flattened.replace('`', chr(39))}`" def _strip(row: dict[str, Any]) -> dict[str, Any]: @@ -200,6 +207,17 @@ def _render_variants_section(variants: dict[str, dict[str, str]]) -> str: return "\n".join(lines) +def _require_intact_section(body: str) -> None: + """Raise unless the body has no section at all or exactly one well-ordered + marker pair. Shared so a reader and a writer judge damage the same way.""" + starts = body.count(_VARIANTS_START) + ends = body.count(_VARIANTS_END) + if starts == 0 and ends == 0: + return + if starts != 1 or ends != 1 or body.find(_VARIANTS_END) < body.find(_VARIANTS_START): + raise StoreError("issue body has a damaged variants section") + + def _parse_variants_section(body: str) -> dict[str, dict[str, str]]: """Parse the existing delimited variants table back out of an issue body.""" start = body.find(_VARIANTS_START) @@ -243,19 +261,13 @@ def _splice_variants(body: str, variants: dict[str, dict[str, str]]) -> str: and appending a second one there would strand the variants already recorded above, while a duplicated marker would make the splice rewrite whatever sits between the copies.""" - starts = body.count(_VARIANTS_START) - ends = body.count(_VARIANTS_END) + _require_intact_section(body) section = _render_variants_section(variants) - if starts == 0 and ends == 0: + start = body.find(_VARIANTS_START) + if start == -1: sep = "" if body == "" else "\n\n" return _ensure_issue_body(f"{body}{sep}{section}\n") - start = body.find(_VARIANTS_START) end = body.find(_VARIANTS_END) - # Past the fresh-body case above, anything but exactly one well-ordered pair - # is damage: splicing across a duplicated marker would silently rewrite - # whatever sits between the copies, which is human territory. - if starts != 1 or ends != 1 or end < start: - raise StoreError("issue body has a damaged variants section") return _ensure_issue_body(body[:start] + section + body[end + len(_VARIANTS_END) :]) @@ -352,14 +364,15 @@ def _within_cooldown( return False elapsed = now_dt - touched_dt # A touch dated in the future (clock skew, a backward clock, a damaged row) - # would otherwise look like "no time has passed" forever and silently skip - # this template on every future scan. Treat it as not in cooldown. + # would otherwise read as "no time has passed" and silently skip this + # template until the clock passes that timestamp plus the whole window. + # Treat it as not in cooldown. if elapsed < timedelta(0): return False return elapsed < timedelta(minutes=cooldown_minutes) -def _pending_issue(store: Store, row: dict[str, Any]) -> tuple[str, str, dict[str, Any]]: +def _pending_issue(store: Store, row: dict[str, Any]) -> tuple[str, dict[str, Any]]: payload = row.get("payload") if not isinstance(payload, dict): raise StoreError("payload must be an object") @@ -376,7 +389,7 @@ def _pending_issue(store: Store, row: dict[str, Any]) -> tuple[str, str, dict[st template_fp = _nonempty_str(seen_payload.get("template_fingerprint")) if template_fp is None: raise StoreError("template_fingerprint is required") - return error_id, template_fp, seen_payload + return template_fp, seen_payload def _gh(runner: Runner, argv: list[str], fallback: str) -> str: @@ -386,7 +399,10 @@ def _gh(runner: Runner, argv: list[str], fallback: str) -> str: github_act).""" try: completed = runner(argv) - except OSError as exc: + except (OSError, ValueError) as exc: + # OSError is a missing or broken binary; ValueError is subprocess + # refusing an argument, e.g. a NUL byte carried in from a log line. + # Either way this stays one row's failure, not the scan's. raise StoreError(f"{fallback}: {exc}") from exc if completed.returncode != 0: raise StoreError((completed.stderr or completed.stdout or fallback).strip()) @@ -639,11 +655,11 @@ def _process_storm( *, issue_repo: str, dry_run: bool, - resolved: list[tuple[dict[str, Any], str, str, dict[str, Any]]], + resolved: list[tuple[dict[str, Any], str, dict[str, Any]]], now: str, ) -> list[str]: templates: dict[str, dict[str, str]] = {} - for _row, _error_id, template_fp, seen_payload in resolved: + for _row, template_fp, seen_payload in resolved: label = _storm_label(template_fp, seen_payload) if label in templates: templates[label]["last_seen"] = now @@ -653,7 +669,7 @@ def _process_storm( lines: list[str] = [] if dry_run: - for row, _error_id, template_fp, _seen_payload in resolved: + for row, template_fp, _seen_payload in resolved: rid = str(row.get("id") or "?") result = { "mode": "storm-dry-run", @@ -678,13 +694,13 @@ def _process_storm( _update_storm_issue(runner, issue_repo=issue_repo, number=number, templates=templates) extra = {"number": number, "created": False} except StoreError as exc: - for row, _error_id, _template_fp, _seen_payload in resolved: + for row, _template_fp, _seen_payload in resolved: rid = str(row.get("id") or "?") _mark(store, row, status="error", error=str(exc)) lines.append(f"error.issue {rid} error") return lines - for row, _error_id, template_fp, _seen_payload in resolved: + for row, template_fp, _seen_payload in resolved: rid = str(row.get("id") or "?") result = { "mode": "storm", @@ -737,16 +753,16 @@ def _scan_error_issue( rows.sort(key=lambda row: str(row.get("id") or "")) lines: list[str] = [] - resolved: list[tuple[dict[str, Any], str, str, dict[str, Any]]] = [] + resolved: list[tuple[dict[str, Any], str, dict[str, Any]]] = [] for row in rows: rid = str(row.get("id") or "?") try: - error_id, template_fp, seen_payload = _pending_issue(store, row) + template_fp, seen_payload = _pending_issue(store, row) except StoreError as exc: _mark(store, row, status="error", error=str(exc)) lines.append(f"error.issue {rid} error") continue - resolved.append((row, error_id, template_fp, seen_payload)) + resolved.append((row, template_fp, seen_payload)) if not resolved: return lines @@ -758,7 +774,7 @@ def _scan_error_issue( history = _touch_history(store, issue_repo) groups: dict[str, list[tuple[dict[str, Any], dict[str, Any]]]] = {} - for row, _error_id, template_fp, seen_payload in resolved: + for row, template_fp, seen_payload in resolved: groups.setdefault(template_fp, []).append((row, seen_payload)) # Burst detection counts only templates never filed before. A backlog of @@ -767,7 +783,7 @@ def _scan_error_issue( new_templates = [fp for fp in groups if fp not in history] if len(new_templates) > storm_threshold: storm_rows = [ - (row, "", template_fp, seen_payload) + (row, template_fp, seen_payload) for template_fp in new_templates for row, seen_payload in groups[template_fp] ] @@ -835,6 +851,10 @@ def covers(self, label: str, template_fingerprint: str) -> int | None: self._labels = set() else: body = _issue_body(self._runner, self._issue_repo, self._number) + # A damaged section parses to nothing, which would read as "this + # burst covers no template" and file duplicates. Fail loud on it, + # exactly as a splice would. + _require_intact_section(body) self._labels = set(_parse_variants_section(body)) if self._number is None: return None diff --git a/tests/test_error_issue_act.py b/tests/test_error_issue_act.py index f33cd10..92c41b4 100644 --- a/tests/test_error_issue_act.py +++ b/tests/test_error_issue_act.py @@ -1364,7 +1364,7 @@ def test_storm_label_is_unique_per_template_despite_separators(tmp_path: Path) - "api|x|error|sigAAAA|prod", {"service": "api|x", "class": "error", "environment": "prod"}, ) - assert shifted.startswith("api/x/prod: error (") + assert shifted.startswith("`api/x/prod: error (") def test_storm_issue_keeps_a_row_per_colliding_template(tmp_path: Path) -> None: @@ -1497,7 +1497,7 @@ def test_a_row_named_like_the_header_survives_the_round_trip(tmp_path: Path) -> "variant|error|abc123def|prod", {"service": "variant", "class": "error", "environment": "prod"}, ) - assert label.startswith("variant") + assert label.startswith("`variant") section = _render_variants_section({label: {"first_seen": "t1", "last_seen": "t1"}}) assert _parse_variants_section(section) == {label: {"first_seen": "t1", "last_seen": "t1"}} @@ -1533,3 +1533,67 @@ def runner(argv: list[str]) -> Completed: store, runner, issue_repo="org/tracker", dry_run=False, cooldown_minutes=0 ) assert lines == ["error.issue issue-1 already-in-burst number=99"] + + +def test_storm_label_renders_log_text_inertly() -> None: + """service and class come from the log source. A bare mention or link in a + table cell renders as a live mention or link in the issue.""" + label = _storm_label( + "api|error|abc|prod", + {"service": "@someone", "class": "[click](http://x)", "environment": "prod"}, + ) + assert label.startswith("`") and label.endswith("`") + assert "@someone" in label # the text is kept, only made inert + # A backtick cannot survive inside a single-backtick span. + assert "`" not in label[1:-1] + spanned = _storm_label( + "api|error|abc|prod", + {"service": "a`b", "class": "error", "environment": "prod"}, + ) + assert "`" not in spanned[1:-1] + + +def test_gh_argument_errors_stay_on_the_row(tmp_path: Path) -> None: + """subprocess refuses a NUL byte with ValueError, not OSError. Uncaught, it + would abort the whole scan and the row would block every later run.""" + store = Store(tmp_path) + _runner_session(store) + _seen(store) + _issue(store) + + def runner(argv: list[str]) -> Completed: + raise ValueError("embedded null byte") + + lines = scan_error_issue(store, runner, issue_repo="org/tracker", dry_run=False) + assert lines == ["error.issue issue-1 error"] + row = store.row("activity", "issue-1") + assert row is not None + assert row["execution_status"] == "error" + assert "embedded null byte" in row["execution_error"] + + +def test_burst_lookup_fails_loud_on_a_damaged_body(tmp_path: Path) -> None: + """A damaged burst body parses to nothing, which would read as "covers no + template" and file a duplicate. It has to fail like a splice would.""" + store = Store(tmp_path) + _runner_session(store) + _seen(store) + _issue(store) + damaged = f"{STORM_MARKER}\n\n{_VARIANTS_START}\n\n| a | t1 | t1 |\n" + + def runner(argv: list[str]) -> Completed: + if argv[:3] == ["gh", "issue", "create"]: + raise AssertionError(f"must not open an issue off a damaged body: {argv}") + if argv[:3] == ["gh", "issue", "list"]: + return Completed(0, json.dumps([{"number": 99, "body": STORM_MARKER}]), "") + if argv[:3] == ["gh", "issue", "view"]: + return Completed(0, json.dumps({"body": damaged}), "") + return Completed(0, "", "") + + lines = scan_error_issue( + store, runner, issue_repo="org/tracker", dry_run=False, cooldown_minutes=0 + ) + assert lines == ["error.issue issue-1 error"] + row = store.row("activity", "issue-1") + assert row is not None + assert "damaged variants section" in row["execution_error"] From 3a039c9f69938a3c2103a6fc94763540087675e1 Mon Sep 17 00:00:00 2001 From: Danswar <48102227+Danswar@users.noreply.github.com> Date: Tue, 1 Sep 2026 00:50:52 -0300 Subject: [PATCH 25/43] Say why the alternation is sorted, and document escaping and comments. --- DESIGN.md | 11 ++++++++--- src/agent_cli/errors.py | 13 ++++++++++--- tests/test_errors.py | 8 ++++++++ 3 files changed, 26 insertions(+), 6 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 17fef7c..3f39bff 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -638,7 +638,9 @@ Log lines, stack traces, and error messages are untrusted data (§19.2). They ar `template_fingerprint` is `fingerprint` one step coarser: known blockchain and payment-rail names and asset tickers are masked before hashing, so per-chain and -per-token variants of one error share it. Names are masked only as whole tokens +per-token variants of one error share it. `service`, `class` and `environment` +are percent-escaped (`%`→`%25`, `|`→`%7C`) before the join, so two different +field tuples cannot serialize to one fingerprint. Names are masked only as whole tokens and case-sensitively, so prose like "Based" or lowercase "usd" is not mistaken for a chain or a ticker. It groups which issue a variant belongs to (§21.6); `fingerprint` stays the finer-grained identity used for `count` / `last_seen`, @@ -692,8 +694,11 @@ The model never receives production credentials. Analysis that only reads the ex concrete variants live in a machine-owned, delimited section of the issue body; nothing outside that section is ever rewritten. A body carrying only one of the two markers, or a duplicated marker, is treated as damaged rather than appended -to. Two throttles sit in front: a burst fold, counted over templates never filed -before so a backlog draining after downtime is not mistaken for a burst, and a +to. A genuinely new variant is also announced with one `Also seen on: …` +comment; that comment is best effort, so a comment that fails after the body +edit landed is recorded on the row rather than discarding the edit. Two +throttles sit in front: a burst fold, counted over templates never filed before +so a backlog draining after downtime is not mistaken for a burst, and a per-template cooldown read from local history — a dry run is a preview and never opens that window. diff --git a/src/agent_cli/errors.py b/src/agent_cli/errors.py index 24e9ada..7119524 100644 --- a/src/agent_cli/errors.py +++ b/src/agent_cli/errors.py @@ -60,9 +60,16 @@ def _token_pattern(names: frozenset[str]) -> re.Pattern[str]: - """Alternation over known names, longest-first so e.g. "Bitcoin" cannot - shadow-match a prefix of "BitcoinTestnet4" and "USD" cannot shadow-match - "USDC". Anchored on both sides so a name only matches as a whole token: + """Alternation over known names, longest-first, anchored on both sides. + + The anchors alone settle names made only of word characters: "Bitcoin" + inside "BitcoinTestnet4" fails its own trailing look-ahead, so the engine + backtracks to the longer alternative whatever the order. Ordering is what + settles the rest — "USDC" inside "USDC.e" is followed by ".", which is not a + word character, so the short alternative would match and strip the ticker + down to the wrong asset unless the longer one is tried first. + + Anchored on both sides so a name only matches as a whole token: without that, "Base" matches inside "Based", "SOL" inside "RESOLVE", "COMP" inside "COMPLETE" and "DAI" inside "DAILY", which would mask unrelated words and label an unrelated error as a chain/asset variant. The anchors are diff --git a/tests/test_errors.py b/tests/test_errors.py index 4c59376..29b3c52 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -995,3 +995,11 @@ def test_template_fingerprint_fields_cannot_collide() -> None: assert template_fingerprint( service="a%7Cb", error_class="c", template_sig="sig", environment="e" ) != second + + +def test_asset_alternation_must_try_the_longest_name_first() -> None: + """The boundary anchors settle word-character names on their own, but not a + ticker containing punctuation: "." is not a word character, so "USDC" would + match inside "USDC.e" and strip it down to the wrong asset.""" + assert known_asset_in("USDC.e drift on Arbitrum") == "USDC.e" + assert known_asset_in("USDC drift on Arbitrum") == "USDC" From 29757c4e626bb631c8b2077997b86b471cc462b5 Mon Sep 17 00:00:00 2001 From: Danswar <48102227+Danswar@users.noreply.github.com> Date: Tue, 1 Sep 2026 01:01:08 -0300 Subject: [PATCH 26/43] Fetch a burst body once per scan even when it is damaged. --- src/agent_cli/error_issue_act.py | 49 ++++++++++++++++++++------------ tests/test_error_issue_act.py | 34 ++++++++++++++++++++-- 2 files changed, 62 insertions(+), 21 deletions(-) diff --git a/src/agent_cli/error_issue_act.py b/src/agent_cli/error_issue_act.py index e6716d1..851bba4 100644 --- a/src/agent_cli/error_issue_act.py +++ b/src/agent_cli/error_issue_act.py @@ -393,10 +393,12 @@ def _pending_issue(store: Store, row: dict[str, Any]) -> tuple[str, dict[str, An def _gh(runner: Runner, argv: list[str], fallback: str) -> str: - """Run one gh command and return its stdout. A missing or broken binary - raises the same StoreError as a non-zero exit, so it stays a per-row failure - instead of aborting the whole scan (same contract as error_fix_act and - github_act).""" + """Run one gh command and return its stdout. + + A missing or broken binary, and subprocess refusing an argument outright + (a NUL byte carried in from a log line), both raise the same StoreError as a + non-zero exit, so either stays a per-row failure instead of aborting the + whole scan (same contract as error_fix_act and github_act).""" try: completed = runner(argv) except (OSError, ValueError) as exc: @@ -837,6 +839,7 @@ def __init__(self, runner: Runner, issue_repo: str, folded: dict[str, set[int]]) self._folded = folded self._number: int | None = None self._labels: set[str] | None = None + self._failure: StoreError | None = None def covers(self, label: str, template_fingerprint: str) -> int | None: """The open burst issue's number when this template is already in it. @@ -845,17 +848,25 @@ def covers(self, label: str, template_fingerprint: str) -> int | None: older fold can be missing from it. Local history covers exactly that gap. Both only count while a burst issue is actually open: once a human closes it, a template that recurs has earned its own issue.""" + if self._failure is not None: + # Cache the failure too, or the guard below would send every later + # template through the same two gh calls to the same broken body. + raise self._failure if self._labels is None: - self._number = _find_issue_number(self._runner, self._issue_repo, STORM_MARKER) - if self._number is None: - self._labels = set() - else: - body = _issue_body(self._runner, self._issue_repo, self._number) - # A damaged section parses to nothing, which would read as "this - # burst covers no template" and file duplicates. Fail loud on it, - # exactly as a splice would. - _require_intact_section(body) - self._labels = set(_parse_variants_section(body)) + try: + self._number = _find_issue_number(self._runner, self._issue_repo, STORM_MARKER) + if self._number is None: + self._labels = set() + else: + body = _issue_body(self._runner, self._issue_repo, self._number) + # A damaged section parses to nothing, which would read as + # "this burst covers no template" and file duplicates. Fail + # loud on it, exactly as a splice would. + _require_intact_section(body) + self._labels = set(_parse_variants_section(body)) + except StoreError as exc: + self._failure = exc + raise if self._number is None: return None if label in self._labels or self._number in self._folded.get(template_fingerprint, set()): @@ -876,10 +887,12 @@ def _process_template( cooldown_minutes: int, open_burst: _OpenBurst, ) -> list[str]: - """Handle every pending row of one template together. Rows are grouped - because two variants of the same template in one scan belong in one issue: - processing them one by one would make the first a cooldown touch for the - second and silently drop that variant.""" + """Handle every pending row of one template together. + + Two variants of the same template in one scan belong in one issue, so they + are collected into a single create or update and a single comment rather + than one round trip each. Cooldown safety is not what grouping buys: the + history snapshot is taken before the scan marks anything.""" lines: list[str] = [] excerpts: list[str] = [] for _row, seen_payload in members: diff --git a/tests/test_error_issue_act.py b/tests/test_error_issue_act.py index 92c41b4..3efe0d9 100644 --- a/tests/test_error_issue_act.py +++ b/tests/test_error_issue_act.py @@ -759,9 +759,9 @@ def runner(argv: list[str]) -> Completed: def test_scan_merges_two_variants_of_one_template_into_one_issue(tmp_path: Path) -> None: - """Two variants of the same template in one scan belong in one issue. Handled - row by row, the first would become a cooldown touch for the second and that - variant would be dropped.""" + """Two variants of the same template in one scan belong in one issue, written + once with a single comment rather than one round trip each. Cooldown safety + comes from the history snapshot, not from this grouping.""" store = Store(tmp_path) _runner_session(store) _seen( @@ -1597,3 +1597,31 @@ def runner(argv: list[str]) -> Completed: row = store.row("activity", "issue-1") assert row is not None assert "damaged variants section" in row["execution_error"] + + +def test_a_damaged_burst_body_is_only_fetched_once(tmp_path: Path) -> None: + """The lookup promises one fetch per scan. Without caching the failure, every + later template would repeat both gh calls against the same broken body.""" + store = Store(tmp_path) + _runner_session(store) + for i in range(3): + _seen_and_issue(store, index=i, template_fingerprint=f"api|error|t{i}|prod") + damaged = f"{STORM_MARKER}\n\n{_VARIANTS_START}\n\n| a | t1 | t1 |\n" + calls: list[list[str]] = [] + + def runner(argv: list[str]) -> Completed: + calls.append(list(argv)) + if argv[:3] == ["gh", "issue", "list"]: + return Completed(0, json.dumps([{"number": 99, "body": STORM_MARKER}]), "") + if argv[:3] == ["gh", "issue", "view"]: + return Completed(0, json.dumps({"body": damaged}), "") + return Completed(0, "", "") + + lines = scan_error_issue( + store, runner, issue_repo="org/tracker", dry_run=False, cooldown_minutes=0 + ) + assert len(lines) == 3 + assert all(line.endswith("error") for line in lines) + # One list + one view for the burst issue, plus the per-template marker + # lookups; the damaged body must not be re-fetched per template. + assert len([c for c in calls if c[:3] == ["gh", "issue", "view"]]) == 1 From 5d1866f949e95dfc1812a64f54a8ca69a7824065 Mon Sep 17 00:00:00 2001 From: Danswar <48102227+Danswar@users.noreply.github.com> Date: Tue, 1 Sep 2026 01:15:15 -0300 Subject: [PATCH 27/43] Describe the create path as it is: no comment, one opening table. --- src/agent_cli/error_issue_act.py | 21 +++++++++++++-------- tests/test_error_issue_act.py | 7 ++++--- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/src/agent_cli/error_issue_act.py b/src/agent_cli/error_issue_act.py index 851bba4..41cbd8e 100644 --- a/src/agent_cli/error_issue_act.py +++ b/src/agent_cli/error_issue_act.py @@ -18,9 +18,10 @@ never opens that window. All pending rows of one template are handled together, so two variants seen in -the same run land in one issue with one comment instead of one issue write and -one comment each. The cooldown cannot mistake them for repeats either way: the -history snapshot is taken before the scan marks anything. +the same run land in one issue write instead of one each — a create carries them +in its opening table, an update splices them in and announces the genuinely new +ones in a single comment. The cooldown cannot mistake them for repeats either +way: the history snapshot is taken before the scan marks anything. Both are plain comparisons against local state; neither involves model judgment.""" @@ -849,8 +850,8 @@ def covers(self, label: str, template_fingerprint: str) -> int | None: gap. Both only count while a burst issue is actually open: once a human closes it, a template that recurs has earned its own issue.""" if self._failure is not None: - # Cache the failure too, or the guard below would send every later - # template through the same two gh calls to the same broken body. + # Re-raise a remembered failure rather than repeating the fetch for + # every later template against the same broken body. raise self._failure if self._labels is None: try: @@ -865,6 +866,8 @@ def covers(self, label: str, template_fingerprint: str) -> int | None: _require_intact_section(body) self._labels = set(_parse_variants_section(body)) except StoreError as exc: + # Remember it: the guard above turns this into one failed lookup + # per scan instead of one per template. self._failure = exc raise if self._number is None: @@ -890,9 +893,11 @@ def _process_template( """Handle every pending row of one template together. Two variants of the same template in one scan belong in one issue, so they - are collected into a single create or update and a single comment rather - than one round trip each. Cooldown safety is not what grouping buys: the - history snapshot is taken before the scan marks anything.""" + are collected into a single create or update rather than one round trip + each. A create writes them straight into its opening table; an update + splices them in and announces the genuinely new ones in one comment. + Cooldown safety is not what grouping buys: the history snapshot is taken + before the scan marks anything.""" lines: list[str] = [] excerpts: list[str] = [] for _row, seen_payload in members: diff --git a/tests/test_error_issue_act.py b/tests/test_error_issue_act.py index 3efe0d9..25a19de 100644 --- a/tests/test_error_issue_act.py +++ b/tests/test_error_issue_act.py @@ -759,9 +759,10 @@ def runner(argv: list[str]) -> Completed: def test_scan_merges_two_variants_of_one_template_into_one_issue(tmp_path: Path) -> None: - """Two variants of the same template in one scan belong in one issue, written - once with a single comment rather than one round trip each. Cooldown safety - comes from the history snapshot, not from this grouping.""" + """Two variants of the same template in one scan belong in one issue. Here + that issue does not exist yet, so both land in the opening table of a single + create — no update and no comment. Cooldown safety comes from the history + snapshot, not from this grouping.""" store = Store(tmp_path) _runner_session(store) _seen( From 70d842283b7d2505bec9b8539f82f9aff4b663ce Mon Sep 17 00:00:00 2001 From: Danswar <48102227+Danswar@users.noreply.github.com> Date: Tue, 1 Sep 2026 01:22:19 -0300 Subject: [PATCH 28/43] Split create from update in the design notes too. --- DESIGN.md | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 3f39bff..1d4da1c 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -694,13 +694,14 @@ The model never receives production credentials. Analysis that only reads the ex concrete variants live in a machine-owned, delimited section of the issue body; nothing outside that section is ever rewritten. A body carrying only one of the two markers, or a duplicated marker, is treated as damaged rather than appended -to. A genuinely new variant is also announced with one `Also seen on: …` -comment; that comment is best effort, so a comment that fails after the body -edit landed is recorded on the row rather than discarding the edit. Two -throttles sit in front: a burst fold, counted over templates never filed before -so a backlog draining after downtime is not mistaken for a burst, and a -per-template cooldown read from local history — a dry run is a preview and never -opens that window. +to. Creating an issue writes its variants straight into that opening table and +posts no comment; updating one splices them in and announces the genuinely new +ones in a single `Also seen on: …` comment. That comment is best effort, so one +that fails after the body edit landed is recorded on the row rather than +discarding the edit. Two throttles sit in front: a burst fold, counted over +templates never filed before so a backlog draining after downtime is not +mistaken for a burst, and a per-template cooldown read from local history — a +dry run is a preview and never opens that window. ## 22. Static supervise loop (v1) From a5139de61e5f1ecc5bc2699ea77d3b0c22426252 Mon Sep 17 00:00:00 2001 From: Danswar <48102227+Danswar@users.noreply.github.com> Date: Tue, 1 Sep 2026 01:44:24 -0300 Subject: [PATCH 29/43] Check the marker on the body that actually gets written. --- src/agent_cli/error_issue_act.py | 31 +++++++++++++++++++++-- tests/test_error_issue_act.py | 43 ++++++++++++++++++++++++++++++-- 2 files changed, 70 insertions(+), 4 deletions(-) diff --git a/src/agent_cli/error_issue_act.py b/src/agent_cli/error_issue_act.py index 41cbd8e..f8fa07d 100644 --- a/src/agent_cli/error_issue_act.py +++ b/src/agent_cli/error_issue_act.py @@ -208,6 +208,19 @@ def _render_variants_section(variants: dict[str, dict[str, str]]) -> str: return "\n".join(lines) +def _require_marker(body: str, marker: str) -> None: + """Raise unless this body still carries its marker exactly once. + + The marker was matched on the body from the issue list, but the body about + to be spliced is fetched again. If it lost its marker in between — a hand + edit, or the wrong issue coming back — writing to it anyway would leave an + issue this module can never find again, and the next scan would open a + second one for the same template.""" + found = body.count(marker) + if found != 1: + raise StoreError(f"issue body carries its marker {found} times, expected 1") + + def _require_intact_section(body: str) -> None: """Raise unless the body has no section at all or exactly one well-ordered marker pair. Shared so a reader and a writer judge damage the same way.""" @@ -517,7 +530,13 @@ def _create_issue( def _update_issue( - runner: Runner, *, issue_repo: str, number: int, variants: list[str], now: str + runner: Runner, + *, + issue_repo: str, + number: int, + marker: str, + variants: list[str], + now: str, ) -> tuple[list[str], str | None]: """Splice these variants into the issue's tracked table; comment once for the ones that are genuinely new. Returns the new variants and, if the comment @@ -530,6 +549,7 @@ def _update_issue( send that comment again (the variant is no longer new). So a comment failure is reported on the row instead of discarding the successful edit.""" body = _issue_body(runner, issue_repo, number) + _require_marker(body, marker) tracked = _parse_variants_section(body) new_variants = [v for v in variants if v not in tracked] for variant in variants: @@ -630,6 +650,7 @@ def _update_storm_issue( runner: Runner, *, issue_repo: str, number: int, templates: dict[str, dict[str, str]] ) -> None: body = _issue_body(runner, issue_repo, number) + _require_marker(body, STORM_MARKER) existing = _parse_variants_section(body) for name, entry in templates.items(): if name in existing: @@ -860,6 +881,7 @@ def covers(self, label: str, template_fingerprint: str) -> int | None: self._labels = set() else: body = _issue_body(self._runner, self._issue_repo, self._number) + _require_marker(body, STORM_MARKER) # A damaged section parses to nothing, which would read as # "this burst covers no template" and file duplicates. Fail # loud on it, exactly as a splice would. @@ -1021,7 +1043,12 @@ def fail_all(exc: StoreError) -> list[str]: try: new_variants, comment_error = _update_issue( - runner, issue_repo=issue_repo, number=number, variants=variants, now=now + runner, + issue_repo=issue_repo, + number=number, + marker=_marker_for(template_fp), + variants=variants, + now=now, ) except StoreError as exc: return fail_all(exc) diff --git a/tests/test_error_issue_act.py b/tests/test_error_issue_act.py index 25a19de..8f8002a 100644 --- a/tests/test_error_issue_act.py +++ b/tests/test_error_issue_act.py @@ -829,7 +829,11 @@ def runner(argv: list[str]) -> Completed: "", ) if argv[:3] == ["gh", "issue", "view"]: - return Completed(0, '{"body": "text\\n"}', "") + return Completed( + 0, + json.dumps({"body": f'{_marker_for("api|error|same|prod")}\n\ntext\n'}), + "", + ) return Completed(0, "", "") calls: list[list[str]] = [] @@ -863,7 +867,11 @@ def runner(argv: list[str]) -> Completed: "", ) if argv[:3] == ["gh", "issue", "view"]: - return Completed(0, '{"body": "text\\n"}', "") + return Completed( + 0, + json.dumps({"body": f'{_marker_for("api|error|abc123|prod")}\n\ntext\n'}), + "", + ) if argv[:3] == ["gh", "issue", "comment"]: return Completed(1, "", "rate limited") return Completed(0, "", "") @@ -1626,3 +1634,34 @@ def runner(argv: list[str]) -> Completed: # One list + one view for the burst issue, plus the per-template marker # lookups; the damaged body must not be re-fetched per template. assert len([c for c in calls if c[:3] == ["gh", "issue", "view"]]) == 1 + + +def test_an_issue_that_lost_its_marker_is_not_edited(tmp_path: Path) -> None: + """The marker is matched on the listed body, but the body that gets spliced + is fetched again. If it lost the marker in between, writing to it would + leave an issue this module can never find again and the next scan would open + a second one for the same template.""" + store = Store(tmp_path) + _runner_session(store) + _seen(store) + _issue(store) + + def runner(argv: list[str]) -> Completed: + if argv[:3] == ["gh", "issue", "list"]: + return Completed( + 0, + json.dumps([{"number": 12, "body": _marker_for("api|error|abc123|prod")}]), + "", + ) + if argv[:3] == ["gh", "issue", "view"]: + # Someone edited the marker away between the two calls. + return Completed(0, json.dumps({"body": "human rewrote this\n"}), "") + if argv[:3] == ["gh", "issue", "edit"]: + raise AssertionError(f"must not edit an issue that lost its marker: {argv}") + return Completed(0, "", "") + + lines = scan_error_issue(store, runner, issue_repo="org/tracker", dry_run=False) + assert lines == ["error.issue issue-1 error"] + row = store.row("activity", "issue-1") + assert row is not None + assert "carries its marker 0 times" in row["execution_error"] From 993a839cdb017ca2d443c3b9bdea1114b28ff76d Mon Sep 17 00:00:00 2001 From: Danswar <48102227+Danswar@users.noreply.github.com> Date: Tue, 1 Sep 2026 02:12:27 -0300 Subject: [PATCH 30/43] Anchor known names on Unicode word boundaries. --- src/agent_cli/errors.py | 6 ++++-- tests/test_errors.py | 10 ++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/agent_cli/errors.py b/src/agent_cli/errors.py index 7119524..9bee4d3 100644 --- a/src/agent_cli/errors.py +++ b/src/agent_cli/errors.py @@ -69,7 +69,9 @@ def _token_pattern(names: frozenset[str]) -> re.Pattern[str]: word character, so the short alternative would match and strip the ticker down to the wrong asset unless the longer one is tried first. - Anchored on both sides so a name only matches as a whole token: + Anchored on both sides with a word-character look-around so a name only + matches as a whole token. That class is Unicode-aware, so a ticker glued to + non-Latin letters or to an underscore is not a ticker either: without that, "Base" matches inside "Based", "SOL" inside "RESOLVE", "COMP" inside "COMPLETE" and "DAI" inside "DAILY", which would mask unrelated words and label an unrelated error as a chain/asset variant. The anchors are @@ -77,7 +79,7 @@ def _token_pattern(names: frozenset[str]) -> re.Pattern[str]: word-character-only ("USDC.e"). Matching stays case-sensitive on purpose: lowercase prose words like "usd" in "token tether -> usd" are not tickers.""" alternation = "|".join(re.escape(name) for name in sorted(names, key=len, reverse=True)) - return re.compile(rf"(? None: match inside "USDC.e" and strip it down to the wrong asset.""" assert known_asset_in("USDC.e drift on Arbitrum") == "USDC.e" assert known_asset_in("USDC drift on Arbitrum") == "USDC" + + +def test_token_boundaries_are_unicode_aware() -> None: + """An ASCII-only boundary class would let a ticker glued to non-Latin letters + or to an underscore still count as a whole token.""" + assert known_asset_in("ЖUSDCб drift") is None + assert known_asset_in("USDC_balance drift") is None + assert known_chain_in("Ethereumб handler") is None + # Real names next to ordinary punctuation still match. + assert known_asset_in("balance for USDC, low") == "USDC" From a4d8a8dc106833058f3f3176843c650287d8f459 Mon Sep 17 00:00:00 2001 From: Danswar <48102227+Danswar@users.noreply.github.com> Date: Tue, 1 Sep 2026 02:12:29 -0300 Subject: [PATCH 31/43] Guard the body that is written and the fields that go public. --- src/agent_cli/error_issue_act.py | 60 ++++++++++---------- tests/test_error_issue_act.py | 94 +++++++++++++++++++++++++++++++- 2 files changed, 121 insertions(+), 33 deletions(-) diff --git a/src/agent_cli/error_issue_act.py b/src/agent_cli/error_issue_act.py index f8fa07d..f1fafc8 100644 --- a/src/agent_cli/error_issue_act.py +++ b/src/agent_cli/error_issue_act.py @@ -35,7 +35,7 @@ from datetime import datetime, timedelta, timezone from typing import Any -from .errors import known_asset_in, known_chain_in +from .errors import known_asset_in, known_chain_in, redact from .runtime import Completed from .store import Store, StoreError, utcnow @@ -316,6 +316,10 @@ def _touch_history(store: Store, issue_repo: str) -> dict[str, datetime]: template_fingerprint = result.get("template_fingerprint") if not isinstance(template_fingerprint, str): continue + # A row that names no issue never touched one; a corrupted or partial + # result must not open a cooldown window with nothing behind it. + if result.get("number") is None and result.get("url") is None: + continue touched_at = result.get("at") if not isinstance(touched_at, str): continue @@ -557,18 +561,15 @@ def _update_issue( tracked[variant]["last_seen"] = now else: tracked[variant] = {"first_seen": now, "last_seen": now} + spliced = _splice_variants(body, tracked) + # The marker was checked on the body we read; check it again on the body we + # are about to write. A marker hand-moved inside the machine-owned section + # would otherwise be deleted by the splice, leaving an issue this module can + # never find again. + _require_marker(spliced, marker) _gh( runner, - [ - "gh", - "issue", - "edit", - str(number), - "--repo", - issue_repo, - "--body", - _splice_variants(body, tracked), - ], + ["gh", "issue", "edit", str(number), "--repo", issue_repo, "--body", spliced], "gh issue edit failed", ) if not new_variants: @@ -593,6 +594,11 @@ def _update_issue( return new_variants, None +def _field(seen_payload: dict[str, Any], key: str, fallback: str) -> str: + value = seen_payload.get(key) + return value if isinstance(value, str) and value else fallback + + def _storm_label(template_fingerprint: str, seen_payload: dict[str, Any]) -> str: """One burst-table row per template: readable text plus a digest that makes it unique. @@ -605,12 +611,12 @@ def _storm_label(template_fingerprint: str, seen_payload: dict[str, Any]) -> str fingerprint rather than a slice of it, so distinct templates keep distinct rows whatever the text does — otherwise one of them would be missing from the burst issue while a row still claimed to cover it.""" - service = seen_payload.get("service") - service = service if isinstance(service, str) and service else "unknown" - cls = seen_payload.get("class") - cls = cls if isinstance(cls, str) and cls else "error" - environment = seen_payload.get("environment") - environment = environment if isinstance(environment, str) and environment else "unknown" + # class is already derived from a redacted line; service and environment are + # stream labels that never went through redact(), and both end up in a public + # issue. Redact them here rather than trust their source. + service = redact(_field(seen_payload, "service", "unknown")) + cls = _field(seen_payload, "class", "error") + environment = redact(_field(seen_payload, "environment", "unknown")) digest = hashlib.sha256(template_fingerprint.encode("utf-8")).hexdigest()[:12] return _inert_cell(f"{service}/{environment}: {cls} ({digest})") @@ -657,18 +663,11 @@ def _update_storm_issue( existing[name]["last_seen"] = entry["last_seen"] else: existing[name] = dict(entry) + spliced = _splice_variants(body, existing) + _require_marker(spliced, STORM_MARKER) _gh( runner, - [ - "gh", - "issue", - "edit", - str(number), - "--repo", - issue_repo, - "--body", - _splice_variants(body, existing), - ], + ["gh", "issue", "edit", str(number), "--repo", issue_repo, "--body", spliced], "gh issue edit failed", ) @@ -1006,10 +1005,9 @@ def fail_all(exc: StoreError) -> list[str]: ) lines.append(f"error.issue {rid} already-in-burst number={burst_number}") return lines - service = first_payload.get("service") - service = service if isinstance(service, str) and service else "unknown" - cls = first_payload.get("class") - cls = cls if isinstance(cls, str) and cls else "error" + # Same reasoning as _storm_label: this title is public. + service = redact(_field(first_payload, "service", "unknown")) + cls = _field(first_payload, "class", "error") try: url = _create_issue( runner, diff --git a/tests/test_error_issue_act.py b/tests/test_error_issue_act.py index 8f8002a..444ab59 100644 --- a/tests/test_error_issue_act.py +++ b/tests/test_error_issue_act.py @@ -460,17 +460,18 @@ def _prior_touch( mode: str | None = None, number: int | None = None, ) -> None: + # A real touch always names the issue it touched; touch history ignores rows + # that do not, so the default here has to carry one too. result: dict[str, object] = { "issue_repo": "org/tracker", "template_fingerprint": template_fingerprint, "at": at, + "number": number if number is not None else 7, } if skipped: result["skipped"] = "cooldown" if mode is not None: result["mode"] = mode - if number is not None: - result["number"] = number store.write( "activity", "insert", @@ -1665,3 +1666,92 @@ def runner(argv: list[str]) -> Completed: row = store.row("activity", "issue-1") assert row is not None assert "carries its marker 0 times" in row["execution_error"] + + +def test_a_marker_moved_into_the_section_is_not_spliced_away(tmp_path: Path) -> None: + """The marker is checked on the body we read; it has to hold on the body we + write too. One hand-moved inside the machine-owned section would otherwise be + deleted by the splice, leaving an issue this module can never find again.""" + store = Store(tmp_path) + _runner_session(store) + _seen(store) + _issue(store) + marker = _marker_for("api|error|abc123|prod") + # A human moved the marker inside the tracked section. + body = ( + "notes\n\n" + f"{_VARIANTS_START}\n\n" + "| variant | first seen | last seen |\n|---|---|---|\n" + f"| Ethereum | t1 | t1 |\n\n{marker}\n\n" + f"{_VARIANTS_END}\n" + ) + + def runner(argv: list[str]) -> Completed: + if argv[:3] == ["gh", "issue", "list"]: + return Completed(0, json.dumps([{"number": 12, "body": marker}]), "") + if argv[:3] == ["gh", "issue", "view"]: + return Completed(0, json.dumps({"body": body}), "") + if argv[:3] == ["gh", "issue", "edit"]: + raise AssertionError(f"must not write a body that lost its marker: {argv}") + return Completed(0, "", "") + + lines = scan_error_issue(store, runner, issue_repo="org/tracker", dry_run=False) + assert lines == ["error.issue issue-1 error"] + row = store.row("activity", "issue-1") + assert row is not None + assert "carries its marker 0 times" in row["execution_error"] + + +def test_a_result_naming_no_issue_is_not_a_touch(tmp_path: Path) -> None: + """A corrupted or partial row would otherwise open a cooldown window with no + issue behind it and silently swallow the next real occurrence.""" + store = Store(tmp_path) + _runner_session(store) + store.write( + "activity", + "insert", + "partial-1", + { + "id": "partial-1", + "session_id": "runner-1", + "type": "error.issue", + "payload": {"error_id": "irrelevant"}, + "execution_status": "done", + # done, right repo, parseable timestamp — but names no issue. + "result": { + "issue_repo": "org/tracker", + "template_fingerprint": "api|error|abc|prod", + "at": "2026-08-31T10:00:00Z", + }, + }, + ) + assert _touch_history(store, "org/tracker") == {} + + +def test_public_issue_text_redacts_the_unredacted_fields(tmp_path: Path) -> None: + """class comes from an already-redacted line, but service and environment are + stream labels that never passed through redact() — and both reach a public + issue title or table row.""" + label = _storm_label( + "api|error|abc|prod", + {"service": "api-alice@example.com", "class": "error", "environment": "prod"}, + ) + assert "alice@example.com" not in label + assert "[redacted]" in label + + store = Store(tmp_path) + _runner_session(store) + _seen(store, service="api-alice@example.com") + _issue(store) + created: list[list[str]] = [] + + def runner(argv: list[str]) -> Completed: + created.append(list(argv)) + if argv[:3] == ["gh", "issue", "list"]: + return Completed(0, "[]", "") + return Completed(0, "https://github.com/org/tracker/issues/1\n", "") + + scan_error_issue(store, runner, issue_repo="org/tracker", dry_run=False) + title = created[-1][created[-1].index("--title") + 1] + assert "alice@example.com" not in title + assert title.startswith("[redacted]") From aff90e109a901f6531127c9e2957a51977d7afd6 Mon Sep 17 00:00:00 2001 From: Danswar <48102227+Danswar@users.noreply.github.com> Date: Tue, 1 Sep 2026 02:23:34 -0300 Subject: [PATCH 32/43] Do not fall back to a prefix ticker when the longer one is glued. --- src/agent_cli/errors.py | 18 ++++++++++++++++-- tests/test_errors.py | 14 ++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/agent_cli/errors.py b/src/agent_cli/errors.py index 9bee4d3..941ac88 100644 --- a/src/agent_cli/errors.py +++ b/src/agent_cli/errors.py @@ -78,8 +78,22 @@ def _token_pattern(names: frozenset[str]) -> re.Pattern[str]: explicit look-arounds rather than \b because several names are not word-character-only ("USDC.e"). Matching stays case-sensitive on purpose: lowercase prose words like "usd" in "token tether -> usd" are not tickers.""" - alternation = "|".join(re.escape(name) for name in sorted(names, key=len, reverse=True)) - return re.compile(rf"(? len(name) and not longer[len(name)].isalnum() + ) + alternatives.append(re.escape(name) + blockers) + return re.compile(rf"(? None: assert known_chain_in("Ethereumб handler") is None # Real names next to ordinary punctuation still match. assert known_asset_in("balance for USDC, low") == "USDC" + + +def test_a_prefix_name_does_not_survive_its_longer_form_losing_the_boundary() -> None: + """"USDC.e" glued to a word character fails its own trailing boundary. Without + blocking the continuation the engine falls back to "USDC", whose boundary + passes because "." is not a word character — so the same glued text would + yield a ticker here but None in "USDC_balance".""" + assert known_asset_in("USDC_balance") is None + assert known_asset_in("USDC.e_balance") is None + assert known_asset_in("USDC.eб") is None + # The longer name still matches on its own, and a sentence-final period is + # not a continuation. + assert known_asset_in("USDC.e drift on Arbitrum") == "USDC.e" + assert known_asset_in("balance in USDC.") == "USDC" From 269c68e984e0dff07e81fc643ddb9aacf5f6e0c0 Mon Sep 17 00:00:00 2001 From: Danswar <48102227+Danswar@users.noreply.github.com> Date: Tue, 1 Sep 2026 02:41:19 -0300 Subject: [PATCH 33/43] Mean word character where the boundary means word character. --- src/agent_cli/errors.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/agent_cli/errors.py b/src/agent_cli/errors.py index 941ac88..3ceb8ba 100644 --- a/src/agent_cli/errors.py +++ b/src/agent_cli/errors.py @@ -90,7 +90,12 @@ def _token_pattern(names: frozenset[str]) -> re.Pattern[str]: blockers = "".join( f"(?!{re.escape(longer[len(name):])})" for longer in ordered - if longer.startswith(name) and len(longer) > len(name) and not longer[len(name)].isalnum() + if longer.startswith(name) + and len(longer) > len(name) + # Word characters, not just alphanumerics: an underscore continuation + # is already handled by the trailing boundary below, so the predicate + # has to mean the same thing that boundary does. + and re.match(r"\w", longer[len(name)]) is None ) alternatives.append(re.escape(name) + blockers) return re.compile(rf"(? Date: Tue, 1 Sep 2026 02:55:33 -0300 Subject: [PATCH 34/43] Require a created issue to name a URL before it counts as touched. --- src/agent_cli/error_issue_act.py | 21 ++++++-- tests/test_error_issue_act.py | 88 ++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 3 deletions(-) diff --git a/src/agent_cli/error_issue_act.py b/src/agent_cli/error_issue_act.py index f1fafc8..93a799a 100644 --- a/src/agent_cli/error_issue_act.py +++ b/src/agent_cli/error_issue_act.py @@ -289,6 +289,15 @@ def _parse_iso(ts: str) -> datetime: return datetime.strptime(ts, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc) +def _names_an_issue(result: dict[str, Any]) -> bool: + """Whether a result actually points at an issue, by number or by url.""" + number = result.get("number") + if not isinstance(number, bool) and isinstance(number, int): + return True + url = result.get("url") + return isinstance(url, str) and url != "" + + def _touch_history(store: Store, issue_repo: str) -> dict[str, datetime]: """Most recent real touch per template, read once from local activity history. A touch is a completed error.issue row that actually created, @@ -317,8 +326,9 @@ def _touch_history(store: Store, issue_repo: str) -> dict[str, datetime]: if not isinstance(template_fingerprint, str): continue # A row that names no issue never touched one; a corrupted or partial - # result must not open a cooldown window with nothing behind it. - if result.get("number") is None and result.get("url") is None: + # result must not open a cooldown window with nothing behind it. An + # empty string names nothing either. + if not _names_an_issue(result): continue touched_at = result.get("at") if not isinstance(touched_at, str): @@ -514,7 +524,7 @@ def _create_issue( + _render_variants_section({v: {"first_seen": now, "last_seen": now} for v in variants}) + "\n" ) - return _gh( + url = _gh( runner, [ "gh", @@ -531,6 +541,11 @@ def _create_issue( ], "gh issue create failed", ).strip() + # Same check the burst path makes: a create that reports success without an + # issue URL would be recorded as a touch that names no issue. + if _ISSUE_URL.search(url) is None: + raise StoreError("gh issue create returned no issue URL") + return url def _update_issue( diff --git a/tests/test_error_issue_act.py b/tests/test_error_issue_act.py index 444ab59..3efb4ec 100644 --- a/tests/test_error_issue_act.py +++ b/tests/test_error_issue_act.py @@ -1755,3 +1755,91 @@ def runner(argv: list[str]) -> Completed: title = created[-1][created[-1].index("--title") + 1] assert "alice@example.com" not in title assert title.startswith("[redacted]") + + +def test_the_cooldown_window_is_half_open(tmp_path: Path) -> None: + """A touch exactly one window old is out of cooldown; a "<" that became "<=" + would otherwise hold the template one scan too long.""" + store = Store(tmp_path) + _runner_session(store) + _prior_touch( + store, + template_fingerprint="api|error|abc|prod", + at="2026-08-31T10:00:00Z", + activity_id="prior-1", + ) + history = _touch_history(store, "org/tracker") + assert _within_cooldown(history, "api|error|abc|prod", "2026-08-31T10:59:59Z", 60) is True + assert _within_cooldown(history, "api|error|abc|prod", "2026-08-31T11:00:00Z", 60) is False + + +def test_a_touch_named_only_by_url_still_counts(tmp_path: Path) -> None: + """The create path records a url and no number. Requiring a number would drop + every freshly created issue out of the cooldown history.""" + store = Store(tmp_path) + _runner_session(store) + store.write( + "activity", + "insert", + "url-only", + { + "id": "url-only", + "session_id": "runner-1", + "type": "error.issue", + "payload": {"error_id": "irrelevant"}, + "execution_status": "done", + "result": { + "issue_repo": "org/tracker", + "template_fingerprint": "api|error|abc|prod", + "at": "2026-08-31T10:00:00Z", + "url": "https://github.com/org/tracker/issues/4", + "created": True, + }, + }, + ) + assert "api|error|abc|prod" in _touch_history(store, "org/tracker") + + +def test_an_empty_url_names_no_issue(tmp_path: Path) -> None: + store = Store(tmp_path) + _runner_session(store) + store.write( + "activity", + "insert", + "empty-url", + { + "id": "empty-url", + "session_id": "runner-1", + "type": "error.issue", + "payload": {"error_id": "irrelevant"}, + "execution_status": "done", + "result": { + "issue_repo": "org/tracker", + "template_fingerprint": "api|error|abc|prod", + "at": "2026-08-31T10:00:00Z", + "url": "", + "created": True, + }, + }, + ) + assert _touch_history(store, "org/tracker") == {} + + +def test_a_create_reporting_no_url_fails_the_row(tmp_path: Path) -> None: + """gh exiting zero without an issue URL would otherwise be recorded as a + touch that names no issue.""" + store = Store(tmp_path) + _runner_session(store) + _seen(store) + _issue(store) + + def runner(argv: list[str]) -> Completed: + if argv[:3] == ["gh", "issue", "list"]: + return Completed(0, "[]", "") + return Completed(0, " \n", "") + + lines = scan_error_issue(store, runner, issue_repo="org/tracker", dry_run=False) + assert lines == ["error.issue issue-1 error"] + row = store.row("activity", "issue-1") + assert row is not None + assert "no issue URL" in row["execution_error"] From f58e24ee00f5c9ece1a418a75f89a78b6d40b18e Mon Sep 17 00:00:00 2001 From: Danswar <48102227+Danswar@users.noreply.github.com> Date: Tue, 1 Sep 2026 03:19:37 -0300 Subject: [PATCH 35/43] Hash redacted metadata into the template fingerprint. --- src/agent_cli/errors.py | 8 ++++++-- tests/test_errors.py | 32 ++++++++++++++++++++++++++++++-- 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/src/agent_cli/errors.py b/src/agent_cli/errors.py index 3ceb8ba..2f06367 100644 --- a/src/agent_cli/errors.py +++ b/src/agent_cli/errors.py @@ -611,11 +611,15 @@ def _apply_lines( stack_sig=stack_sig(redacted), environment=environment, ) + # Redacted here, not just where these reach a public issue: the marker in + # a public issue body is a digest of this fingerprint, so hashing the raw + # value would let a reader confirm a guessed one. fingerprint() keeps the + # raw value — it is local identity for already-stored error.seen rows. template_fp = template_fingerprint( - service=service, + service=redact(service), error_class=cls, template_sig=template_signature(redacted), - environment=environment, + environment=redact(environment), ) server = item.get("server") container = item.get("container") diff --git a/tests/test_errors.py b/tests/test_errors.py index 57bd0c4..9d07ffc 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -39,9 +39,16 @@ def _runner_session(store: Store, sid: str = "runner-1") -> None: ) -def _write_config(home: Path, session_id: str = "runner-1") -> None: +def _write_config(home: Path, session_id: str = "runner-1", service: str = "api") -> None: config_path(home).write_text( - json.dumps({"session_id": session_id, "service": "api", "environment": "prod", "repo": "org/app"}), + json.dumps( + { + "session_id": session_id, + "service": service, + "environment": "prod", + "repo": "org/app", + } + ), encoding="utf-8", ) @@ -1027,3 +1034,24 @@ def test_a_prefix_name_does_not_survive_its_longer_form_losing_the_boundary() -> # not a continuation. assert known_asset_in("USDC.e drift on Arbitrum") == "USDC.e" assert known_asset_in("balance in USDC.") == "USDC" + + +def test_the_template_fingerprint_hashes_redacted_metadata(tmp_path: Path) -> None: + """The marker in a public issue body is a digest of this fingerprint, so + hashing a raw stream label would let a reader confirm a guessed one by + recomputing it — exactly what redacting the visible text prevents. The + finer-grained fingerprint keeps the raw value: it is local identity for + already-stored error.seen rows.""" + store = Store(tmp_path) + _runner_session(store) + _write_config(tmp_path, service="api-alice@example.com") + + def fetch(_cfg: dict, _cursor: str | None) -> tuple[list[dict], str | None]: + return ([{"ts": "2026-08-23T16:00:00Z", "line": "TimeoutError boom"}], None) + + created, _ = scan_errors(store, fetch) + row = store.row("activity", created[0]) + assert row is not None + payload = row["payload"] + assert "alice@example.com" not in payload["template_fingerprint"] + assert "alice@example.com" in payload["fingerprint"] From 55ff023770b3a348dd0b743b75d41f5dd576f4d5 Mon Sep 17 00:00:00 2001 From: Danswar <48102227+Danswar@users.noreply.github.com> Date: Tue, 1 Sep 2026 03:19:39 -0300 Subject: [PATCH 36/43] Hold a recorded issue reference to the shape the writers produce. --- src/agent_cli/error_issue_act.py | 11 ++++++++--- tests/test_error_issue_act.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/src/agent_cli/error_issue_act.py b/src/agent_cli/error_issue_act.py index 93a799a..3d76d63 100644 --- a/src/agent_cli/error_issue_act.py +++ b/src/agent_cli/error_issue_act.py @@ -290,12 +290,17 @@ def _parse_iso(ts: str) -> datetime: def _names_an_issue(result: dict[str, Any]) -> bool: - """Whether a result actually points at an issue, by number or by url.""" + """Whether a result actually points at an issue, by number or by url. + + Held to the same shape the write paths produce: a real issue number is + positive, and a real url is one _ISSUE_URL recognises. A corrupted or + hand-edited row that merely carries a truthy value must not open a cooldown + window with nothing behind it.""" number = result.get("number") - if not isinstance(number, bool) and isinstance(number, int): + if not isinstance(number, bool) and isinstance(number, int) and number > 0: return True url = result.get("url") - return isinstance(url, str) and url != "" + return isinstance(url, str) and _ISSUE_URL.search(url) is not None def _touch_history(store: Store, issue_repo: str) -> dict[str, datetime]: diff --git a/tests/test_error_issue_act.py b/tests/test_error_issue_act.py index 3efb4ec..49d35f7 100644 --- a/tests/test_error_issue_act.py +++ b/tests/test_error_issue_act.py @@ -1843,3 +1843,35 @@ def runner(argv: list[str]) -> Completed: row = store.row("activity", "issue-1") assert row is not None assert "no issue URL" in row["execution_error"] + + +def test_a_result_with_an_implausible_issue_reference_is_not_a_touch(tmp_path: Path) -> None: + """The write paths only ever record a positive number or a real issue URL. + A hand-edited or corrupted row carrying some other truthy value must not + open a cooldown window with nothing behind it.""" + store = Store(tmp_path) + _runner_session(store) + for activity_id, result in ( + ("zero-number", {"number": 0}), + ("negative-number", {"number": -3}), + ("not-a-url", {"url": "failed"}), + ): + store.write( + "activity", + "insert", + activity_id, + { + "id": activity_id, + "session_id": "runner-1", + "type": "error.issue", + "payload": {"error_id": "irrelevant"}, + "execution_status": "done", + "result": { + "issue_repo": "org/tracker", + "template_fingerprint": "api|error|abc|prod", + "at": "2026-08-31T10:00:00Z", + **result, + }, + }, + ) + assert _touch_history(store, "org/tracker") == {} From 3e9c92478fa69e6a452d405e5f9a3204d8ff4c79 Mon Sep 17 00:00:00 2001 From: Danswar <48102227+Danswar@users.noreply.github.com> Date: Tue, 1 Sep 2026 03:29:25 -0300 Subject: [PATCH 37/43] Note in the design notes that the hashed metadata is redacted. --- DESIGN.md | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 1d4da1c..2cc100b 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -638,13 +638,16 @@ Log lines, stack traces, and error messages are untrusted data (§19.2). They ar `template_fingerprint` is `fingerprint` one step coarser: known blockchain and payment-rail names and asset tickers are masked before hashing, so per-chain and -per-token variants of one error share it. `service`, `class` and `environment` -are percent-escaped (`%`→`%25`, `|`→`%7C`) before the join, so two different -field tuples cannot serialize to one fingerprint. Names are masked only as whole tokens +per-token variants of one error share it. Names are masked only as whole tokens and case-sensitively, so prose like "Based" or lowercase "usd" is not mistaken -for a chain or a ticker. It groups which issue a variant belongs to (§21.6); -`fingerprint` stays the finer-grained identity used for `count` / `last_seen`, -so per-variant dedup remains exact. +for a chain or a ticker. `service` and `environment` go through `redact()` first +— the hidden marker in a public issue is a digest of this fingerprint, so +hashing a raw stream label would let a reader confirm a guessed one — and then +`service`, `class` and `environment` are percent-escaped (`%`→`%25`, `|`→`%7C`) +before the join, so two different field tuples cannot serialize to one +fingerprint. It groups which issue a variant belongs to (§21.6); `fingerprint` +keeps the raw values and stays the finer-grained identity used for `count` / +`last_seen`, so per-variant dedup remains exact. `repo` may be omitted when the adapter cannot map the stream; the session then `error.skip`s with reason `unmapped-repo`. `line_fingerprint` is optional: `sha256(server + newline + container + newline + exact line)` as 64 lowercase hex, computed from the raw line before redaction. Omit it when `server` or `container` is missing. Host adapters may print the hex on `error.fix` stdout; it is not a mandate and not a log-host name. From 6ec9780d4a489116bab569de6adbf60e6f69b3b2 Mon Sep 17 00:00:00 2001 From: Danswar <48102227+Danswar@users.noreply.github.com> Date: Tue, 1 Sep 2026 04:19:44 -0300 Subject: [PATCH 38/43] Salt the public marker instead of blurring the grouping key. --- DESIGN.md | 18 ++--- src/agent_cli/error_issue_act.py | 72 +++++++++++++++++--- src/agent_cli/errors.py | 8 +-- tests/test_error_issue_act.py | 110 +++++++++++++++++++++++++------ tests/test_errors.py | 34 ++++++---- 5 files changed, 188 insertions(+), 54 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 2cc100b..7117d02 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -640,14 +640,16 @@ Log lines, stack traces, and error messages are untrusted data (§19.2). They ar payment-rail names and asset tickers are masked before hashing, so per-chain and per-token variants of one error share it. Names are masked only as whole tokens and case-sensitively, so prose like "Based" or lowercase "usd" is not mistaken -for a chain or a ticker. `service` and `environment` go through `redact()` first -— the hidden marker in a public issue is a digest of this fingerprint, so -hashing a raw stream label would let a reader confirm a guessed one — and then -`service`, `class` and `environment` are percent-escaped (`%`→`%25`, `|`→`%7C`) -before the join, so two different field tuples cannot serialize to one -fingerprint. It groups which issue a variant belongs to (§21.6); `fingerprint` -keeps the raw values and stays the finer-grained identity used for `count` / -`last_seen`, so per-variant dedup remains exact. +for a chain or a ticker. `service`, `class` and `environment` are +percent-escaped (`%`→`%25`, `|`→`%7C`) before the join, so two different field +tuples cannot serialize to one fingerprint. They keep their raw values: +grouping has to stay injective, or two +tenants whose labels merely look alike after redaction would file into one +issue. The hidden marker in a public issue is a digest of this fingerprint +salted with the device id, so a reader cannot confirm a guessed stream label by +recomputing it. It groups which issue a variant belongs to (§21.6); +`fingerprint` stays the finer-grained identity used for `count` / `last_seen`, +so per-variant dedup remains exact. `repo` may be omitted when the adapter cannot map the stream; the session then `error.skip`s with reason `unmapped-repo`. `line_fingerprint` is optional: `sha256(server + newline + container + newline + exact line)` as 64 lowercase hex, computed from the raw line before redaction. Omit it when `server` or `container` is missing. Host adapters may print the hex on `error.fix` stdout; it is not a mandate and not a log-host name. diff --git a/src/agent_cli/error_issue_act.py b/src/agent_cli/error_issue_act.py index 3d76d63..74d002d 100644 --- a/src/agent_cli/error_issue_act.py +++ b/src/agent_cli/error_issue_act.py @@ -143,16 +143,27 @@ def _error_seen(store: Store, session_id: str, error_id: str) -> dict[str, Any]: return row -def _marker_for(template_fingerprint: str) -> str: +def _marker_for(template_fingerprint: str, salt: str) -> str: """The hidden marker that identifies a template's issue. It carries a digest rather than the fingerprint itself: service, class and environment are free text from the log source, so a raw fingerprint could close the HTML comment early or embed this module's own section markers, which would corrupt the body and lose the marker the next lookup needs. The - marker is invisible to readers anyway, so nothing is lost by hashing it.""" - digest = hashlib.sha256(template_fingerprint.encode("utf-8")).hexdigest()[:32] - return f"{_MARKER_PREFIX}{digest}{_MARKER_SUFFIX}" + marker is invisible to readers anyway, so nothing is lost by hashing it. + + The digest is salted with the device id. The fingerprint stays injective — + grouping must not merge two services — but an unsalted digest of it sits in + a public issue, where a reader could confirm a guessed stream label by + recomputing it. The salt is local, so the marker stays stable for the one + device that owns this loop (§21.1) and tells a reader nothing.""" + try: + seed = f"{salt}\x00{template_fingerprint}".encode("utf-8") + except UnicodeEncodeError as exc: + # A lone surrogate can reach here from an untrusted stream label. Fail + # this row rather than the scan, as line_fingerprint already does. + raise StoreError(f"template fingerprint is not encodable: {exc}") from exc + return f"{_MARKER_PREFIX}{hashlib.sha256(seed).hexdigest()[:32]}{_MARKER_SUFFIX}" def _extract_variant(excerpt: str) -> str: @@ -303,6 +314,35 @@ def _names_an_issue(result: dict[str, Any]) -> bool: return isinstance(url, str) and _ISSUE_URL.search(url) is not None +def _filed_templates(store: Store, issue_repo: str) -> set[str]: + """Templates this device has ever filed an issue for in this repo. + + Deliberately independent of whether the row carries a parseable timestamp: + a row with a damaged `at` still proves an issue exists. Treating such a + template as never filed would let a burst fold it in without ever looking + up the issue it already has, orphaning that issue for good.""" + filed: set[str] = set() + origin = store.device_id() + for row in store.rows("activity"): + if row.get("_origin_device_id") != origin or row.get("type") != "error.issue": + continue + if row.get("execution_status") != "done": + continue + result = row.get("result") + if not isinstance(result, dict) or result.get("skipped"): + continue + if result.get("mode") in _DRY_RUN_MODES: + continue + if result.get("issue_repo") != issue_repo: + continue + if not _names_an_issue(result): + continue + template_fingerprint = result.get("template_fingerprint") + if isinstance(template_fingerprint, str): + filed.add(template_fingerprint) + return filed + + def _touch_history(store: Store, issue_repo: str) -> dict[str, datetime]: """Most recent real touch per template, read once from local activity history. A touch is a completed error.issue row that actually created, @@ -517,13 +557,13 @@ def _create_issue( *, issue_repo: str, title: str, - template_fingerprint: str, + marker: str, excerpt: str, variants: list[str], now: str, ) -> str: body = _ensure_issue_body( - f"{_marker_for(template_fingerprint)}\n\n" + f"{marker}\n\n" "Automated error-log finding.\n\n" f"{_fenced(_inert_block(excerpt))}\n\n" + _render_variants_section({v: {"first_seen": now, "last_seen": now} for v in variants}) @@ -823,7 +863,11 @@ def _scan_error_issue( # Burst detection counts only templates never filed before. A backlog of # already-tracked templates draining after downtime is a volume spike, not a # burst of new problems, and must keep updating its own issues normally. - new_templates = [fp for fp in groups if fp not in history] + # Asked of the filed set rather than the cooldown history: a row whose + # timestamp is unparseable still proves an issue exists, and folding that + # template into a burst would orphan it. + filed = _filed_templates(store, issue_repo) + new_templates = [fp for fp in groups if fp not in filed] if len(new_templates) > storm_threshold: storm_rows = [ (row, template_fp, seen_payload) @@ -940,6 +984,14 @@ def _process_template( Cooldown safety is not what grouping buys: the history snapshot is taken before the scan marks anything.""" lines: list[str] = [] + try: + marker = _marker_for(template_fp, store.device_id()) + except StoreError as exc: + for row, _seen_payload in members: + rid = str(row.get("id") or "?") + _mark(store, row, status="error", error=str(exc)) + lines.append(f"error.issue {rid} error") + return lines excerpts: list[str] = [] for _row, seen_payload in members: excerpt = seen_payload.get("excerpt") @@ -995,7 +1047,7 @@ def fail_all(exc: StoreError) -> list[str]: return lines try: - number = _find_issue_number(runner, issue_repo, _marker_for(template_fp)) + number = _find_issue_number(runner, issue_repo, marker) except StoreError as exc: return fail_all(exc) @@ -1033,7 +1085,7 @@ def fail_all(exc: StoreError) -> list[str]: runner, issue_repo=issue_repo, title=f"{service}: {cls}", - template_fingerprint=template_fp, + marker=marker, excerpt=excerpts[0][:1000], variants=variants, now=now, @@ -1064,7 +1116,7 @@ def fail_all(exc: StoreError) -> list[str]: runner, issue_repo=issue_repo, number=number, - marker=_marker_for(template_fp), + marker=marker, variants=variants, now=now, ) diff --git a/src/agent_cli/errors.py b/src/agent_cli/errors.py index 2f06367..3ceb8ba 100644 --- a/src/agent_cli/errors.py +++ b/src/agent_cli/errors.py @@ -611,15 +611,11 @@ def _apply_lines( stack_sig=stack_sig(redacted), environment=environment, ) - # Redacted here, not just where these reach a public issue: the marker in - # a public issue body is a digest of this fingerprint, so hashing the raw - # value would let a reader confirm a guessed one. fingerprint() keeps the - # raw value — it is local identity for already-stored error.seen rows. template_fp = template_fingerprint( - service=redact(service), + service=service, error_class=cls, template_sig=template_signature(redacted), - environment=redact(environment), + environment=environment, ) server = item.get("server") container = item.get("container") diff --git a/tests/test_error_issue_act.py b/tests/test_error_issue_act.py index 49d35f7..14f069f 100644 --- a/tests/test_error_issue_act.py +++ b/tests/test_error_issue_act.py @@ -24,6 +24,7 @@ _render_variants_section, scan_error_issue, _splice_variants, + _filed_templates, _touch_history, ) from agent_cli.runtime import Completed @@ -106,22 +107,48 @@ def test_extract_variant_combines_chain_and_asset() -> None: assert _extract_variant("Balance for Base/WBTC went low") == "Base/WBTC" -def test_marker_is_a_digest_and_never_carries_raw_text() -> None: +def test_marker_is_a_salted_digest_and_never_carries_raw_text() -> None: """service, class and environment are free text from the log source. A raw fingerprint in the marker could close the HTML comment early or embed the - section markers, corrupting the body and losing the marker the next lookup - needs.""" - marker = _marker_for("api|error|abc123|prod") + section markers. The digest is salted because it sits in a public issue: an + unsalted one would let a reader confirm a guessed stream label.""" + marker = _marker_for("api|error|abc123|prod", "device-1") assert marker.startswith("") - assert _marker_for("api|error|abc123|prod") == marker - assert _marker_for("api|error|abc123|staging") != marker + assert _marker_for("api|error|abc123|prod", "device-1") == marker + assert _marker_for("api|error|abc123|staging", "device-1") != marker + assert _marker_for("api|error|abc123|prod", "device-2") != marker - hostile = _marker_for(f"api --> {_VARIANTS_START}|error|abc|prod") + hostile = _marker_for(f"api --> {_VARIANTS_START}|error|abc|prod", "device-1") assert hostile.count("-->") == 1 assert _VARIANTS_START not in hostile +def test_a_marker_that_cannot_be_encoded_fails_the_row(tmp_path: Path) -> None: + """A lone surrogate can reach the fingerprint from an untrusted stream label. + Unguarded it would abort the whole scan and recur on every later one, leaving + this and every following template pending for good.""" + store = Store(tmp_path) + _runner_session(store) + _seen(store, template_fingerprint="api|error|abc|\ud800") + _issue(store) + _seen(store, template_fingerprint="api|error|fine|prod", activity_id="seen-ok") + _issue(store, error_id="seen-ok", activity_id="issue-ok") + + def runner(argv: list[str]) -> Completed: + if argv[:3] == ["gh", "issue", "list"]: + return Completed(0, "[]", "") + return Completed(0, "https://github.com/org/tracker/issues/1\n", "") + + lines = scan_error_issue(store, runner, issue_repo="org/tracker", dry_run=False) + # The bad row fails; the good one is still processed. + assert "error.issue issue-1 error" in lines + assert any("issue-ok created" in line for line in lines) + bad = store.row("activity", "issue-1") + assert bad is not None + assert "not encodable" in bad["execution_error"] + + def test_variants_section_round_trips() -> None: variants = { "Ethereum": {"first_seen": "2026-08-31T10:00:00Z", "last_seen": "2026-08-31T10:00:00Z"}, @@ -295,7 +322,7 @@ def runner(argv: list[str]) -> Completed: assert "--repo" in create_call and "org/tracker" in create_call assert "--label" in create_call and ISSUE_LABEL in create_call body = create_call[create_call.index("--body") + 1] - assert _marker_for("api|error|abc123|prod") in body + assert _marker_for("api|error|abc123|prod", store.device_id()) in body assert "Ethereum" in body row = store.row("activity", "issue-1") assert row is not None @@ -313,7 +340,7 @@ def test_scan_updates_existing_issue_same_variant_no_comment(tmp_path: Path) -> _seen(store) _issue(store) existing_body = ( - _marker_for("api|error|abc123|prod") + _marker_for("api|error|abc123|prod", store.device_id()) + "\n\nAutomated error-log finding.\n\n" + _render_variants_section({"Ethereum": {"first_seen": "t0", "last_seen": "t0"}}) + "\n" @@ -325,7 +352,7 @@ def runner(argv: list[str]) -> Completed: if argv[:3] == ["gh", "issue", "list"]: return Completed( 0, - json.dumps([{"number": 9, "body": _marker_for("api|error|abc123|prod")}]), + json.dumps([{"number": 9, "body": _marker_for("api|error|abc123|prod", store.device_id())}]), "", ) if argv[:3] == ["gh", "issue", "view"]: @@ -358,7 +385,7 @@ def test_scan_updates_existing_issue_new_variant_posts_comment(tmp_path: Path) - ) _issue(store) existing_body = ( - _marker_for("api|error|abc123|prod") + _marker_for("api|error|abc123|prod", store.device_id()) + "\n\nAutomated error-log finding.\n\n" + _render_variants_section({"Ethereum": {"first_seen": "t0", "last_seen": "t0"}}) + "\n" @@ -370,7 +397,7 @@ def runner(argv: list[str]) -> Completed: if argv[:3] == ["gh", "issue", "list"]: return Completed( 0, - json.dumps([{"number": 9, "body": _marker_for("api|error|abc123|prod")}]), + json.dumps([{"number": 9, "body": _marker_for("api|error|abc123|prod", store.device_id())}]), "", ) if argv[:3] == ["gh", "issue", "view"]: @@ -826,13 +853,13 @@ def runner(argv: list[str]) -> Completed: if argv[:3] == ["gh", "issue", "list"]: return Completed( 0, - json.dumps([{"number": 12, "body": _marker_for("api|error|same|prod")}]), + json.dumps([{"number": 12, "body": _marker_for("api|error|same|prod", store.device_id())}]), "", ) if argv[:3] == ["gh", "issue", "view"]: return Completed( 0, - json.dumps({"body": f'{_marker_for("api|error|same|prod")}\n\ntext\n'}), + json.dumps({"body": f'{_marker_for("api|error|same|prod", store.device_id())}\n\ntext\n'}), "", ) return Completed(0, "", "") @@ -864,13 +891,13 @@ def runner(argv: list[str]) -> Completed: if argv[:3] == ["gh", "issue", "list"]: return Completed( 0, - json.dumps([{"number": 12, "body": _marker_for("api|error|abc123|prod")}]), + json.dumps([{"number": 12, "body": _marker_for("api|error|abc123|prod", store.device_id())}]), "", ) if argv[:3] == ["gh", "issue", "view"]: return Completed( 0, - json.dumps({"body": f'{_marker_for("api|error|abc123|prod")}\n\ntext\n'}), + json.dumps({"body": f'{_marker_for("api|error|abc123|prod", store.device_id())}\n\ntext\n'}), "", ) if argv[:3] == ["gh", "issue", "comment"]: @@ -1006,7 +1033,7 @@ def test_create_paths_apply_the_same_body_ceiling() -> None: _unreachable_runner, issue_repo="org/tracker", title="api: error", - template_fingerprint="api|error|abc|prod", + marker=_marker_for("api|error|abc|prod", "device-1"), excerpt=huge, variants=["generic"], now="2026-08-31T10:00:00Z", @@ -1651,7 +1678,7 @@ def runner(argv: list[str]) -> Completed: if argv[:3] == ["gh", "issue", "list"]: return Completed( 0, - json.dumps([{"number": 12, "body": _marker_for("api|error|abc123|prod")}]), + json.dumps([{"number": 12, "body": _marker_for("api|error|abc123|prod", store.device_id())}]), "", ) if argv[:3] == ["gh", "issue", "view"]: @@ -1676,7 +1703,7 @@ def test_a_marker_moved_into_the_section_is_not_spliced_away(tmp_path: Path) -> _runner_session(store) _seen(store) _issue(store) - marker = _marker_for("api|error|abc123|prod") + marker = _marker_for("api|error|abc123|prod", store.device_id()) # A human moved the marker inside the tracked section. body = ( "notes\n\n" @@ -1875,3 +1902,48 @@ def test_a_result_with_an_implausible_issue_reference_is_not_a_touch(tmp_path: P }, ) assert _touch_history(store, "org/tracker") == {} + + +def test_a_filed_template_is_never_folded_into_a_burst(tmp_path: Path) -> None: + """A row with a damaged timestamp still proves an issue exists. Judging + "never filed" by the cooldown history would fold that template into a burst + without ever looking up the issue it already has, orphaning it.""" + store = Store(tmp_path) + _runner_session(store) + for i in range(3): + _seen_and_issue(store, index=i, template_fingerprint=f"api|error|t{i}|prod") + # t0 was filed before, but its timestamp is unusable. + store.write( + "activity", + "insert", + "prior-damaged", + { + "id": "prior-damaged", + "session_id": "runner-1", + "type": "error.issue", + "payload": {"error_id": "irrelevant"}, + "execution_status": "done", + "result": { + "issue_repo": "org/tracker", + "template_fingerprint": "api|error|t0|prod", + "at": "not-a-date", + "number": 5, + "created": True, + }, + }, + ) + assert "api|error|t0|prod" in _filed_templates(store, "org/tracker") + assert "api|error|t0|prod" not in _touch_history(store, "org/tracker") + + def runner(argv: list[str]) -> Completed: + if argv[:3] == ["gh", "issue", "list"]: + return Completed(0, "[]", "") + return Completed(0, "https://github.com/org/tracker/issues/9\n", "") + + lines = scan_error_issue( + store, runner, issue_repo="org/tracker", dry_run=False, storm_threshold=2 + ) + # Only t1 and t2 are new, so the threshold is not crossed and nothing folds. + assert all("storm" not in (store.row("activity", f"storm-issue-{i}") or {}) + .get("result", {}).get("mode", "") for i in range(3)) + assert len(lines) == 3 diff --git a/tests/test_errors.py b/tests/test_errors.py index 9d07ffc..31cb292 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -1036,12 +1036,12 @@ def test_a_prefix_name_does_not_survive_its_longer_form_losing_the_boundary() -> assert known_asset_in("balance in USDC.") == "USDC" -def test_the_template_fingerprint_hashes_redacted_metadata(tmp_path: Path) -> None: - """The marker in a public issue body is a digest of this fingerprint, so - hashing a raw stream label would let a reader confirm a guessed one by - recomputing it — exactly what redacting the visible text prevents. The - finer-grained fingerprint keeps the raw value: it is local identity for - already-stored error.seen rows.""" +def test_the_template_fingerprint_keeps_services_apart(tmp_path: Path) -> None: + """Grouping must stay injective: two tenants whose service labels differ are + two templates, even where a coarse redaction would render both the same. + Keeping the raw value here is safe because the digest that reaches a public + issue is salted (see _marker_for); redacting the grouping key instead would + file two tenants' errors into one issue.""" store = Store(tmp_path) _runner_session(store) _write_config(tmp_path, service="api-alice@example.com") @@ -1050,8 +1050,20 @@ def fetch(_cfg: dict, _cursor: str | None) -> tuple[list[dict], str | None]: return ([{"ts": "2026-08-23T16:00:00Z", "line": "TimeoutError boom"}], None) created, _ = scan_errors(store, fetch) - row = store.row("activity", created[0]) - assert row is not None - payload = row["payload"] - assert "alice@example.com" not in payload["template_fingerprint"] - assert "alice@example.com" in payload["fingerprint"] + alice = store.row("activity", created[0]) + assert alice is not None + + bob = template_fingerprint( + service="api-bob@example.com", + error_class="TimeoutError", + template_sig="sig", + environment="prod", + ) + alice_fp = template_fingerprint( + service="api-alice@example.com", + error_class="TimeoutError", + template_sig="sig", + environment="prod", + ) + assert alice_fp != bob + assert alice["payload"]["template_fingerprint"].startswith("api-alice@example.com|") From bb6cc6f16549c5fc8d9afb3925542f17db338cf1 Mon Sep 17 00:00:00 2001 From: Danswar <48102227+Danswar@users.noreply.github.com> Date: Tue, 1 Sep 2026 09:58:24 -0300 Subject: [PATCH 39/43] Salt and guard the burst row digest the same way. --- src/agent_cli/error_issue_act.py | 65 +++++++++++++++++---------- tests/test_error_issue_act.py | 77 +++++++++++++++++++------------- tests/test_errors.py | 6 +-- 3 files changed, 89 insertions(+), 59 deletions(-) diff --git a/src/agent_cli/error_issue_act.py b/src/agent_cli/error_issue_act.py index 74d002d..c35467d 100644 --- a/src/agent_cli/error_issue_act.py +++ b/src/agent_cli/error_issue_act.py @@ -143,6 +143,21 @@ def _error_seen(store: Store, session_id: str, error_id: str) -> dict[str, Any]: return row +def _digest(template_fingerprint: str, salt: str) -> str: + """Salted digest of a fingerprint, for anything that reaches a public issue. + + The salt is the device id: local, stable for the one device that owns this + loop (§21.1), and unknown to a reader, so a guessed stream label cannot be + confirmed by recomputing the digest. A lone surrogate can reach the + fingerprint from an untrusted stream label; that fails this row rather than + the scan, as line_fingerprint already does.""" + try: + seed = f"{salt}\x00{template_fingerprint}".encode("utf-8") + except UnicodeEncodeError as exc: + raise StoreError(f"template fingerprint is not encodable: {exc}") from exc + return hashlib.sha256(seed).hexdigest() + + def _marker_for(template_fingerprint: str, salt: str) -> str: """The hidden marker that identifies a template's issue. @@ -152,18 +167,10 @@ def _marker_for(template_fingerprint: str, salt: str) -> str: which would corrupt the body and lose the marker the next lookup needs. The marker is invisible to readers anyway, so nothing is lost by hashing it. - The digest is salted with the device id. The fingerprint stays injective — + The digest is salted (see _digest): the fingerprint stays injective — grouping must not merge two services — but an unsalted digest of it sits in - a public issue, where a reader could confirm a guessed stream label by - recomputing it. The salt is local, so the marker stays stable for the one - device that owns this loop (§21.1) and tells a reader nothing.""" - try: - seed = f"{salt}\x00{template_fingerprint}".encode("utf-8") - except UnicodeEncodeError as exc: - # A lone surrogate can reach here from an untrusted stream label. Fail - # this row rather than the scan, as line_fingerprint already does. - raise StoreError(f"template fingerprint is not encodable: {exc}") from exc - return f"{_MARKER_PREFIX}{hashlib.sha256(seed).hexdigest()[:32]}{_MARKER_SUFFIX}" + a public issue, where a reader could confirm a guessed stream label.""" + return f"{_MARKER_PREFIX}{_digest(template_fingerprint, salt)[:32]}{_MARKER_SUFFIX}" def _extract_variant(excerpt: str) -> str: @@ -659,7 +666,7 @@ def _field(seen_payload: dict[str, Any], key: str, fallback: str) -> str: return value if isinstance(value, str) and value else fallback -def _storm_label(template_fingerprint: str, seen_payload: dict[str, Any]) -> str: +def _storm_label(template_fingerprint: str, seen_payload: dict[str, Any], salt: str) -> str: """One burst-table row per template: readable text plus a digest that makes it unique. @@ -670,15 +677,18 @@ def _storm_label(template_fingerprint: str, seen_payload: dict[str, Any]) -> str templates could render identical text. The digest is taken over the whole fingerprint rather than a slice of it, so distinct templates keep distinct rows whatever the text does — otherwise one of them would be missing from - the burst issue while a row still claimed to cover it.""" + the burst issue while a row still claimed to cover it. + + Salted like the marker, and for the same reason: this digest is written into + a public burst issue, and the fingerprint behind it is injective and raw, so + an unsalted digest would let a reader confirm a guessed stream label.""" # class is already derived from a redacted line; service and environment are # stream labels that never went through redact(), and both end up in a public # issue. Redact them here rather than trust their source. service = redact(_field(seen_payload, "service", "unknown")) cls = _field(seen_payload, "class", "error") environment = redact(_field(seen_payload, "environment", "unknown")) - digest = hashlib.sha256(template_fingerprint.encode("utf-8")).hexdigest()[:12] - return _inert_cell(f"{service}/{environment}: {cls} ({digest})") + return _inert_cell(f"{service}/{environment}: {cls} ({_digest(template_fingerprint, salt)[:12]})") def _create_storm_issue( @@ -741,15 +751,22 @@ def _process_storm( resolved: list[tuple[dict[str, Any], str, dict[str, Any]]], now: str, ) -> list[str]: - templates: dict[str, dict[str, str]] = {} - for _row, template_fp, seen_payload in resolved: - label = _storm_label(template_fp, seen_payload) - if label in templates: - templates[label]["last_seen"] = now - else: - templates[label] = {"first_seen": now, "last_seen": now} - lines: list[str] = [] + templates: dict[str, dict[str, str]] = {} + try: + for _row, template_fp, seen_payload in resolved: + label = _storm_label(template_fp, seen_payload, store.device_id()) + if label in templates: + templates[label]["last_seen"] = now + else: + templates[label] = {"first_seen": now, "last_seen": now} + except StoreError as exc: + # An unencodable fingerprint must fail these rows, not the scan. + for row, _template_fp, _seen_payload in resolved: + rid = str(row.get("id") or "?") + _mark(store, row, status="error", error=str(exc)) + lines.append(f"error.issue {rid} error") + return lines if dry_run: for row, template_fp, _seen_payload in resolved: @@ -1055,7 +1072,7 @@ def fail_all(exc: StoreError) -> list[str]: if number is None: try: burst_number = open_burst.covers( - _storm_label(template_fp, first_payload), template_fp + _storm_label(template_fp, first_payload, store.device_id()), template_fp ) except StoreError as exc: return fail_all(exc) diff --git a/tests/test_error_issue_act.py b/tests/test_error_issue_act.py index 14f069f..a0fb0fc 100644 --- a/tests/test_error_issue_act.py +++ b/tests/test_error_issue_act.py @@ -1139,7 +1139,7 @@ def runner(argv: list[str]) -> Completed: def test_storm_label_survives_a_round_trip_through_the_table() -> None: """service/class come from the error payload; a pipe or a marker there would break the row apart so the label would not parse back.""" - label = _storm_label("api|error|abc|prod", {"service": "a|b", "class": ""}) + label = _storm_label("api|error|abc|prod", {"service": "a|b", "class": ""}, "device-1") section = _render_variants_section({label: {"first_seen": "t1", "last_seen": "t1"}}) assert _parse_variants_section(section) == {label: {"first_seen": "t1", "last_seen": "t1"}} @@ -1196,7 +1196,7 @@ def storm_runner(argv: list[str]) -> Completed: # The burst issue is the only record that template t2 was already folded. burst_body = _render_variants_section( { - _storm_label(f"api|error|t{i}|prod", {"service": "api", "class": "error"}): { + _storm_label(f"api|error|t{i}|prod", {"service": "api", "class": "error"}, store.device_id()): { "first_seen": "2026-08-31T10:00:00Z", "last_seen": "2026-08-31T10:00:00Z", } @@ -1227,8 +1227,8 @@ def test_storm_label_separates_environments() -> None: """service|class|template-sig|environment: the same error in two environments is two templates, so one shared row would hide one and undercount the burst.""" payload = {"service": "api", "class": "error"} - assert _storm_label("api|error|abc123def|prod", payload) != _storm_label( - "api|error|abc123def|staging", payload + assert _storm_label("api|error|abc123def|prod", payload, "device-1") != _storm_label( + "api|error|abc123def|staging", payload, "device-1" ) @@ -1386,21 +1386,12 @@ def test_storm_label_is_unique_per_template_despite_separators(tmp_path: Path) - can contain the separators the fingerprint joins on and the table renders with. Two different templates must still get two rows, or one goes missing from the burst issue while a row claims to cover it.""" - first = _storm_label( - "api|error|abc123def|prod/eu", - {"service": "api", "class": "error", "environment": "prod/eu"}, - ) - second = _storm_label( - "api/prod|error|abc123def|eu", - {"service": "api/prod", "class": "error", "environment": "eu"}, - ) + first = _storm_label("api|error|abc123def|prod/eu", {"service": "api", "class": "error", "environment": "prod/eu"}, "device-1") + second = _storm_label("api/prod|error|abc123def|eu", {"service": "api/prod", "class": "error", "environment": "eu"}, "device-1") assert first != second # A pipe inside a component must not shift which field the label reports. - shifted = _storm_label( - "api|x|error|sigAAAA|prod", - {"service": "api|x", "class": "error", "environment": "prod"}, - ) + shifted = _storm_label("api|x|error|sigAAAA|prod", {"service": "api|x", "class": "error", "environment": "prod"}, "device-1") assert shifted.startswith("`api/x/prod: error (") @@ -1530,10 +1521,7 @@ def test_a_row_named_like_the_header_survives_the_round_trip(tmp_path: Path) -> """service is free text, so a label can begin with "variant". Matching the header by prefix would drop that row, and a template the burst issue already lists would be filed a second time.""" - label = _storm_label( - "variant|error|abc123def|prod", - {"service": "variant", "class": "error", "environment": "prod"}, - ) + label = _storm_label("variant|error|abc123def|prod", {"service": "variant", "class": "error", "environment": "prod"}, "device-1") assert label.startswith("`variant") section = _render_variants_section({label: {"first_seen": "t1", "last_seen": "t1"}}) assert _parse_variants_section(section) == {label: {"first_seen": "t1", "last_seen": "t1"}} @@ -1552,6 +1540,7 @@ def test_a_row_named_like_the_header_still_blocks_a_second_issue(tmp_path: Path) label = _storm_label( "variant|error|abc123def|prod", {"service": "variant", "class": "error", "environment": "prod"}, + store.device_id(), ) burst_body = f"{STORM_MARKER}\n\n" + _render_variants_section( {label: {"first_seen": "t1", "last_seen": "t1"}} @@ -1575,18 +1564,12 @@ def runner(argv: list[str]) -> Completed: def test_storm_label_renders_log_text_inertly() -> None: """service and class come from the log source. A bare mention or link in a table cell renders as a live mention or link in the issue.""" - label = _storm_label( - "api|error|abc|prod", - {"service": "@someone", "class": "[click](http://x)", "environment": "prod"}, - ) + label = _storm_label("api|error|abc|prod", {"service": "@someone", "class": "[click](http://x)", "environment": "prod"}, "device-1") assert label.startswith("`") and label.endswith("`") assert "@someone" in label # the text is kept, only made inert # A backtick cannot survive inside a single-backtick span. assert "`" not in label[1:-1] - spanned = _storm_label( - "api|error|abc|prod", - {"service": "a`b", "class": "error", "environment": "prod"}, - ) + spanned = _storm_label("api|error|abc|prod", {"service": "a`b", "class": "error", "environment": "prod"}, "device-1") assert "`" not in spanned[1:-1] @@ -1759,10 +1742,7 @@ def test_public_issue_text_redacts_the_unredacted_fields(tmp_path: Path) -> None """class comes from an already-redacted line, but service and environment are stream labels that never passed through redact() — and both reach a public issue title or table row.""" - label = _storm_label( - "api|error|abc|prod", - {"service": "api-alice@example.com", "class": "error", "environment": "prod"}, - ) + label = _storm_label("api|error|abc|prod", {"service": "api-alice@example.com", "class": "error", "environment": "prod"}, "device-1") assert "alice@example.com" not in label assert "[redacted]" in label @@ -1947,3 +1927,36 @@ def runner(argv: list[str]) -> Completed: assert all("storm" not in (store.row("activity", f"storm-issue-{i}") or {}) .get("result", {}).get("mode", "") for i in range(3)) assert len(lines) == 3 + + +def test_the_burst_row_digest_is_salted_too() -> None: + """The burst table lands in a public issue and its digest is taken over the + same injective, raw fingerprint the marker uses. Unsalted, a reader could + confirm a guessed stream label by recomputing it.""" + payload = {"service": "api", "class": "error", "environment": "prod"} + assert _storm_label("api|error|abc|prod", payload, "device-1") != _storm_label( + "api|error|abc|prod", payload, "device-2" + ) + + +def test_an_unencodable_fingerprint_fails_the_burst_rows_not_the_scan(tmp_path: Path) -> None: + """The burst path builds its labels before the gh call. Unguarded, a lone + surrogate there aborted the whole scan and recurred on every later one.""" + store = Store(tmp_path) + _runner_session(store) + for i in range(3): + _seen_and_issue(store, index=i, template_fingerprint=f"api|error|t{i}|\ud800") + + def runner(argv: list[str]) -> Completed: + if argv[:3] == ["gh", "issue", "list"]: + return Completed(0, "[]", "") + return Completed(0, "https://github.com/org/tracker/issues/9\n", "") + + lines = scan_error_issue( + store, runner, issue_repo="org/tracker", dry_run=False, storm_threshold=2 + ) + assert lines == [f"error.issue storm-issue-{i} error" for i in range(3)] + for i in range(3): + row = store.row("activity", f"storm-issue-{i}") + assert row is not None + assert "not encodable" in row["execution_error"] diff --git a/tests/test_errors.py b/tests/test_errors.py index 31cb292..62f3686 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -1039,9 +1039,9 @@ def test_a_prefix_name_does_not_survive_its_longer_form_losing_the_boundary() -> def test_the_template_fingerprint_keeps_services_apart(tmp_path: Path) -> None: """Grouping must stay injective: two tenants whose service labels differ are two templates, even where a coarse redaction would render both the same. - Keeping the raw value here is safe because the digest that reaches a public - issue is salted (see _marker_for); redacting the grouping key instead would - file two tenants' errors into one issue.""" + Keeping the raw value here is safe because every digest that reaches a + public issue is salted (the marker and the burst row alike); redacting the + grouping key instead would file two tenants' errors into one issue.""" store = Store(tmp_path) _runner_session(store) _write_config(tmp_path, service="api-alice@example.com") From cc91a88decb8da618a076eac85ae46caced974df Mon Sep 17 00:00:00 2001 From: Daniel Padrino Date: Tue, 1 Sep 2026 14:06:47 -0300 Subject: [PATCH 40/43] Drop error.issue GitHub-issue filing Remove error_issue_act.py and its tests along with the related design notes. Filing a GitHub issue per grouped error added an unnecessary external dependency with no real benefit over the existing local history and draft-pull-request path; the template_fingerprint grouping itself stays. --- DESIGN.md | 23 - src/agent_cli/error_issue_act.py | 1163 ------------------ tests/test_error_issue_act.py | 1962 ------------------------------ 3 files changed, 3148 deletions(-) delete mode 100644 src/agent_cli/error_issue_act.py delete mode 100644 tests/test_error_issue_act.py diff --git a/DESIGN.md b/DESIGN.md index 7117d02..b8e481a 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -332,7 +332,6 @@ v1 types (mechanism only): | `error.seen` | `error` | script | — (`NOTIFY` `agent_inbox`; error-fix skill, §21) | | `error.skip` | `error` | AI | — | | `error.fix` | `error` | AI | script + spine implement (draft pull request) | -| `error.issue` | `error` | AI | script (`error_issue_act`, §21.6) — scaffolded only: not yet a member of the `agent activity add` allowlist and not yet dispatched by a watch command | | `supervise.event` | `supervise` | script | — (supervise follow bookkeeping / approve; optional closed-question path in tests; skip rows may carry a truncated pane excerpt; no TUI knock) | `investigate` is the thick log: hypothesis, check, result, ruled out, still open — each a new row, at once. Other sessions can query or subscribe and see what was already tried. @@ -685,28 +684,6 @@ The model never receives production credentials. Analysis that only reads the ex ### 21.6 Not in this revision - A second hub state machine, leases, or autonomous merge -- Dispatch for `error.issue`. Its payload is `{ "error_id": "" }`; - the scan reads the template and the excerpt from that `error.seen` row. The - grouping and throttling logic ships as `error_issue_act` with its tests, but - the type is deliberately not yet in the `agent activity add` allowlist and no - watch command calls the scan, so nothing files an issue yet. Wiring it means - adding the type to that allowlist, an `agent watch error-issue` one-scan - command next to `agent watch error-fix`, and `issue_repo` / `dry_run` / - `cooldown_minutes` / `storm_threshold` in `error-fix.json`. - -`error.issue` groups by `template_fingerprint` (§21.3), not by the per-variant -`fingerprint`, so one issue covers every chain/token variant of one error. The -concrete variants live in a machine-owned, delimited section of the issue body; -nothing outside that section is ever rewritten. A body carrying only one of the -two markers, or a duplicated marker, is treated as damaged rather than appended -to. Creating an issue writes its variants straight into that opening table and -posts no comment; updating one splices them in and announces the genuinely new -ones in a single `Also seen on: …` comment. That comment is best effort, so one -that fails after the body edit landed is recorded on the row rather than -discarding the edit. Two throttles sit in front: a burst fold, counted over -templates never filed before so a backlog draining after downtime is not -mistaken for a burst, and a per-template cooldown read from local history — a -dry run is a preview and never opens that window. ## 22. Static supervise loop (v1) diff --git a/src/agent_cli/error_issue_act.py b/src/agent_cli/error_issue_act.py deleted file mode 100644 index c35467d..0000000 --- a/src/agent_cli/error_issue_act.py +++ /dev/null @@ -1,1163 +0,0 @@ -"""Apply pending error.issue activities: file or update a GitHub issue for a -normalized error template, grouped by template_fingerprint rather than the -finer-grained per-variant fingerprint — or, under dry_run, just log the intended -action instead of calling gh for real. - -Two throttles sit in front of the per-template logic: - -- Burst detection: if one run resolves more templates that were never filed - before than storm_threshold, that is treated as one anomaly (a likely shared - root cause) rather than N unrelated problems — those fold into a single, - reused "burst" issue instead of N individual ones. Templates that already have - history keep updating their own issue: a backlog draining after downtime is a - volume spike, not a burst of new problems. -- Cooldown: a template that was already touched (created, updated, or folded - into a burst) within cooldown_minutes is skipped entirely — checked against - local history, not a live gh call, so a fast-recurring error does not cost a - round trip or an issue edit every time it repeats. A dry run is a preview and - never opens that window. - -All pending rows of one template are handled together, so two variants seen in -the same run land in one issue write instead of one each — a create carries them -in its opening table, an update splices them in and announces the genuinely new -ones in a single comment. The cooldown cannot mistake them for repeats either -way: the history snapshot is taken before the scan marks anything. - -Both are plain comparisons against local state; neither involves model -judgment.""" - -from __future__ import annotations - -import hashlib -import json -import re -from collections.abc import Callable -from datetime import datetime, timedelta, timezone -from typing import Any - -from .errors import known_asset_in, known_chain_in, redact -from .runtime import Completed -from .store import Store, StoreError, utcnow - -Runner = Callable[[list[str]], Completed] - -ISSUE_LABEL = "error-log-agent" -_MARKER_PREFIX = "" -_VARIANTS_HEADER = ("variant", "first seen", "last seen") -_VARIANTS_START = "" -_VARIANTS_END = "" -STORM_MARKER = "" - -DEFAULT_COOLDOWN_MINUTES = 60 -DEFAULT_STORM_THRESHOLD = 8 -# GitHub rejects an issue body over 65536 characters. Refuse a little earlier and -# loudly, so a long-lived burst issue reports the ceiling instead of every later -# edit failing at the API with a generic error. -MAX_ISSUE_BODY = 60000 -# One page of candidates, as in github_act. A full page is treated as truncated -# rather than as "no match". -_ISSUE_LIST_LIMIT = 100 -# Keep the variants table bounded so normal growth can never walk an issue into -# MAX_ISSUE_BODY and wedge every later update on it. -MAX_TRACKED_VARIANTS = 200 -# Same shape github_act parses, so a created issue's number can be recorded next -# to its url. -_ISSUE_URL = re.compile(r"https://github\.com/[^/\s]+/[^/\s]+/issues/(\d+)") -# Result modes that never touched GitHub, so they must not start a cooldown -# window: a dry run is a preview, not a touch. -_DRY_RUN_MODES = frozenset({"dry-run", "storm-dry-run"}) - - -def _inert_block(text: str) -> str: - """Log lines are untrusted data (DESIGN.md §19.2). A line carrying this - module's own marker would otherwise move the boundary of the machine-owned - section: a later splice would find the excerpt's marker first and rewrite - everything between it and the real one, destroying issue content. Breaking - the comment opener keeps the line readable inside its code fence while - making it inert.""" - return text.replace("") - assert _marker_for("api|error|abc123|prod", "device-1") == marker - assert _marker_for("api|error|abc123|staging", "device-1") != marker - assert _marker_for("api|error|abc123|prod", "device-2") != marker - - hostile = _marker_for(f"api --> {_VARIANTS_START}|error|abc|prod", "device-1") - assert hostile.count("-->") == 1 - assert _VARIANTS_START not in hostile - - -def test_a_marker_that_cannot_be_encoded_fails_the_row(tmp_path: Path) -> None: - """A lone surrogate can reach the fingerprint from an untrusted stream label. - Unguarded it would abort the whole scan and recur on every later one, leaving - this and every following template pending for good.""" - store = Store(tmp_path) - _runner_session(store) - _seen(store, template_fingerprint="api|error|abc|\ud800") - _issue(store) - _seen(store, template_fingerprint="api|error|fine|prod", activity_id="seen-ok") - _issue(store, error_id="seen-ok", activity_id="issue-ok") - - def runner(argv: list[str]) -> Completed: - if argv[:3] == ["gh", "issue", "list"]: - return Completed(0, "[]", "") - return Completed(0, "https://github.com/org/tracker/issues/1\n", "") - - lines = scan_error_issue(store, runner, issue_repo="org/tracker", dry_run=False) - # The bad row fails; the good one is still processed. - assert "error.issue issue-1 error" in lines - assert any("issue-ok created" in line for line in lines) - bad = store.row("activity", "issue-1") - assert bad is not None - assert "not encodable" in bad["execution_error"] - - -def test_variants_section_round_trips() -> None: - variants = { - "Ethereum": {"first_seen": "2026-08-31T10:00:00Z", "last_seen": "2026-08-31T10:00:00Z"}, - "Polygon": {"first_seen": "2026-08-31T11:00:00Z", "last_seen": "2026-08-31T11:30:00Z"}, - } - section = _render_variants_section(variants) - assert "Ethereum" in section - assert "Polygon" in section - parsed = _parse_variants_section(section) - assert parsed == variants - - -def test_splice_variants_only_touches_delimited_section() -> None: - body = "Human-written context above.\n\nMore human notes.\n" - with_section = _splice_variants(body, {"Ethereum": {"first_seen": "t1", "last_seen": "t1"}}) - assert "Human-written context above." in with_section - assert "More human notes." in with_section - assert "Ethereum" in with_section - - updated = _splice_variants( - with_section, - { - "Ethereum": {"first_seen": "t1", "last_seen": "t2"}, - "Polygon": {"first_seen": "t2", "last_seen": "t2"}, - }, - ) - assert "Human-written context above." in updated - assert "More human notes." in updated - assert "Polygon" in updated - # Splicing again must not duplicate the human-written prose above the section. - assert updated.count("Human-written context above.") == 1 - - -def test_find_issue_number_none_when_empty() -> None: - def runner(argv: list[str]) -> Completed: - assert argv[:4] == ["gh", "issue", "list", "--repo"] - return Completed(0, "[]", "") - - assert _find_issue_number(runner, "org/tracker", "api|error|abc|prod") is None - - -def test_find_issue_number_matches_the_marker_in_the_body() -> None: - def runner(argv: list[str]) -> Completed: - assert "--search" not in argv - return Completed( - 0, - json.dumps( - [ - {"number": 42, "body": "another template "}, - {"number": 43, "body": "carries api|error|abc|prod here"}, - ] - ), - "", - ) - - assert _find_issue_number(runner, "org/tracker", "api|error|abc|prod") == 43 - - -def test_find_issue_number_ignores_issues_without_the_marker() -> None: - """A labeled issue that search might surface but that does not carry this - template's marker must not be adopted as its issue.""" - def runner(argv: list[str]) -> Completed: - return Completed(0, json.dumps([{"number": 42, "body": "unrelated"}]), "") - - assert _find_issue_number(runner, "org/tracker", "api|error|abc|prod") is None - - -def test_find_issue_number_raises_when_the_list_is_truncated() -> None: - """A full page may hide the marker on an unseen issue. Reporting "none" there - would file a duplicate, so this fails loud instead.""" - def runner(argv: list[str]) -> Completed: - return Completed( - 0, - json.dumps([{"number": n, "body": "unrelated"} for n in range(100)]), - "", - ) - - with pytest.raises(StoreError, match="issue list truncated"): - _find_issue_number(runner, "org/tracker", "api|error|abc|prod") - - -def test_find_issue_number_returns_none_on_a_partial_page() -> None: - def runner(argv: list[str]) -> Completed: - return Completed( - 0, - json.dumps([{"number": n, "body": "unrelated"} for n in range(99)]), - "", - ) - - assert _find_issue_number(runner, "org/tracker", "api|error|abc|prod") is None - - -def test_find_issue_number_raises_when_gh_is_missing() -> None: - def runner(argv: list[str]) -> Completed: - raise OSError("No such file or directory: 'gh'") - - with pytest.raises(StoreError, match="gh issue list failed"): - _find_issue_number(runner, "org/tracker", "api|error|abc|prod") - - -def test_find_issue_number_raises_on_gh_failure() -> None: - def runner(argv: list[str]) -> Completed: - return Completed(1, "", "not found") - - with pytest.raises(StoreError, match="not found"): - _find_issue_number(runner, "org/tracker", "api|error|abc|prod") - - -# ---- scan_error_issue: dry run ---- - - -def test_scan_dry_run_never_calls_gh(tmp_path: Path) -> None: - store = Store(tmp_path) - _runner_session(store) - _seen(store) - _issue(store) - calls: list[list[str]] = [] - - def runner(argv: list[str]) -> Completed: - calls.append(list(argv)) - return Completed(0, "", "") - - lines = scan_error_issue(store, runner, issue_repo="org/tracker", dry_run=True) - assert calls == [] - assert lines == ["error.issue issue-1 dry-run variant=Ethereum"] - row = store.row("activity", "issue-1") - assert row is not None - assert row["execution_status"] == "done" - assert row["result"]["mode"] == "dry-run" - assert row["result"]["variant"] == "Ethereum" - assert row["result"]["template_fingerprint"] == "api|error|abc123|prod" - - -def test_scan_dry_run_is_a_noop_on_rerun(tmp_path: Path) -> None: - store = Store(tmp_path) - _runner_session(store) - _seen(store) - _issue(store) - calls: list[list[str]] = [] - - def runner(argv: list[str]) -> Completed: - calls.append(list(argv)) - return Completed(0, "", "") - - scan_error_issue(store, runner, issue_repo="org/tracker", dry_run=True) - assert scan_error_issue(store, runner, issue_repo="org/tracker", dry_run=True) == [] - assert calls == [] - - -# ---- scan_error_issue: create path ---- - - -def test_scan_creates_issue_when_none_exists(tmp_path: Path) -> None: - store = Store(tmp_path) - _runner_session(store) - _seen(store) - _issue(store) - calls: list[list[str]] = [] - - def runner(argv: list[str]) -> Completed: - calls.append(list(argv)) - if argv[:3] == ["gh", "issue", "list"]: - return Completed(0, "[]", "") - if argv[:3] == ["gh", "issue", "create"]: - return Completed(0, "https://github.com/org/tracker/issues/7\n", "") - raise AssertionError(f"unexpected call: {argv}") - - lines = scan_error_issue(store, runner, issue_repo="org/tracker", dry_run=False) - assert lines == ["error.issue issue-1 created variant=Ethereum"] - create_call = next(c for c in calls if c[:3] == ["gh", "issue", "create"]) - assert "--repo" in create_call and "org/tracker" in create_call - assert "--label" in create_call and ISSUE_LABEL in create_call - body = create_call[create_call.index("--body") + 1] - assert _marker_for("api|error|abc123|prod", store.device_id()) in body - assert "Ethereum" in body - row = store.row("activity", "issue-1") - assert row is not None - assert row["execution_status"] == "done" - assert row["result"]["created"] is True - assert row["result"]["url"] == "https://github.com/org/tracker/issues/7" - - -# ---- scan_error_issue: update path ---- - - -def test_scan_updates_existing_issue_same_variant_no_comment(tmp_path: Path) -> None: - store = Store(tmp_path) - _runner_session(store) - _seen(store) - _issue(store) - existing_body = ( - _marker_for("api|error|abc123|prod", store.device_id()) - + "\n\nAutomated error-log finding.\n\n" - + _render_variants_section({"Ethereum": {"first_seen": "t0", "last_seen": "t0"}}) - + "\n" - ) - calls: list[list[str]] = [] - - def runner(argv: list[str]) -> Completed: - calls.append(list(argv)) - if argv[:3] == ["gh", "issue", "list"]: - return Completed( - 0, - json.dumps([{"number": 9, "body": _marker_for("api|error|abc123|prod", store.device_id())}]), - "", - ) - if argv[:3] == ["gh", "issue", "view"]: - return Completed(0, json.dumps({"body": existing_body}), "") - if argv[:3] == ["gh", "issue", "edit"]: - return Completed(0, "", "") - if argv[:3] == ["gh", "issue", "comment"]: - raise AssertionError("must not comment when the variant already existed") - raise AssertionError(f"unexpected call: {argv}") - - lines = scan_error_issue(store, runner, issue_repo="org/tracker", dry_run=False) - assert lines == [ - "error.issue issue-1 updated number=9 variant=Ethereum new_variant=False" - ] - edit_call = next(c for c in calls if c[:3] == ["gh", "issue", "edit"]) - body = edit_call[edit_call.index("--body") + 1] - assert "Ethereum" in body - row = store.row("activity", "issue-1") - assert row is not None - assert row["result"]["new_variant"] is False - - -def test_scan_updates_existing_issue_new_variant_posts_comment(tmp_path: Path) -> None: - store = Store(tmp_path) - _runner_session(store) - _seen( - store, - template_fingerprint="api|error|abc123|prod", - excerpt="Timeout updating balances for Polygon: Error: Timeout", - ) - _issue(store) - existing_body = ( - _marker_for("api|error|abc123|prod", store.device_id()) - + "\n\nAutomated error-log finding.\n\n" - + _render_variants_section({"Ethereum": {"first_seen": "t0", "last_seen": "t0"}}) - + "\n" - ) - calls: list[list[str]] = [] - - def runner(argv: list[str]) -> Completed: - calls.append(list(argv)) - if argv[:3] == ["gh", "issue", "list"]: - return Completed( - 0, - json.dumps([{"number": 9, "body": _marker_for("api|error|abc123|prod", store.device_id())}]), - "", - ) - if argv[:3] == ["gh", "issue", "view"]: - return Completed(0, json.dumps({"body": existing_body}), "") - if argv[:3] in (["gh", "issue", "edit"], ["gh", "issue", "comment"]): - return Completed(0, "", "") - raise AssertionError(f"unexpected call: {argv}") - - lines = scan_error_issue(store, runner, issue_repo="org/tracker", dry_run=False) - assert lines == [ - "error.issue issue-1 updated number=9 variant=Polygon new_variant=True" - ] - edit_call = next(c for c in calls if c[:3] == ["gh", "issue", "edit"]) - body = edit_call[edit_call.index("--body") + 1] - assert "Ethereum" in body - assert "Polygon" in body - comment_call = next(c for c in calls if c[:3] == ["gh", "issue", "comment"]) - comment_body = comment_call[comment_call.index("--body") + 1] - assert "Polygon" in comment_body - - -# ---- error handling ---- - - -def test_scan_marks_error_when_template_fingerprint_missing(tmp_path: Path) -> None: - store = Store(tmp_path) - _runner_session(store) - _seen(store, template_fingerprint=None) - _issue(store) - - lines = scan_error_issue( - store, lambda _argv: Completed(0, "[]", ""), issue_repo="org/tracker", dry_run=False - ) - assert lines == ["error.issue issue-1 error"] - row = store.row("activity", "issue-1") - assert row is not None - assert row["execution_status"] == "error" - assert row["execution_error"] == "template_fingerprint is required" - - -def test_scan_marks_error_when_create_fails(tmp_path: Path) -> None: - store = Store(tmp_path) - _runner_session(store) - _seen(store) - _issue(store) - - def runner(argv: list[str]) -> Completed: - if argv[:3] == ["gh", "issue", "list"]: - return Completed(0, "[]", "") - if argv[:3] == ["gh", "issue", "create"]: - return Completed(1, "", "permission denied") - raise AssertionError(f"unexpected call: {argv}") - - lines = scan_error_issue(store, runner, issue_repo="org/tracker", dry_run=False) - assert lines == ["error.issue issue-1 error"] - row = store.row("activity", "issue-1") - assert row is not None - assert row["execution_status"] == "error" - assert row["execution_error"] == "permission denied" - - -def test_scan_leaves_non_pending_rows_alone(tmp_path: Path) -> None: - store = Store(tmp_path) - _runner_session(store) - _seen(store) - _issue(store) - scan_error_issue( - store, lambda _argv: Completed(0, "[]", ""), issue_repo="org/tracker", dry_run=True - ) - calls: list[list[str]] = [] - - def fail_if_called(argv: list[str]) -> Completed: - calls.append(list(argv)) - return Completed(0, "", "") - - assert scan_error_issue(store, fail_if_called, issue_repo="org/tracker", dry_run=False) == [] - assert calls == [] - - -def _prior_touch( - store: Store, - *, - template_fingerprint: str, - at: str, - activity_id: str, - skipped: bool = False, - mode: str | None = None, - number: int | None = None, -) -> None: - # A real touch always names the issue it touched; touch history ignores rows - # that do not, so the default here has to carry one too. - result: dict[str, object] = { - "issue_repo": "org/tracker", - "template_fingerprint": template_fingerprint, - "at": at, - "number": number if number is not None else 7, - } - if skipped: - result["skipped"] = "cooldown" - if mode is not None: - result["mode"] = mode - store.write( - "activity", - "insert", - activity_id, - { - "id": activity_id, - "session_id": "runner-1", - "type": "error.issue", - "payload": {"error_id": "irrelevant"}, - "execution_status": "done", - "result": result, - }, - ) - - -def _seen_and_issue( - store: Store, *, index: int, template_fingerprint: str, service: str = "api", cls: str = "error" -) -> None: - seen_id = f"seen-{index}" - issue_id = f"storm-issue-{index}" - store.write( - "activity", - "insert", - seen_id, - { - "id": seen_id, - "session_id": "runner-1", - "type": "error.seen", - "payload": { - "fingerprint": f"fp-{index}", - "template_fingerprint": template_fingerprint, - "excerpt": f"Some error number {index}", - "service": service, - "class": cls, - }, - "execution_status": "done", - }, - ) - store.write( - "activity", - "insert", - issue_id, - { - "id": issue_id, - "session_id": "runner-1", - "type": "error.issue", - "payload": {"error_id": seen_id}, - "execution_status": "pending", - }, - ) - - -# ---- cooldown ---- - - -def test_cooldown_false_with_no_history(tmp_path: Path) -> None: - store = Store(tmp_path) - _runner_session(store) - assert _within_cooldown(_touch_history(store, "org/tracker"), "api|error|abc|prod", utcnow(), 60) is False - - -def test_cooldown_true_within_window(tmp_path: Path) -> None: - store = Store(tmp_path) - _runner_session(store) - _prior_touch( - store, - template_fingerprint="api|error|abc|prod", - at="2026-08-31T10:00:00Z", - activity_id="prior-1", - ) - assert ( - _within_cooldown(_touch_history(store, "org/tracker"), "api|error|abc|prod", "2026-08-31T10:30:00Z", 60) - is True - ) - - -def test_cooldown_false_after_expiry(tmp_path: Path) -> None: - store = Store(tmp_path) - _runner_session(store) - _prior_touch( - store, - template_fingerprint="api|error|abc|prod", - at="2026-08-31T10:00:00Z", - activity_id="prior-1", - ) - assert ( - _within_cooldown(_touch_history(store, "org/tracker"), "api|error|abc|prod", "2026-08-31T11:30:00Z", 60) - is False - ) - - -def test_cooldown_ignores_skipped_results(tmp_path: Path) -> None: - store = Store(tmp_path) - _runner_session(store) - _prior_touch( - store, - template_fingerprint="api|error|abc|prod", - at="2026-08-31T10:29:00Z", - activity_id="prior-1", - skipped=True, - ) - # A skip-only history must not itself extend the cooldown window. - assert ( - _within_cooldown(_touch_history(store, "org/tracker"), "api|error|abc|prod", "2026-08-31T10:30:00Z", 60) - is False - ) - - -def test_scan_skips_recently_touched_template_without_gh_calls(tmp_path: Path) -> None: - store = Store(tmp_path) - _runner_session(store) - _seen(store) - _issue(store) - _prior_touch( - store, template_fingerprint="api|error|abc123|prod", at=utcnow(), activity_id="prior-1" - ) - calls: list[list[str]] = [] - - def fail_if_called(argv: list[str]) -> Completed: - calls.append(list(argv)) - return Completed(0, "", "") - - lines = scan_error_issue( - store, fail_if_called, issue_repo="org/tracker", dry_run=False, cooldown_minutes=60 - ) - assert lines == ["error.issue issue-1 skipped-cooldown"] - assert calls == [] - row = store.row("activity", "issue-1") - assert row is not None - assert row["execution_status"] == "done" - assert row["result"]["skipped"] == "cooldown" - - -def test_scan_processes_normally_after_cooldown_expires(tmp_path: Path) -> None: - store = Store(tmp_path) - _runner_session(store) - _seen(store) - _issue(store) - _prior_touch( - store, - template_fingerprint="api|error|abc123|prod", - at="2020-01-01T00:00:00Z", - activity_id="prior-1", - ) - - def runner(argv: list[str]) -> Completed: - if argv[:3] == ["gh", "issue", "list"]: - return Completed(0, "[]", "") - if argv[:3] == ["gh", "issue", "create"]: - return Completed(0, "https://github.com/org/tracker/issues/1\n", "") - raise AssertionError(f"unexpected call: {argv}") - - lines = scan_error_issue( - store, runner, issue_repo="org/tracker", dry_run=False, cooldown_minutes=60 - ) - assert lines == ["error.issue issue-1 created variant=Ethereum"] - - -# ---- burst / storm detection ---- - - -def test_scan_does_not_storm_at_or_below_threshold(tmp_path: Path) -> None: - store = Store(tmp_path) - _runner_session(store) - for i in range(2): - _seen_and_issue(store, index=i, template_fingerprint=f"api|error|t{i}|prod") - calls: list[list[str]] = [] - - def runner(argv: list[str]) -> Completed: - calls.append(list(argv)) - if argv[:3] == ["gh", "issue", "list"]: - return Completed(0, "[]", "") - if argv[:3] == ["gh", "issue", "create"]: - return Completed(0, "https://github.com/org/tracker/issues/1\n", "") - raise AssertionError(f"unexpected call: {argv}") - - lines = scan_error_issue(store, runner, issue_repo="org/tracker", dry_run=False, storm_threshold=2) - assert len(lines) == 2 - assert all("created" in line for line in lines) - create_calls = [c for c in calls if c[:3] == ["gh", "issue", "create"]] - assert len(create_calls) == 2 # two separate issues, not folded - - -def test_scan_folds_burst_into_one_storm_issue(tmp_path: Path) -> None: - store = Store(tmp_path) - _runner_session(store) - for i in range(3): - _seen_and_issue(store, index=i, template_fingerprint=f"api|error|t{i}|prod") - calls: list[list[str]] = [] - - def runner(argv: list[str]) -> Completed: - calls.append(list(argv)) - if argv[:3] == ["gh", "issue", "list"]: - return Completed(0, "[]", "") - if argv[:3] == ["gh", "issue", "create"]: - return Completed(0, "https://github.com/org/tracker/issues/99\n", "") - raise AssertionError(f"unexpected call: {argv}") - - lines = scan_error_issue(store, runner, issue_repo="org/tracker", dry_run=False, storm_threshold=2) - assert len(lines) == 3 - assert all("storm" in line for line in lines) - create_calls = [c for c in calls if c[:3] == ["gh", "issue", "create"]] - assert len(create_calls) == 1 - body = create_calls[0][create_calls[0].index("--body") + 1] - assert STORM_MARKER in body - for i in range(3): - row = store.row("activity", f"storm-issue-{i}") - assert row is not None - assert row["execution_status"] == "done" - assert row["result"]["mode"] == "storm" - - -def test_scan_storm_dry_run_never_calls_gh(tmp_path: Path) -> None: - store = Store(tmp_path) - _runner_session(store) - for i in range(3): - _seen_and_issue(store, index=i, template_fingerprint=f"api|error|t{i}|prod") - calls: list[list[str]] = [] - - def runner(argv: list[str]) -> Completed: - calls.append(list(argv)) - return Completed(0, "", "") - - lines = scan_error_issue(store, runner, issue_repo="org/tracker", dry_run=True, storm_threshold=2) - assert calls == [] - assert len(lines) == 3 - assert all("storm-dry-run" in line for line in lines) - - -def test_scan_storm_reuses_existing_open_storm_issue(tmp_path: Path) -> None: - store = Store(tmp_path) - _runner_session(store) - for i in range(3): - _seen_and_issue(store, index=i, template_fingerprint=f"api|error|t{i}|prod") - existing_body = ( - STORM_MARKER - + "\n\n" - + _render_variants_section({"api: error (oldhash)": {"first_seen": "t0", "last_seen": "t0"}}) - + "\n" - ) - calls: list[list[str]] = [] - - def runner(argv: list[str]) -> Completed: - calls.append(list(argv)) - if argv[:3] == ["gh", "issue", "list"]: - return Completed(0, json.dumps([{"number": 55, "body": STORM_MARKER}]), "") - if argv[:3] == ["gh", "issue", "view"]: - return Completed(0, json.dumps({"body": existing_body}), "") - if argv[:3] == ["gh", "issue", "edit"]: - return Completed(0, "", "") - raise AssertionError(f"unexpected call: {argv}") - - lines = scan_error_issue(store, runner, issue_repo="org/tracker", dry_run=False, storm_threshold=2) - assert len(lines) == 3 - assert [c for c in calls if c[:3] == ["gh", "issue", "create"]] == [] - edit_calls = [c for c in calls if c[:3] == ["gh", "issue", "edit"]] - assert len(edit_calls) == 1 - body = edit_calls[0][edit_calls[0].index("--body") + 1] - assert "api: error (oldhash)" in body - assert body.count(STORM_MARKER) == 1 - - -def test_scan_storm_marks_all_rows_error_on_create_failure(tmp_path: Path) -> None: - store = Store(tmp_path) - _runner_session(store) - for i in range(3): - _seen_and_issue(store, index=i, template_fingerprint=f"api|error|t{i}|prod") - - def runner(argv: list[str]) -> Completed: - if argv[:3] == ["gh", "issue", "list"]: - return Completed(0, "[]", "") - if argv[:3] == ["gh", "issue", "create"]: - return Completed(1, "", "permission denied") - raise AssertionError(f"unexpected call: {argv}") - - lines = scan_error_issue(store, runner, issue_repo="org/tracker", dry_run=False, storm_threshold=2) - assert len(lines) == 3 - assert all(line.endswith("error") for line in lines) - for i in range(3): - row = store.row("activity", f"storm-issue-{i}") - assert row is not None - assert row["execution_status"] == "error" - - -# ---- one template, several pending rows in one scan ---- - - -def test_scan_merges_two_variants_of_one_template_into_one_issue(tmp_path: Path) -> None: - """Two variants of the same template in one scan belong in one issue. Here - that issue does not exist yet, so both land in the opening table of a single - create — no update and no comment. Cooldown safety comes from the history - snapshot, not from this grouping.""" - store = Store(tmp_path) - _runner_session(store) - _seen( - store, - template_fingerprint="api|error|same|prod", - excerpt="Balance for Arbitrum/USDC went low", - activity_id="seen-a", - ) - _seen( - store, - template_fingerprint="api|error|same|prod", - excerpt="Balance for Arbitrum/WBTC went low", - activity_id="seen-b", - ) - _issue(store, error_id="seen-a", activity_id="issue-a") - _issue(store, error_id="seen-b", activity_id="issue-b") - calls: list[list[str]] = [] - - def runner(argv: list[str]) -> Completed: - calls.append(list(argv)) - if argv[:3] == ["gh", "issue", "list"]: - return Completed(0, "[]", "") - if argv[:3] == ["gh", "issue", "create"]: - return Completed(0, "https://github.com/org/tracker/issues/7\n", "") - raise AssertionError(f"unexpected call: {argv}") - - lines = scan_error_issue( - store, runner, issue_repo="org/tracker", dry_run=False, cooldown_minutes=60 - ) - assert lines == [ - "error.issue issue-a created variant=Arbitrum/USDC", - "error.issue issue-b created variant=Arbitrum/WBTC", - ] - create_calls = [c for c in calls if c[:3] == ["gh", "issue", "create"]] - assert len(create_calls) == 1 - body = create_calls[0][create_calls[0].index("--body") + 1] - assert "Arbitrum/USDC" in body - assert "Arbitrum/WBTC" in body - - -def test_scan_comments_once_for_several_new_variants(tmp_path: Path) -> None: - store = Store(tmp_path) - _runner_session(store) - _seen( - store, - template_fingerprint="api|error|same|prod", - excerpt="Balance for Arbitrum/USDC went low", - activity_id="seen-a", - ) - _seen( - store, - template_fingerprint="api|error|same|prod", - excerpt="Balance for Base/WBTC went low", - activity_id="seen-b", - ) - _issue(store, error_id="seen-a", activity_id="issue-a") - _issue(store, error_id="seen-b", activity_id="issue-b") - - def runner(argv: list[str]) -> Completed: - if argv[:3] == ["gh", "issue", "list"]: - return Completed( - 0, - json.dumps([{"number": 12, "body": _marker_for("api|error|same|prod", store.device_id())}]), - "", - ) - if argv[:3] == ["gh", "issue", "view"]: - return Completed( - 0, - json.dumps({"body": f'{_marker_for("api|error|same|prod", store.device_id())}\n\ntext\n'}), - "", - ) - return Completed(0, "", "") - - calls: list[list[str]] = [] - - def recording(argv: list[str]) -> Completed: - calls.append(list(argv)) - return runner(argv) - - scan_error_issue(store, recording, issue_repo="org/tracker", dry_run=False) - comments = [c for c in calls if c[:3] == ["gh", "issue", "comment"]] - assert len(comments) == 1 - body = comments[0][comments[0].index("--body") + 1] - assert "Arbitrum/USDC" in body - assert "Base/WBTC" in body - - -def test_scan_keeps_the_edit_when_the_comment_fails(tmp_path: Path) -> None: - """The edit is the durable record. Failing the row on a comment error would - strand a variant that is already in the table and can never be re-announced, - because a retry no longer sees it as new.""" - store = Store(tmp_path) - _runner_session(store) - _seen(store) - _issue(store) - - def runner(argv: list[str]) -> Completed: - if argv[:3] == ["gh", "issue", "list"]: - return Completed( - 0, - json.dumps([{"number": 12, "body": _marker_for("api|error|abc123|prod", store.device_id())}]), - "", - ) - if argv[:3] == ["gh", "issue", "view"]: - return Completed( - 0, - json.dumps({"body": f'{_marker_for("api|error|abc123|prod", store.device_id())}\n\ntext\n'}), - "", - ) - if argv[:3] == ["gh", "issue", "comment"]: - return Completed(1, "", "rate limited") - return Completed(0, "", "") - - lines = scan_error_issue(store, runner, issue_repo="org/tracker", dry_run=False) - assert lines == [ - "error.issue issue-1 updated number=12 variant=Ethereum new_variant=True comment-failed" - ] - row = store.row("activity", "issue-1") - assert row is not None - assert row["execution_status"] == "done" - assert row["result"]["comment_error"] == "rate limited" - - -# ---- dry run must not open a cooldown window ---- - - -def test_dry_run_does_not_start_a_cooldown_window(tmp_path: Path) -> None: - store = Store(tmp_path) - _runner_session(store) - _seen(store) - _issue(store) - assert scan_error_issue( - store, lambda _argv: Completed(0, "[]", ""), issue_repo="org/tracker", dry_run=True - ) == ["error.issue issue-1 dry-run variant=Ethereum"] - - _issue(store, activity_id="issue-2") - calls: list[list[str]] = [] - - def runner(argv: list[str]) -> Completed: - calls.append(list(argv)) - if argv[:3] == ["gh", "issue", "list"]: - return Completed(0, "[]", "") - return Completed(0, "https://github.com/org/tracker/issues/1\n", "") - - lines = scan_error_issue( - store, runner, issue_repo="org/tracker", dry_run=False, cooldown_minutes=60 - ) - assert lines == ["error.issue issue-2 created variant=Ethereum"] - assert calls != [] - - -# ---- burst detection counts only templates never filed before ---- - - -def test_storm_threshold_ignores_already_tracked_templates(tmp_path: Path) -> None: - """A backlog of known templates draining after downtime is a volume spike, - not a burst of new problems, and must keep updating its own issues.""" - store = Store(tmp_path) - _runner_session(store) - for i in range(3): - _seen_and_issue(store, index=i, template_fingerprint=f"api|error|t{i}|prod") - for i in range(3): - _prior_touch( - store, - template_fingerprint=f"api|error|t{i}|prod", - at="2026-08-30T10:00:00Z", - activity_id=f"prior-{i}", - ) - - def runner(argv: list[str]) -> Completed: - if argv[:3] == ["gh", "issue", "list"]: - return Completed(0, "[]", "") - if argv[:3] == ["gh", "issue", "create"]: - return Completed(0, "https://github.com/org/tracker/issues/1\n", "") - raise AssertionError(f"unexpected call: {argv}") - - lines = scan_error_issue( - store, - runner, - issue_repo="org/tracker", - dry_run=False, - storm_threshold=2, - cooldown_minutes=0, - ) - assert len(lines) == 3 - assert all(line.endswith("created variant=generic") for line in lines) - for i in range(3): - row = store.row("activity", f"storm-issue-{i}") - assert row is not None - assert row["result"].get("mode") != "storm" - - -# ---- damaged variants section ---- - - -def test_splice_variants_refuses_a_half_open_section() -> None: - damaged = "Human notes.\n\n\n\n| variant | first seen | last seen |\n" - with pytest.raises(StoreError): - _splice_variants(damaged, {"Ethereum": {"first_seen": "t1", "last_seen": "t1"}}) - - -def test_splice_variants_refuses_a_body_over_the_github_limit() -> None: - """The ceiling still guards a body that is oversized for reasons the table - cap cannot control, such as very long human-written prose.""" - huge_human_body = "human prose. " * (MAX_ISSUE_BODY // 10) - with pytest.raises(StoreError): - _splice_variants(huge_human_body, {"Ethereum": {"first_seen": "t1", "last_seen": "t1"}}) - - -def test_render_variants_section_caps_the_table() -> None: - """Unbounded growth would walk the issue into the body limit and wedge every - later update on it, so the table keeps the most recent variants and says how - many it dropped.""" - variants = { - f"chain-{i:04d}": {"first_seen": "t1", "last_seen": f"2026-08-{(i % 28) + 1:02d}"} - for i in range(MAX_TRACKED_VARIANTS + 50) - } - section = _render_variants_section(variants) - parsed = _parse_variants_section(section) - assert len(parsed) == MAX_TRACKED_VARIANTS - assert "50 older variants dropped" in section - # The dropped-count note must not survive as a phantom variant row. - assert all(name.startswith("chain-") for name in parsed) - - -def test_variants_table_stays_under_the_ceiling_when_saturated() -> None: - variants = { - f"chain-{i:04d}": {"first_seen": "2026-08-31T10:00:00Z", "last_seen": "2026-08-31T10:00:00Z"} - for i in range(MAX_TRACKED_VARIANTS * 5) - } - assert len(_splice_variants("Human notes.\n", variants)) <= MAX_ISSUE_BODY - - -def test_create_paths_apply_the_same_body_ceiling() -> None: - """The ceiling is a property of every body sent to gh, not just of a splice - into an existing issue.""" - huge = "x" * (MAX_ISSUE_BODY + 1) - with pytest.raises(StoreError): - _create_issue( - _unreachable_runner, - issue_repo="org/tracker", - title="api: error", - marker=_marker_for("api|error|abc|prod", "device-1"), - excerpt=huge, - variants=["generic"], - now="2026-08-31T10:00:00Z", - ) - # The storm body is bounded by the same table cap, so it stays writable even - # with far more templates than the cap. - templates = {f"t-{i}": {"first_seen": "t1", "last_seen": "t1"} for i in range(6000)} - created: list[list[str]] = [] - - def runner(argv: list[str]) -> Completed: - created.append(list(argv)) - return Completed(0, "https://github.com/org/tracker/issues/1\n", "") - - _create_storm_issue( - runner, issue_repo="org/tracker", templates=templates - ) - body = created[0][created[0].index("--body") + 1] - assert len(body) <= MAX_ISSUE_BODY - - -def _unreachable_runner(argv: list[str]) -> Completed: - raise AssertionError(f"gh must not be called: {argv}") - - -# ---- touch history ---- - - -def test_touch_history_keeps_the_newest_touch_per_template(tmp_path: Path) -> None: - store = Store(tmp_path) - _runner_session(store) - _prior_touch( - store, - template_fingerprint="api|error|abc|prod", - at="2026-08-31T10:00:00Z", - activity_id="prior-old", - ) - _prior_touch( - store, - template_fingerprint="api|error|abc|prod", - at="2026-08-31T12:00:00Z", - activity_id="prior-new", - ) - history = _touch_history(store, "org/tracker") - assert history["api|error|abc|prod"].isoformat() == "2026-08-31T12:00:00+00:00" - - -def test_touch_history_ignores_unusable_rows(tmp_path: Path) -> None: - store = Store(tmp_path) - _runner_session(store) - for activity_id, result in ( - ("bad-1", "not-a-dict"), - ("bad-2", {"template_fingerprint": "api|error|abc|prod"}), # no "at" - ("bad-3", {"template_fingerprint": "api|error|abc|prod", "at": "not-a-date"}), - ("bad-4", {"at": "2026-08-31T10:00:00Z"}), # no template_fingerprint - ): - store.write( - "activity", - "insert", - activity_id, - { - "id": activity_id, - "session_id": "runner-1", - "type": "error.issue", - "payload": {"error_id": "irrelevant"}, - "execution_status": "done", - "result": result, - }, - ) - assert _touch_history(store, "org/tracker") == {} - - -# ---- untrusted excerpt text ---- - - -def test_excerpt_cannot_move_the_section_boundary(tmp_path: Path) -> None: - """A log line is untrusted data. One carrying the section marker would - otherwise make a later splice rewrite everything between it and the real - marker, destroying the excerpt and the issue's own structure.""" - store = Store(tmp_path) - _runner_session(store) - hostile = f"Timeout for Ethereum {_VARIANTS_START} injected" - _seen(store, excerpt=hostile) - _issue(store) - created: list[list[str]] = [] - - def runner(argv: list[str]) -> Completed: - created.append(list(argv)) - if argv[:3] == ["gh", "issue", "list"]: - return Completed(0, "[]", "") - return Completed(0, "https://github.com/org/tracker/issues/1\n", "") - - scan_error_issue(store, runner, issue_repo="org/tracker", dry_run=False) - body = created[-1][created[-1].index("--body") + 1] - assert body.count(_VARIANTS_START) == 1 - assert body.count(_VARIANTS_END) == 1 - - # A later update must keep the excerpt intact rather than splicing over it. - updated = _splice_variants(body, _parse_variants_section(body)) - assert updated.count(_VARIANTS_START) == 1 - assert "injected" in updated - - -def test_storm_label_survives_a_round_trip_through_the_table() -> None: - """service/class come from the error payload; a pipe or a marker there would - break the row apart so the label would not parse back.""" - label = _storm_label("api|error|abc|prod", {"service": "a|b", "class": ""}, "device-1") - section = _render_variants_section({label: {"first_seen": "t1", "last_seen": "t1"}}) - assert _parse_variants_section(section) == {label: {"first_seen": "t1", "last_seen": "t1"}} - - -# ---- clock skew ---- - - -def test_cooldown_ignores_a_touch_dated_in_the_future(tmp_path: Path) -> None: - """A future-dated touch would otherwise read as "no time has passed" forever - and skip this template on every later scan.""" - store = Store(tmp_path) - _runner_session(store) - _prior_touch( - store, - template_fingerprint="api|error|abc|prod", - at="2026-09-30T10:00:00Z", - activity_id="prior-future", - ) - assert ( - _within_cooldown(_touch_history(store, "org/tracker"), "api|error|abc|prod", "2026-08-31T10:00:00Z", 60) - is False - ) - - -# ---- interrupted burst, then retry ---- - - -def test_retry_after_a_partially_marked_burst_does_not_split_the_template(tmp_path: Path) -> None: - """A burst writes its issue in one gh call but marks its rows one at a time. - If the process dies mid-loop, the rows left pending belong to templates the - burst issue already lists, so a retry must not file a second issue for them.""" - store = Store(tmp_path) - _runner_session(store) - for i in range(3): - _seen_and_issue(store, index=i, template_fingerprint=f"api|error|t{i}|prod") - - def storm_runner(argv: list[str]) -> Completed: - if argv[:3] == ["gh", "issue", "list"]: - return Completed(0, "[]", "") - return Completed(0, "https://github.com/org/tracker/issues/99\n", "") - - scan_error_issue( - store, storm_runner, issue_repo="org/tracker", dry_run=False, storm_threshold=2 - ) - - # Simulate the crash: put one of the burst's rows back to pending. - row = store.row("activity", "storm-issue-2") - assert row is not None - replayed = {k: v for k, v in row.items() if not k.startswith("_")} - replayed["execution_status"] = "pending" - replayed.pop("result", None) - store.write("activity", "update", "storm-issue-2", replayed) - - # The burst issue is the only record that template t2 was already folded. - burst_body = _render_variants_section( - { - _storm_label(f"api|error|t{i}|prod", {"service": "api", "class": "error"}, store.device_id()): { - "first_seen": "2026-08-31T10:00:00Z", - "last_seen": "2026-08-31T10:00:00Z", - } - for i in range(3) - } - ) - - def fail_if_created(argv: list[str]) -> Completed: - if argv[:3] == ["gh", "issue", "create"]: - raise AssertionError(f"must not open a second issue: {argv}") - if argv[:3] == ["gh", "issue", "list"]: - return Completed(0, json.dumps([{"number": 99, "body": STORM_MARKER}]), "") - if argv[:3] == ["gh", "issue", "view"]: - return Completed(0, json.dumps({"body": f"{STORM_MARKER}\n\n{burst_body}\n"}), "") - return Completed(0, "", "") - - lines = scan_error_issue( - store, fail_if_created, issue_repo="org/tracker", dry_run=False, storm_threshold=2 - ) - assert lines == ["error.issue storm-issue-2 already-in-burst number=99"] - row = store.row("activity", "storm-issue-2") - assert row is not None - assert row["execution_status"] == "done" - assert row["result"]["mode"] == "storm" - - -def test_storm_label_separates_environments() -> None: - """service|class|template-sig|environment: the same error in two environments - is two templates, so one shared row would hide one and undercount the burst.""" - payload = {"service": "api", "class": "error"} - assert _storm_label("api|error|abc123def|prod", payload, "device-1") != _storm_label( - "api|error|abc123def|staging", payload, "device-1" - ) - - -def test_storm_issue_lists_each_environment_separately(tmp_path: Path) -> None: - store = Store(tmp_path) - _runner_session(store) - for index, environment in enumerate(("prod", "staging", "test")): - _seen_and_issue(store, index=index, template_fingerprint=f"api|error|same|{environment}") - created: list[list[str]] = [] - - def runner(argv: list[str]) -> Completed: - created.append(list(argv)) - if argv[:3] == ["gh", "issue", "list"]: - return Completed(0, "[]", "") - return Completed(0, "https://github.com/org/tracker/issues/99\n", "") - - lines = scan_error_issue( - store, runner, issue_repo="org/tracker", dry_run=False, storm_threshold=2 - ) - assert all("storm size=3" in line for line in lines) - body = created[-1][created[-1].index("--body") + 1] - assert len(_parse_variants_section(body)) == 3 - - -def test_evicted_burst_label_still_blocks_a_second_issue(tmp_path: Path) -> None: - """The burst table is capped, so an old fold can drop out of the issue body. - Local history has to cover that gap, or the template gets a second issue and - ends up split across two.""" - store = Store(tmp_path) - _runner_session(store) - _seen(store, template_fingerprint="api|error|old|prod") - _issue(store) - _prior_touch( - store, - template_fingerprint="api|error|old|prod", - at="2026-08-01T10:00:00Z", - activity_id="prior-storm", - mode="storm", - number=99, - ) - - def runner(argv: list[str]) -> Completed: - if argv[:3] == ["gh", "issue", "create"]: - raise AssertionError(f"must not open a second issue: {argv}") - if argv[:3] == ["gh", "issue", "list"]: - marker = STORM_MARKER if "--search" not in argv else "" - return Completed(0, json.dumps([{"number": 99, "body": STORM_MARKER}]), marker) - if argv[:3] == ["gh", "issue", "view"]: - # The burst issue is open, but this template's row was evicted. - body = f"{STORM_MARKER}\n\n" + _render_variants_section( - {"other/prod: error (zzzzzzzz)": {"first_seen": "t1", "last_seen": "t1"}} - ) - return Completed(0, json.dumps({"body": body}), "") - return Completed(0, "", "") - - lines = scan_error_issue( - store, runner, issue_repo="org/tracker", dry_run=False, cooldown_minutes=0 - ) - assert lines == ["error.issue issue-1 already-in-burst number=99"] - - -def test_a_closed_burst_lets_the_template_get_its_own_issue(tmp_path: Path) -> None: - """Once a human closes the burst issue, a template that recurs has earned an - issue of its own — history alone must not suppress it forever.""" - store = Store(tmp_path) - _runner_session(store) - _seen(store, template_fingerprint="api|error|old|prod") - _issue(store) - _prior_touch( - store, - template_fingerprint="api|error|old|prod", - at="2026-08-01T10:00:00Z", - activity_id="prior-storm", - mode="storm", - number=42, - ) - created: list[list[str]] = [] - - def runner(argv: list[str]) -> Completed: - created.append(list(argv)) - if argv[:3] == ["gh", "issue", "list"]: - return Completed(0, "[]", "") - return Completed(0, "https://github.com/org/tracker/issues/5\n", "") - - lines = scan_error_issue( - store, runner, issue_repo="org/tracker", dry_run=False, cooldown_minutes=0 - ) - assert lines == ["error.issue issue-1 created variant=Ethereum"] - assert any(c[:3] == ["gh", "issue", "create"] for c in created) - - -def test_a_fold_into_a_closed_burst_does_not_count_for_a_different_one(tmp_path: Path) -> None: - """A template folded into a burst that has since been closed must not be - marked as handled by whatever burst happens to be open now — that burst does - not list it, so the error would be swallowed with no issue mentioning it.""" - store = Store(tmp_path) - _runner_session(store) - _seen(store, template_fingerprint="api|error|old|prod") - _issue(store) - _prior_touch( - store, - template_fingerprint="api|error|old|prod", - at="2026-08-01T10:00:00Z", - activity_id="prior-storm", - mode="storm", - number=42, - ) - created: list[list[str]] = [] - - def runner(argv: list[str]) -> Completed: - created.append(list(argv)) - if argv[:3] == ["gh", "issue", "list"]: - # A different burst issue is open now; it does not list this template. - return Completed(0, json.dumps([{"number": 777, "body": STORM_MARKER}]), "") - if argv[:3] == ["gh", "issue", "view"]: - body = f"{STORM_MARKER}\n\n" + _render_variants_section( - {"other/prod: error (unrelated)": {"first_seen": "t1", "last_seen": "t1"}} - ) - return Completed(0, json.dumps({"body": body}), "") - return Completed(0, "https://github.com/org/tracker/issues/5\n", "") - - lines = scan_error_issue( - store, runner, issue_repo="org/tracker", dry_run=False, cooldown_minutes=0 - ) - assert lines == ["error.issue issue-1 created variant=Ethereum"] - assert any(c[:3] == ["gh", "issue", "create"] for c in created) - - -def test_a_created_burst_records_its_issue_number(tmp_path: Path) -> None: - """The number is what later scans match a fold against, so a burst that was - created rather than reused has to record it too.""" - store = Store(tmp_path) - _runner_session(store) - for i in range(3): - _seen_and_issue(store, index=i, template_fingerprint=f"api|error|t{i}|prod") - - def runner(argv: list[str]) -> Completed: - if argv[:3] == ["gh", "issue", "list"]: - return Completed(0, "[]", "") - return Completed(0, "https://github.com/org/tracker/issues/99\n", "") - - scan_error_issue(store, runner, issue_repo="org/tracker", dry_run=False, storm_threshold=2) - for i in range(3): - row = store.row("activity", f"storm-issue-{i}") - assert row is not None - assert row["result"]["created"] is True - assert row["result"]["number"] == 99 - assert _burst_folded_templates(store, "org/tracker") == { - f"api|error|t{i}|prod": {99} for i in range(3) - } - - -def test_storm_label_is_unique_per_template_despite_separators(tmp_path: Path) -> None: - """service, class and environment are free text from the log source, so they - can contain the separators the fingerprint joins on and the table renders - with. Two different templates must still get two rows, or one goes missing - from the burst issue while a row claims to cover it.""" - first = _storm_label("api|error|abc123def|prod/eu", {"service": "api", "class": "error", "environment": "prod/eu"}, "device-1") - second = _storm_label("api/prod|error|abc123def|eu", {"service": "api/prod", "class": "error", "environment": "eu"}, "device-1") - assert first != second - - # A pipe inside a component must not shift which field the label reports. - shifted = _storm_label("api|x|error|sigAAAA|prod", {"service": "api|x", "class": "error", "environment": "prod"}, "device-1") - assert shifted.startswith("`api/x/prod: error (") - - -def test_storm_issue_keeps_a_row_per_colliding_template(tmp_path: Path) -> None: - store = Store(tmp_path) - _runner_session(store) - for index, (service, environment) in enumerate( - (("api", "prod/eu"), ("api/prod", "eu"), ("api", "eu")) - ): - seen_id, issue_id = f"seen-{index}", f"storm-issue-{index}" - store.write( - "activity", - "insert", - seen_id, - { - "id": seen_id, - "session_id": "runner-1", - "type": "error.seen", - "payload": { - "fingerprint": f"fp-{index}", - "template_fingerprint": f"{service}|error|abc123def|{environment}", - "excerpt": "Some error", - "service": service, - "class": "error", - "environment": environment, - }, - "execution_status": "done", - }, - ) - store.write( - "activity", - "insert", - issue_id, - { - "id": issue_id, - "session_id": "runner-1", - "type": "error.issue", - "payload": {"error_id": seen_id}, - "execution_status": "pending", - }, - ) - created: list[list[str]] = [] - - def runner(argv: list[str]) -> Completed: - created.append(list(argv)) - if argv[:3] == ["gh", "issue", "list"]: - return Completed(0, "[]", "") - return Completed(0, "https://github.com/org/tracker/issues/99\n", "") - - lines = scan_error_issue( - store, runner, issue_repo="org/tracker", dry_run=False, storm_threshold=2 - ) - assert all("storm size=3" in line for line in lines) - body = created[-1][created[-1].index("--body") + 1] - assert len(_parse_variants_section(body)) == 3 - - -# ---- untrusted text cannot escape its quoting ---- - - -def test_excerpt_cannot_break_out_of_its_code_fence(tmp_path: Path) -> None: - """A log line containing a fence would close the quote early and let the - rest render as live Markdown in an issue presented as an inert log quote.""" - store = Store(tmp_path) - _runner_session(store) - _seen(store, excerpt="boom ``` then [a](http://x) and more") - _issue(store) - created: list[list[str]] = [] - - def runner(argv: list[str]) -> Completed: - created.append(list(argv)) - if argv[:3] == ["gh", "issue", "list"]: - return Completed(0, "[]", "") - return Completed(0, "https://github.com/org/tracker/issues/1\n", "") - - scan_error_issue(store, runner, issue_repo="org/tracker", dry_run=False) - body = created[-1][created[-1].index("--body") + 1] - fence = "````" - assert body.count(fence) == 2 - quoted = body.split(fence)[1] - assert "boom ``` then [a](http://x) and more" in quoted - - -def test_splice_refuses_a_duplicated_marker(tmp_path: Path) -> None: - """Two copies of a marker mean the body is damaged; splicing across them - would silently rewrite whatever a human put between the copies.""" - section = _render_variants_section({"Ethereum": {"first_seen": "t1", "last_seen": "t1"}}) - doubled = f"{section}\n\nhuman notes worth keeping\n\n{section}\n" - with pytest.raises(StoreError, match="damaged variants section"): - _splice_variants(doubled, {"Ethereum": {"first_seen": "t1", "last_seen": "t2"}}) - - -# ---- history is per issue_repo ---- - - -def test_cooldown_history_does_not_leak_across_repos(tmp_path: Path) -> None: - store = Store(tmp_path) - _runner_session(store) - _prior_touch( - store, - template_fingerprint="api|error|abc|prod", - at="2026-08-31T10:00:00Z", - activity_id="prior-1", - ) - assert _touch_history(store, "org/tracker") != {} - assert _touch_history(store, "org/other-tracker") == {} - - -def test_burst_folds_do_not_leak_across_repos(tmp_path: Path) -> None: - """Issue numbers are per repo, so a fold recorded against another tracker - must not mark a template as covered here.""" - store = Store(tmp_path) - _runner_session(store) - _prior_touch( - store, - template_fingerprint="api|error|abc|prod", - at="2026-08-31T10:00:00Z", - activity_id="prior-1", - mode="storm", - number=99, - ) - assert _burst_folded_templates(store, "org/tracker") == {"api|error|abc|prod": {99}} - assert _burst_folded_templates(store, "org/other-tracker") == {} - - -def test_a_row_named_like_the_header_survives_the_round_trip(tmp_path: Path) -> None: - """service is free text, so a label can begin with "variant". Matching the - header by prefix would drop that row, and a template the burst issue already - lists would be filed a second time.""" - label = _storm_label("variant|error|abc123def|prod", {"service": "variant", "class": "error", "environment": "prod"}, "device-1") - assert label.startswith("`variant") - section = _render_variants_section({label: {"first_seen": "t1", "last_seen": "t1"}}) - assert _parse_variants_section(section) == {label: {"first_seen": "t1", "last_seen": "t1"}} - - -def test_a_row_named_like_the_header_still_blocks_a_second_issue(tmp_path: Path) -> None: - store = Store(tmp_path) - _runner_session(store) - _seen( - store, - template_fingerprint="variant|error|abc123def|prod", - service="variant", - excerpt="Some error", - ) - _issue(store) - label = _storm_label( - "variant|error|abc123def|prod", - {"service": "variant", "class": "error", "environment": "prod"}, - store.device_id(), - ) - burst_body = f"{STORM_MARKER}\n\n" + _render_variants_section( - {label: {"first_seen": "t1", "last_seen": "t1"}} - ) - - def runner(argv: list[str]) -> Completed: - if argv[:3] == ["gh", "issue", "create"]: - raise AssertionError(f"must not open a second issue: {argv}") - if argv[:3] == ["gh", "issue", "list"]: - return Completed(0, json.dumps([{"number": 99, "body": STORM_MARKER}]), "") - if argv[:3] == ["gh", "issue", "view"]: - return Completed(0, json.dumps({"body": burst_body}), "") - return Completed(0, "", "") - - lines = scan_error_issue( - store, runner, issue_repo="org/tracker", dry_run=False, cooldown_minutes=0 - ) - assert lines == ["error.issue issue-1 already-in-burst number=99"] - - -def test_storm_label_renders_log_text_inertly() -> None: - """service and class come from the log source. A bare mention or link in a - table cell renders as a live mention or link in the issue.""" - label = _storm_label("api|error|abc|prod", {"service": "@someone", "class": "[click](http://x)", "environment": "prod"}, "device-1") - assert label.startswith("`") and label.endswith("`") - assert "@someone" in label # the text is kept, only made inert - # A backtick cannot survive inside a single-backtick span. - assert "`" not in label[1:-1] - spanned = _storm_label("api|error|abc|prod", {"service": "a`b", "class": "error", "environment": "prod"}, "device-1") - assert "`" not in spanned[1:-1] - - -def test_gh_argument_errors_stay_on_the_row(tmp_path: Path) -> None: - """subprocess refuses a NUL byte with ValueError, not OSError. Uncaught, it - would abort the whole scan and the row would block every later run.""" - store = Store(tmp_path) - _runner_session(store) - _seen(store) - _issue(store) - - def runner(argv: list[str]) -> Completed: - raise ValueError("embedded null byte") - - lines = scan_error_issue(store, runner, issue_repo="org/tracker", dry_run=False) - assert lines == ["error.issue issue-1 error"] - row = store.row("activity", "issue-1") - assert row is not None - assert row["execution_status"] == "error" - assert "embedded null byte" in row["execution_error"] - - -def test_burst_lookup_fails_loud_on_a_damaged_body(tmp_path: Path) -> None: - """A damaged burst body parses to nothing, which would read as "covers no - template" and file a duplicate. It has to fail like a splice would.""" - store = Store(tmp_path) - _runner_session(store) - _seen(store) - _issue(store) - damaged = f"{STORM_MARKER}\n\n{_VARIANTS_START}\n\n| a | t1 | t1 |\n" - - def runner(argv: list[str]) -> Completed: - if argv[:3] == ["gh", "issue", "create"]: - raise AssertionError(f"must not open an issue off a damaged body: {argv}") - if argv[:3] == ["gh", "issue", "list"]: - return Completed(0, json.dumps([{"number": 99, "body": STORM_MARKER}]), "") - if argv[:3] == ["gh", "issue", "view"]: - return Completed(0, json.dumps({"body": damaged}), "") - return Completed(0, "", "") - - lines = scan_error_issue( - store, runner, issue_repo="org/tracker", dry_run=False, cooldown_minutes=0 - ) - assert lines == ["error.issue issue-1 error"] - row = store.row("activity", "issue-1") - assert row is not None - assert "damaged variants section" in row["execution_error"] - - -def test_a_damaged_burst_body_is_only_fetched_once(tmp_path: Path) -> None: - """The lookup promises one fetch per scan. Without caching the failure, every - later template would repeat both gh calls against the same broken body.""" - store = Store(tmp_path) - _runner_session(store) - for i in range(3): - _seen_and_issue(store, index=i, template_fingerprint=f"api|error|t{i}|prod") - damaged = f"{STORM_MARKER}\n\n{_VARIANTS_START}\n\n| a | t1 | t1 |\n" - calls: list[list[str]] = [] - - def runner(argv: list[str]) -> Completed: - calls.append(list(argv)) - if argv[:3] == ["gh", "issue", "list"]: - return Completed(0, json.dumps([{"number": 99, "body": STORM_MARKER}]), "") - if argv[:3] == ["gh", "issue", "view"]: - return Completed(0, json.dumps({"body": damaged}), "") - return Completed(0, "", "") - - lines = scan_error_issue( - store, runner, issue_repo="org/tracker", dry_run=False, cooldown_minutes=0 - ) - assert len(lines) == 3 - assert all(line.endswith("error") for line in lines) - # One list + one view for the burst issue, plus the per-template marker - # lookups; the damaged body must not be re-fetched per template. - assert len([c for c in calls if c[:3] == ["gh", "issue", "view"]]) == 1 - - -def test_an_issue_that_lost_its_marker_is_not_edited(tmp_path: Path) -> None: - """The marker is matched on the listed body, but the body that gets spliced - is fetched again. If it lost the marker in between, writing to it would - leave an issue this module can never find again and the next scan would open - a second one for the same template.""" - store = Store(tmp_path) - _runner_session(store) - _seen(store) - _issue(store) - - def runner(argv: list[str]) -> Completed: - if argv[:3] == ["gh", "issue", "list"]: - return Completed( - 0, - json.dumps([{"number": 12, "body": _marker_for("api|error|abc123|prod", store.device_id())}]), - "", - ) - if argv[:3] == ["gh", "issue", "view"]: - # Someone edited the marker away between the two calls. - return Completed(0, json.dumps({"body": "human rewrote this\n"}), "") - if argv[:3] == ["gh", "issue", "edit"]: - raise AssertionError(f"must not edit an issue that lost its marker: {argv}") - return Completed(0, "", "") - - lines = scan_error_issue(store, runner, issue_repo="org/tracker", dry_run=False) - assert lines == ["error.issue issue-1 error"] - row = store.row("activity", "issue-1") - assert row is not None - assert "carries its marker 0 times" in row["execution_error"] - - -def test_a_marker_moved_into_the_section_is_not_spliced_away(tmp_path: Path) -> None: - """The marker is checked on the body we read; it has to hold on the body we - write too. One hand-moved inside the machine-owned section would otherwise be - deleted by the splice, leaving an issue this module can never find again.""" - store = Store(tmp_path) - _runner_session(store) - _seen(store) - _issue(store) - marker = _marker_for("api|error|abc123|prod", store.device_id()) - # A human moved the marker inside the tracked section. - body = ( - "notes\n\n" - f"{_VARIANTS_START}\n\n" - "| variant | first seen | last seen |\n|---|---|---|\n" - f"| Ethereum | t1 | t1 |\n\n{marker}\n\n" - f"{_VARIANTS_END}\n" - ) - - def runner(argv: list[str]) -> Completed: - if argv[:3] == ["gh", "issue", "list"]: - return Completed(0, json.dumps([{"number": 12, "body": marker}]), "") - if argv[:3] == ["gh", "issue", "view"]: - return Completed(0, json.dumps({"body": body}), "") - if argv[:3] == ["gh", "issue", "edit"]: - raise AssertionError(f"must not write a body that lost its marker: {argv}") - return Completed(0, "", "") - - lines = scan_error_issue(store, runner, issue_repo="org/tracker", dry_run=False) - assert lines == ["error.issue issue-1 error"] - row = store.row("activity", "issue-1") - assert row is not None - assert "carries its marker 0 times" in row["execution_error"] - - -def test_a_result_naming_no_issue_is_not_a_touch(tmp_path: Path) -> None: - """A corrupted or partial row would otherwise open a cooldown window with no - issue behind it and silently swallow the next real occurrence.""" - store = Store(tmp_path) - _runner_session(store) - store.write( - "activity", - "insert", - "partial-1", - { - "id": "partial-1", - "session_id": "runner-1", - "type": "error.issue", - "payload": {"error_id": "irrelevant"}, - "execution_status": "done", - # done, right repo, parseable timestamp — but names no issue. - "result": { - "issue_repo": "org/tracker", - "template_fingerprint": "api|error|abc|prod", - "at": "2026-08-31T10:00:00Z", - }, - }, - ) - assert _touch_history(store, "org/tracker") == {} - - -def test_public_issue_text_redacts_the_unredacted_fields(tmp_path: Path) -> None: - """class comes from an already-redacted line, but service and environment are - stream labels that never passed through redact() — and both reach a public - issue title or table row.""" - label = _storm_label("api|error|abc|prod", {"service": "api-alice@example.com", "class": "error", "environment": "prod"}, "device-1") - assert "alice@example.com" not in label - assert "[redacted]" in label - - store = Store(tmp_path) - _runner_session(store) - _seen(store, service="api-alice@example.com") - _issue(store) - created: list[list[str]] = [] - - def runner(argv: list[str]) -> Completed: - created.append(list(argv)) - if argv[:3] == ["gh", "issue", "list"]: - return Completed(0, "[]", "") - return Completed(0, "https://github.com/org/tracker/issues/1\n", "") - - scan_error_issue(store, runner, issue_repo="org/tracker", dry_run=False) - title = created[-1][created[-1].index("--title") + 1] - assert "alice@example.com" not in title - assert title.startswith("[redacted]") - - -def test_the_cooldown_window_is_half_open(tmp_path: Path) -> None: - """A touch exactly one window old is out of cooldown; a "<" that became "<=" - would otherwise hold the template one scan too long.""" - store = Store(tmp_path) - _runner_session(store) - _prior_touch( - store, - template_fingerprint="api|error|abc|prod", - at="2026-08-31T10:00:00Z", - activity_id="prior-1", - ) - history = _touch_history(store, "org/tracker") - assert _within_cooldown(history, "api|error|abc|prod", "2026-08-31T10:59:59Z", 60) is True - assert _within_cooldown(history, "api|error|abc|prod", "2026-08-31T11:00:00Z", 60) is False - - -def test_a_touch_named_only_by_url_still_counts(tmp_path: Path) -> None: - """The create path records a url and no number. Requiring a number would drop - every freshly created issue out of the cooldown history.""" - store = Store(tmp_path) - _runner_session(store) - store.write( - "activity", - "insert", - "url-only", - { - "id": "url-only", - "session_id": "runner-1", - "type": "error.issue", - "payload": {"error_id": "irrelevant"}, - "execution_status": "done", - "result": { - "issue_repo": "org/tracker", - "template_fingerprint": "api|error|abc|prod", - "at": "2026-08-31T10:00:00Z", - "url": "https://github.com/org/tracker/issues/4", - "created": True, - }, - }, - ) - assert "api|error|abc|prod" in _touch_history(store, "org/tracker") - - -def test_an_empty_url_names_no_issue(tmp_path: Path) -> None: - store = Store(tmp_path) - _runner_session(store) - store.write( - "activity", - "insert", - "empty-url", - { - "id": "empty-url", - "session_id": "runner-1", - "type": "error.issue", - "payload": {"error_id": "irrelevant"}, - "execution_status": "done", - "result": { - "issue_repo": "org/tracker", - "template_fingerprint": "api|error|abc|prod", - "at": "2026-08-31T10:00:00Z", - "url": "", - "created": True, - }, - }, - ) - assert _touch_history(store, "org/tracker") == {} - - -def test_a_create_reporting_no_url_fails_the_row(tmp_path: Path) -> None: - """gh exiting zero without an issue URL would otherwise be recorded as a - touch that names no issue.""" - store = Store(tmp_path) - _runner_session(store) - _seen(store) - _issue(store) - - def runner(argv: list[str]) -> Completed: - if argv[:3] == ["gh", "issue", "list"]: - return Completed(0, "[]", "") - return Completed(0, " \n", "") - - lines = scan_error_issue(store, runner, issue_repo="org/tracker", dry_run=False) - assert lines == ["error.issue issue-1 error"] - row = store.row("activity", "issue-1") - assert row is not None - assert "no issue URL" in row["execution_error"] - - -def test_a_result_with_an_implausible_issue_reference_is_not_a_touch(tmp_path: Path) -> None: - """The write paths only ever record a positive number or a real issue URL. - A hand-edited or corrupted row carrying some other truthy value must not - open a cooldown window with nothing behind it.""" - store = Store(tmp_path) - _runner_session(store) - for activity_id, result in ( - ("zero-number", {"number": 0}), - ("negative-number", {"number": -3}), - ("not-a-url", {"url": "failed"}), - ): - store.write( - "activity", - "insert", - activity_id, - { - "id": activity_id, - "session_id": "runner-1", - "type": "error.issue", - "payload": {"error_id": "irrelevant"}, - "execution_status": "done", - "result": { - "issue_repo": "org/tracker", - "template_fingerprint": "api|error|abc|prod", - "at": "2026-08-31T10:00:00Z", - **result, - }, - }, - ) - assert _touch_history(store, "org/tracker") == {} - - -def test_a_filed_template_is_never_folded_into_a_burst(tmp_path: Path) -> None: - """A row with a damaged timestamp still proves an issue exists. Judging - "never filed" by the cooldown history would fold that template into a burst - without ever looking up the issue it already has, orphaning it.""" - store = Store(tmp_path) - _runner_session(store) - for i in range(3): - _seen_and_issue(store, index=i, template_fingerprint=f"api|error|t{i}|prod") - # t0 was filed before, but its timestamp is unusable. - store.write( - "activity", - "insert", - "prior-damaged", - { - "id": "prior-damaged", - "session_id": "runner-1", - "type": "error.issue", - "payload": {"error_id": "irrelevant"}, - "execution_status": "done", - "result": { - "issue_repo": "org/tracker", - "template_fingerprint": "api|error|t0|prod", - "at": "not-a-date", - "number": 5, - "created": True, - }, - }, - ) - assert "api|error|t0|prod" in _filed_templates(store, "org/tracker") - assert "api|error|t0|prod" not in _touch_history(store, "org/tracker") - - def runner(argv: list[str]) -> Completed: - if argv[:3] == ["gh", "issue", "list"]: - return Completed(0, "[]", "") - return Completed(0, "https://github.com/org/tracker/issues/9\n", "") - - lines = scan_error_issue( - store, runner, issue_repo="org/tracker", dry_run=False, storm_threshold=2 - ) - # Only t1 and t2 are new, so the threshold is not crossed and nothing folds. - assert all("storm" not in (store.row("activity", f"storm-issue-{i}") or {}) - .get("result", {}).get("mode", "") for i in range(3)) - assert len(lines) == 3 - - -def test_the_burst_row_digest_is_salted_too() -> None: - """The burst table lands in a public issue and its digest is taken over the - same injective, raw fingerprint the marker uses. Unsalted, a reader could - confirm a guessed stream label by recomputing it.""" - payload = {"service": "api", "class": "error", "environment": "prod"} - assert _storm_label("api|error|abc|prod", payload, "device-1") != _storm_label( - "api|error|abc|prod", payload, "device-2" - ) - - -def test_an_unencodable_fingerprint_fails_the_burst_rows_not_the_scan(tmp_path: Path) -> None: - """The burst path builds its labels before the gh call. Unguarded, a lone - surrogate there aborted the whole scan and recurred on every later one.""" - store = Store(tmp_path) - _runner_session(store) - for i in range(3): - _seen_and_issue(store, index=i, template_fingerprint=f"api|error|t{i}|\ud800") - - def runner(argv: list[str]) -> Completed: - if argv[:3] == ["gh", "issue", "list"]: - return Completed(0, "[]", "") - return Completed(0, "https://github.com/org/tracker/issues/9\n", "") - - lines = scan_error_issue( - store, runner, issue_repo="org/tracker", dry_run=False, storm_threshold=2 - ) - assert lines == [f"error.issue storm-issue-{i} error" for i in range(3)] - for i in range(3): - row = store.row("activity", f"storm-issue-{i}") - assert row is not None - assert "not encodable" in row["execution_error"] From 8d3283887bc3b80dedf419c88da5b43e038501d2 Mon Sep 17 00:00:00 2001 From: Daniel Padrino Date: Tue, 1 Sep 2026 14:15:36 -0300 Subject: [PATCH 41/43] Fix a dangling reference left after the previous removal Section 21.3 still described the deleted issue-marker mechanism and pointed at section 21.6 for detail that no longer exists there. --- DESIGN.md | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index b8e481a..70842e4 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -642,13 +642,9 @@ and case-sensitively, so prose like "Based" or lowercase "usd" is not mistaken for a chain or a ticker. `service`, `class` and `environment` are percent-escaped (`%`→`%25`, `|`→`%7C`) before the join, so two different field tuples cannot serialize to one fingerprint. They keep their raw values: -grouping has to stay injective, or two -tenants whose labels merely look alike after redaction would file into one -issue. The hidden marker in a public issue is a digest of this fingerprint -salted with the device id, so a reader cannot confirm a guessed stream label by -recomputing it. It groups which issue a variant belongs to (§21.6); -`fingerprint` stays the finer-grained identity used for `count` / `last_seen`, -so per-variant dedup remains exact. +grouping has to stay injective, or two tenants whose labels merely look alike +after redaction would be grouped as one. `fingerprint` stays the finer-grained +identity used for `count` / `last_seen`, so per-variant dedup remains exact. `repo` may be omitted when the adapter cannot map the stream; the session then `error.skip`s with reason `unmapped-repo`. `line_fingerprint` is optional: `sha256(server + newline + container + newline + exact line)` as 64 lowercase hex, computed from the raw line before redaction. Omit it when `server` or `container` is missing. Host adapters may print the hex on `error.fix` stdout; it is not a mandate and not a log-host name. From 9961b1652e7b8fe4ce6e85cd80e01cc0649a7243 Mon Sep 17 00:00:00 2001 From: Daniel Padrino Date: Tue, 1 Sep 2026 14:50:50 -0300 Subject: [PATCH 42/43] Reword docstrings left over from the issue-filing removal Three docstrings still described the deleted GitHub-issue mechanism; the functions and tests they document are unrelated leftovers that stay exactly as they are. --- src/agent_cli/errors.py | 12 +++++------- tests/test_errors.py | 5 ++--- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/src/agent_cli/errors.py b/src/agent_cli/errors.py index 3ceb8ba..5bb72f6 100644 --- a/src/agent_cli/errors.py +++ b/src/agent_cli/errors.py @@ -293,10 +293,9 @@ def stack_sig(line: str) -> str: def template_signature(line: str) -> str: """Coarser than stack_sig: also masks known blockchain and payment-rail names and asset tickers (see _KNOWN_CHAINS/_KNOWN_ASSETS), so a per-chain or - per-token error variant groups under one issue-filing template instead of - fragmenting into one fingerprint per chain/token pair. Used only for - grouping which GitHub issue a variant belongs to — error.seen identity keeps - using the finer-grained fingerprint()/stack_sig() so per-variant + per-token error variant groups under one coarser template instead of + fragmenting into one fingerprint per chain/token pair. error.seen identity + keeps using the finer-grained fingerprint()/stack_sig() so per-variant count/last_seen tracking stays exact.""" norm = redact(strip_ansi(line)) norm = _UUID.sub("", norm) @@ -332,9 +331,8 @@ def template_fingerprint( def known_chain_in(line: str) -> str | None: - """The first known chain or payment-rail name present in the line, if any — - used to label which concrete variant a template_fingerprint incident belongs - to. _KNOWN_CHAINS covers both, since the platform exposes them as one set of + """The first known chain or payment-rail name present in the line, if any. + _KNOWN_CHAINS covers both, since the platform exposes them as one set of transfer options. Most error lines name neither; those return None.""" match = _CHAIN_TOKEN.search(line) return match.group(0) if match is not None else None diff --git a/tests/test_errors.py b/tests/test_errors.py index 62f3686..e0f2ab7 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -1039,9 +1039,8 @@ def test_a_prefix_name_does_not_survive_its_longer_form_losing_the_boundary() -> def test_the_template_fingerprint_keeps_services_apart(tmp_path: Path) -> None: """Grouping must stay injective: two tenants whose service labels differ are two templates, even where a coarse redaction would render both the same. - Keeping the raw value here is safe because every digest that reaches a - public issue is salted (the marker and the burst row alike); redacting the - grouping key instead would file two tenants' errors into one issue.""" + Keeping the raw value here matters because redacting the grouping key + instead would merge two tenants' distinct errors into one template.""" store = Store(tmp_path) _runner_session(store) _write_config(tmp_path, service="api-alice@example.com") From c536db1e554c3bbc6c0febeac7dfe507faec2f04 Mon Sep 17 00:00:00 2001 From: Daniel Padrino Date: Tue, 1 Sep 2026 14:56:13 -0300 Subject: [PATCH 43/43] Fix a second stale comment missed by the first pass The module comment above _KNOWN_CHAINS still described the deleted issue-filing feature. --- src/agent_cli/errors.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/agent_cli/errors.py b/src/agent_cli/errors.py index 5bb72f6..5a1cebb 100644 --- a/src/agent_cli/errors.py +++ b/src/agent_cli/errors.py @@ -45,8 +45,8 @@ # The blockchain and payment-rail names the platform already exposes as transfer # options, current as of 2026-08-31 — masked in the template signature so a # per-chain error variant ("Timeout updating balances for Ethereum" vs "...for -# Polygon") groups under one issue-filing template instead of fragmenting one -# issue per chain. Refresh when that list changes; there is no automated sync. +# Polygon") groups under one coarser template instead of fragmenting one +# fingerprint per chain. Refresh when that list changes; there is no automated sync. _KNOWN_CHAINS = frozenset( { "DeFiChain", "Ethereum", "Arbitrum", "Polygon", "BinanceSmartChain", "Binance",