From 36e275f1867fe62da5e07dcfee6f35439fa8557c Mon Sep 17 00:00:00 2001 From: Luke Maj Date: Mon, 19 Oct 2026 15:32:07 +0200 Subject: [PATCH 1/2] fix: classify pending gh pr checks without false failures (#132) --- .claude-plugin/plugin.json | 2 +- .codex-plugin/plugin.json | 2 +- .grok-plugin/plugin.json | 2 +- CHANGELOG.md | 6 ++ VERSION | 2 +- agent_observer/analysis.py | 2 + agent_observer/command_status.py | 28 +++++++ agent_observer/db.py | 15 ++++ agent_observer/ingest.py | 3 + agent_observer/privacy.py | 2 +- tests/fixtures/codex-pr-checks-pending.jsonl | 2 + tests/test_pr_checks_pending.py | 87 ++++++++++++++++++++ 12 files changed, 148 insertions(+), 5 deletions(-) create mode 100644 agent_observer/command_status.py create mode 100644 tests/fixtures/codex-pr-checks-pending.jsonl create mode 100644 tests/test_pr_checks_pending.py diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 5cde397..f713329 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "agent-observer", - "version": "1.4.0", + "version": "1.4.1", "description": "Measure agent work across Codex, Claude Code, OpenCode and Grok Build from native records, and compare behavior by model and AgentsMD version.", "author": { "name": "toolboxmd", diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 6abc112..2b98f8b 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "agent-observer", - "version": "1.4.0", + "version": "1.4.1", "description": "Measure agent work across Codex, Claude Code, OpenCode and Grok Build from native records, and compare behavior by model and AgentsMD version.", "author": { "name": "toolboxmd", diff --git a/.grok-plugin/plugin.json b/.grok-plugin/plugin.json index 95702a8..e1a9590 100644 --- a/.grok-plugin/plugin.json +++ b/.grok-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "agent-observer", - "version": "1.4.0", + "version": "1.4.1", "description": "Measure agent work across Codex, Claude Code, OpenCode and Grok Build from native records, and compare behavior by model and AgentsMD version.", "author": { "name": "toolboxmd", diff --git a/CHANGELOG.md b/CHANGELOG.md index e811c88..011697a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [1.4.1] - 2026-10-19 + +### Changed + +- Treat gh pr checks exit 8 as pending and correct existing ledgers + ## [1.4.0] - 2026-10-19 ### Added diff --git a/VERSION b/VERSION index 88c5fb8..347f583 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.4.0 +1.4.1 diff --git a/agent_observer/analysis.py b/agent_observer/analysis.py index 53c79e9..4f3f028 100644 --- a/agent_observer/analysis.py +++ b/agent_observer/analysis.py @@ -73,6 +73,8 @@ def _changed_paths(event) -> set: def _failed(row) -> bool: + if row["status"] == "pending": + return False if row["status"] in ("error", "failed", "failure"): return True detail = _detail(row) diff --git a/agent_observer/command_status.py b/agent_observer/command_status.py new file mode 100644 index 0000000..7fabe97 --- /dev/null +++ b/agent_observer/command_status.py @@ -0,0 +1,28 @@ +"""Command-specific result semantics, shared by ingest and ledger correction.""" + +import shlex + + +def pending_pr_checks(family, target, detail) -> bool: + """Only a standalone gh pr checks exit 8 means checks are pending. + + Compound shell commands can return a different command's exit code, so + they cannot establish this result. Never infer it from output text. + """ + if family != "tool_result" or not isinstance(target, str): + return False + if detail.get("exit_code", detail.get("exitCode")) != 8: + return False + if any(part in target for part in ("\n", "`", "$(")): + return False + try: + lexer = shlex.shlex(target, posix=True, punctuation_chars=";&|()<>") + lexer.whitespace_split = True + lexer.commenters = "" + words = list(lexer) + except ValueError: + return False + return (len(words) >= 3 and words[0].rsplit("/", 1)[-1] == "gh" + and words[1:3] == ["pr", "checks"] + and not any(word and all(c in ";&|()<>" for c in word) + for word in words[3:])) diff --git a/agent_observer/db.py b/agent_observer/db.py index 356b23c..a4a45e9 100644 --- a/agent_observer/db.py +++ b/agent_observer/db.py @@ -10,6 +10,7 @@ import errno import fcntl +import json import os import sqlite3 import sys @@ -17,6 +18,7 @@ from pathlib import Path from . import CAPTURE_CONTRACT_VERSION, EVENT_CONTRACT_VERSION, SCHEMA_VERSION +from .command_status import pending_pr_checks DEFAULT_DB = os.path.join( os.path.expanduser("~"), ".local", "state", "agent-observer", "observer.db") @@ -706,6 +708,19 @@ def init_db(con: sqlite3.Connection) -> None: (str(SCHEMA_VERSION), str(EVENT_CONTRACT_VERSION), str(CAPTURE_CONTRACT_VERSION)), ) + # One-time correction also covers ledger-only events whose source is gone. + if not con.execute("SELECT 1 FROM schema_meta WHERE key='pr_checks_pending_version'").fetchone(): + for event in con.execute( + "SELECT id, family, target, detail_json FROM events" + " WHERE family='tool_result' AND detail_json LIKE '%8%'"): + try: + detail = json.loads(event["detail_json"]) + except (ValueError, TypeError): + continue + if isinstance(detail, dict) and pending_pr_checks( + event["family"], event["target"], detail): + con.execute("UPDATE events SET status='pending' WHERE id=?", (event["id"],)) + con.execute("INSERT INTO schema_meta(key, value) VALUES ('pr_checks_pending_version', '1')") con.commit() try: row = con.execute("PRAGMA database_list").fetchone() diff --git a/agent_observer/ingest.py b/agent_observer/ingest.py index 0182ff4..6c5e0c3 100644 --- a/agent_observer/ingest.py +++ b/agent_observer/ingest.py @@ -25,6 +25,7 @@ import time from . import privacy +from .command_status import pending_pr_checks from .db import now TAIL_BYTES = 4096 @@ -333,6 +334,8 @@ def insert_event(con: sqlite3.Connection, stats: dict, *, source_id: int, safe_status = privacy.filter_event_status(family, status) safe_target = privacy.filter_target(target, family) filtered = privacy.filter_detail(family, detail) + if pending_pr_checks(family, safe_target, filtered): + safe_status = "pending" # The size bound applies before serialization: shrinking list values # (only paths can grow large) keeps detail_json always valid JSON. # Slicing serialized JSON could leave an invalid truncated value. diff --git a/agent_observer/privacy.py b/agent_observer/privacy.py index 9d2e3f7..0abc4f7 100644 --- a/agent_observer/privacy.py +++ b/agent_observer/privacy.py @@ -733,7 +733,7 @@ def filter_event_name(family: str, value: object) -> str | None: _HTTP_STATUS_FAMILIES = frozenset({"lifecycle", "tool_result"}) EVENT_STATUS_ENUMS: dict[str, frozenset] = { "tool_call": _COMMON_STATUS | frozenset({"cancelled"}), - "tool_result": _COMMON_STATUS | frozenset({"cancelled"}), + "tool_result": _COMMON_STATUS | frozenset({"cancelled", "pending"}), "read": _COMMON_STATUS | frozenset({"cancelled", "attempted", "pending"}), "skill_read": _COMMON_STATUS | frozenset({"cancelled", "attempted", "pending"}), "lifecycle": frozenset( diff --git a/tests/fixtures/codex-pr-checks-pending.jsonl b/tests/fixtures/codex-pr-checks-pending.jsonl new file mode 100644 index 0000000..ba31e51 --- /dev/null +++ b/tests/fixtures/codex-pr-checks-pending.jsonl @@ -0,0 +1,2 @@ +{"timestamp":"2026-10-15T08:20:49Z","type":"session_meta","payload":{"id":"pr-checks-pending","originator":"codex-tui","thread_source":"user"}} +{"timestamp":"2026-10-15T08:23:09.118Z","type":"event_msg","payload":{"type":"item_completed","item":{"type":"CommandExecution","id":"pending-checks","command":["/usr/bin/bash","-lc","gh pr checks 353 --repo toolboxmd/chromeria"],"status":"failed","exit_code":8,"duration":{"secs":0,"nanos":866773325}}}} diff --git a/tests/test_pr_checks_pending.py b/tests/test_pr_checks_pending.py new file mode 100644 index 0000000..8b35226 --- /dev/null +++ b/tests/test_pr_checks_pending.py @@ -0,0 +1,87 @@ +"""Regression from bestia ledger event 363608 (native exit-8 command). + +The fixture retains the command, status, exit code and duration. Session and +item identities are replaced; cwd, process id and all output are omitted. +""" + +import json +from pathlib import Path + +from agent_observer import analysis, db, pricing, report +from agent_observer.adapters.codex import import_codex_file +from tests.helpers import LedgerCase, fixture + + +class PendingChecksTest(LedgerCase): + def commands(self, commands): + rows = [json.loads(line) for line in Path( + fixture("codex-pr-checks-pending.jsonl")).read_text().splitlines()] + path = Path(self.tmp.name) / "pending.jsonl" + events = [rows[0]] + for index, (command, code) in enumerate(commands): + event = json.loads(json.dumps(rows[1])) + item = event["payload"]["item"] + item.update(id=f"check-{index}", command=["/usr/bin/bash", "-lc", command], + exit_code=code) + events.append(event) + path.write_text("".join(json.dumps(row) + "\n" for row in events)) + import_codex_file(self.con, str(path)) + return path + + def test_real_exit8_is_pending_and_not_a_failure(self): + command = "gh pr checks 353 --repo toolboxmd/chromeria" + self.commands([(command, 8)] * 2) + events = self.query("SELECT * FROM events WHERE family='tool_result'") + self.assertEqual(len(events), 2) + self.assertEqual([e["status"] for e in events], ["pending", "pending"]) + self.assertFalse(any(analysis._failed(e) for e in events)) + self.assertFalse(any(analysis._passed(e) for e in events)) + self.sync("codex-mini.jsonl") + usage = dict(self.query("SELECT * FROM responses LIMIT 1")[0]) + usage["session_key"] = "codex:pr-checks-pending" + phases, _ = report._phase_report( + self.con, "unbound-task", [usage], {usage["session_key"]}, [], + pricing.load_schedule()) + self.assertEqual(phases[0]["activity"]["failed_tool_results"], 0) + result = analysis.diagnose(self.con, detectors=["repeated_failure"]) + self.assertEqual(result["counts"].get("repeated_failure", 0), 0) + + def test_other_nonzero_exits_still_fail(self): + commands = [("gh pr checks 353", 1), ("gh pr checks 353", 1), + ("pytest", 8), + ("echo gh pr checks 353", 8), + ("gh pr checks-other", 8), + ("gh pr checks 353; pytest", 8), + ("gh pr checks 353 && pytest", 8), + ("gh pr checks 353 | cat", 8), + ("gh pr checks $(echo 353)", 8), + ("gh pr checks 353\npytest", 8), + ("gh pr checks \'", 8)] + self.commands(commands) + events = self.query("SELECT * FROM events WHERE family='tool_result'") + self.assertEqual(len(events), len(commands)) + self.assertTrue(all(analysis._failed(e) for e in events)) + self.assertTrue(all(e["status"] == "failed" for e in events)) + result = analysis.diagnose(self.con, detectors=["repeated_failure"]) + self.assertEqual(result["counts"].get("repeated_failure"), 1) + + def test_existing_rows_corrected_without_native_replay(self): + path = self.commands([("gh pr checks 353", 8), ("gh pr checks 353", 1), + ("pytest", 8)]) + # Simulate the previous release's rows and absent correction marker. + self.con.execute("UPDATE events SET status='failed' WHERE family='tool_result'") + self.con.execute("DELETE FROM schema_meta WHERE key='pr_checks_pending_version'") + self.con.commit() + path.unlink() # Existing ledgers must not need retained native sources. + db.init_db(self.con) + events = self.query("SELECT * FROM events WHERE family='tool_result' ORDER BY id") + self.assertEqual([e["status"] for e in events], ["pending", "failed", "failed"]) + db.init_db(self.con) + self.assertEqual(self.query("SELECT COUNT(*) FROM events")[0][0], 3) + + def test_quoted_arguments_and_full_replay_stay_pending(self): + path = self.commands([('/usr/bin/gh pr checks 353 --repo "owner/repo"', 8)]) + import_codex_file(self.con, str(path), full=True) + event = self.query("SELECT * FROM events WHERE family='tool_result'")[0] + self.assertEqual(event["status"], "pending") + self.assertEqual(json.loads(event["detail_json"])["exit_code"], 8) From 090e87cdc478b31b4f6516be4be64d18b6c3354f Mon Sep 17 00:00:00 2001 From: Luke Maj Date: Mon, 19 Oct 2026 15:53:04 +0200 Subject: [PATCH 2/2] fix: classify status-producing gh checks in stored scripts --- agent_observer/command_status.py | 151 +++++++++++++++++-- agent_observer/db.py | 6 +- tests/fixtures/pr-checks-stored-targets.json | 22 +++ tests/test_pr_checks_pending.py | 43 ++++++ 4 files changed, 207 insertions(+), 15 deletions(-) create mode 100644 tests/fixtures/pr-checks-stored-targets.json diff --git a/agent_observer/command_status.py b/agent_observer/command_status.py index 7fabe97..e94764b 100644 --- a/agent_observer/command_status.py +++ b/agent_observer/command_status.py @@ -1,28 +1,153 @@ """Command-specific result semantics, shared by ingest and ledger correction.""" +import re import shlex +# Keep operators distinct from quoted words, including quoted ';' and '|'. +_SHELL_TOKEN = re.compile(r""" + (?P[\t\r ]+) + | (?P\n) + | (?P\#[^\n]*) + | (?P&&|\|\||<<-?|>>|>&|<&|[;&|()<>]) + | (?P(?:'[^']*'|"(?:\\[\s\S]|[^"\\])*"|\\[\s\S]|[^\s'"\\;&|()<>])+) +""", re.VERBOSE) + + +def _tokens(script): + """Lex the supported shell list syntax without executing any input. + + Heredoc bodies are data: even unquoted operators there do not split the + script. Retain raw words so quoting cannot turn data into an operator. + """ + result, heredocs = [], [] + offset = 0 + delimiter = None + while offset < len(script): + match = _SHELL_TOKEN.match(script, offset) + if match is None: + raise ValueError("unsupported or unclosed shell word") + offset = match.end() + kind, value = match.lastgroup, match.group() + if kind in ("space", "comment"): + continue + if kind == "word" and delimiter is not None: + words = shlex.split(value) + if len(words) != 1: + raise ValueError("invalid heredoc delimiter") + heredocs.append((words[0], delimiter == "<<-")) + delimiter = None + elif kind == "operator" and value in ("<<", "<<-"): + delimiter = value + result.append((kind, value)) + if kind == "newline": + for end, strip_tabs in heredocs: + while True: + if offset >= len(script): + raise ValueError("unclosed heredoc") + line_end = script.find("\n", offset) + line_end = len(script) if line_end < 0 else line_end + 1 + line = script[offset:line_end].rstrip("\n") + offset = line_end + if (line.lstrip("\t") if strip_tabs else line) == end: + break + heredocs.clear() + if delimiter is not None or heredocs: + raise ValueError("unclosed heredoc") + return result + + +def _last_chain(tokens): + """A sequence's last AND-list owns its status, including short circuits.""" + chain, command, last = [], [], [] + for kind, value in tokens: + if kind == "newline" and not command: + continue # Blank lines or continuation after &&. + if kind == "newline" or (kind == "operator" and value == ";"): + last = chain + [command] + chain, command = [], [] + elif kind == "operator" and value == "&&": + if not command: + raise ValueError("empty AND command") + chain.append(command) + command = [] + else: + command.append((kind, value)) + if chain and not command: + raise ValueError("unfinished AND-list") + return chain + [command] if command else last + + +def _command_words(tokens): + words = [] + index = 0 + while index < len(tokens): + kind, raw = tokens[index] + if kind == "operator": + # Redirections do not replace the status-producing command. + if raw not in ("<", ">", ">>", "<&", ">&"): + return [] # Pipelines, OR, subshells and other unsupported syntax. + if index + 1 >= len(tokens) or tokens[index + 1][0] != "word": + return [] + if words and words[-1].isdigit(): + words.pop() # Optional file descriptor, e.g. 2>file. + index += 2 + continue + if "$(" in raw or "`" in raw: + return [] # A substitution can itself fail; no output inference. + words.extend(shlex.split(raw)) + index += 1 + return words + + +def _known_gate(words): + """The observed AND-list gates cannot themselves explain exit 8. + + Limit this to git's status/rev-parse and gh pr view without jq/template + execution or a browser. Unknown gates (notably pytest) stay ambiguous. + """ + if not words: + return False + executable, args = words[0].rsplit("/", 1)[-1], words[1:] + if executable == "git": + if args[:1] == ["-C"] and len(args) >= 3: + args = args[2:] + return args == ["rev-parse", "HEAD"] or args in ( + ["status", "--porcelain=v1"], ["status", "--short"]) + if executable == "gh" and args[:2] == ["pr", "view"]: + args = args[2:] + while args: + if args[0] in ("-R", "--repo", "--json") and len(args) >= 2: + args = args[2:] + elif args[0].isdigit(): + args = args[1:] + else: + return False + return True + return False + + def pending_pr_checks(family, target, detail) -> bool: - """Only a standalone gh pr checks exit 8 means checks are pending. + """Recognize exit 8 only when gh pr checks can account for that status. - Compound shell commands can return a different command's exit code, so - they cannot establish this result. Never infer it from output text. + Earlier commands separated by newline or ';' do not own the result. + In an AND-list any failing command can own it, so unknown gates prevent + classification. Quoting and heredocs never create command separators. """ if family != "tool_result" or not isinstance(target, str): return False if detail.get("exit_code", detail.get("exitCode")) != 8: return False - if any(part in target for part in ("\n", "`", "$(")): - return False try: - lexer = shlex.shlex(target, posix=True, punctuation_chars=";&|()<>") - lexer.whitespace_split = True - lexer.commenters = "" - words = list(lexer) + chain = _last_chain(_tokens(target)) + commands = [_command_words(command) for command in chain] except ValueError: return False - return (len(words) >= 3 and words[0].rsplit("/", 1)[-1] == "gh" - and words[1:3] == ["pr", "checks"] - and not any(word and all(c in ";&|()<>" for c in word) - for word in words[3:])) + pending = False + for words in commands: + if (len(words) >= 3 and words[0].rsplit("/", 1)[-1] == "gh" + and words[1:3] == ["pr", "checks"]): + pending = True + elif not _known_gate(words): + return False + return pending diff --git a/agent_observer/db.py b/agent_observer/db.py index a4a45e9..f81a9f0 100644 --- a/agent_observer/db.py +++ b/agent_observer/db.py @@ -709,7 +709,8 @@ def init_db(con: sqlite3.Connection) -> None: str(CAPTURE_CONTRACT_VERSION)), ) # One-time correction also covers ledger-only events whose source is gone. - if not con.execute("SELECT 1 FROM schema_meta WHERE key='pr_checks_pending_version'").fetchone(): + if not con.execute("SELECT 1 FROM schema_meta WHERE key='pr_checks_pending_version'" + " AND value='2'").fetchone(): for event in con.execute( "SELECT id, family, target, detail_json FROM events" " WHERE family='tool_result' AND detail_json LIKE '%8%'"): @@ -720,7 +721,8 @@ def init_db(con: sqlite3.Connection) -> None: if isinstance(detail, dict) and pending_pr_checks( event["family"], event["target"], detail): con.execute("UPDATE events SET status='pending' WHERE id=?", (event["id"],)) - con.execute("INSERT INTO schema_meta(key, value) VALUES ('pr_checks_pending_version', '1')") + con.execute("INSERT OR REPLACE INTO schema_meta(key, value)" + " VALUES ('pr_checks_pending_version', '2')") con.commit() try: row = con.execute("PRAGMA database_list").fetchone() diff --git a/tests/fixtures/pr-checks-stored-targets.json b/tests/fixtures/pr-checks-stored-targets.json new file mode 100644 index 0000000..a7e7e19 --- /dev/null +++ b/tests/fixtures/pr-checks-stored-targets.json @@ -0,0 +1,22 @@ +[ + { + "ledger_id": 860917, + "target": "tail -8 /tmp/sanitized-tests.log; gh pr checks 114 --repo toolboxmd/agent-observer" + }, + { + "ledger_id": 145983, + "target": "gh issue view 284 -R toolboxmd/chromeria --json body,comments\ngh pr checks 243 -R toolboxmd/chromeria\ngh pr checks 52 -R toolboxmd/contextmd" + }, + { + "ledger_id": 288772, + "target": "tail -12 /tmp/sanitized-proof.log\n gh pr checks 335 --repo toolboxmd/chromeria" + }, + { + "ledger_id": 90306, + "target": "sed 's@./chromeria/apps/@./chromeria-main/apps/@' /tmp/sanitized/probe.mjs > /tmp/sanitized/probe.base.mjs\nnode /tmp/sanitized/probe.base.mjs /tmp/sanitized/commands.json > /tmp/sanitized/results.json\npython3 - <<'PY'\n# Original Python body omitted; heredoc structure retained.\npass\nPY\ngh pr checks 243 -R toolboxmd/chromeria > /tmp/sanitized/chrom-checks.txt\ngh pr checks 52 -R toolboxmd/contextmd > /tmp/sanitized/context-checks.txt" + }, + { + "ledger_id": 894845, + "target": "git -C /tmp/sanitized/repo rev-parse HEAD && git -C /tmp/sanitized/repo status --porcelain=v1 && gh pr view 106 -R toolboxmd/from --json headRefOid,baseRefOid,state,url && gh pr checks 106 -R toolboxmd/from" + } +] diff --git a/tests/test_pr_checks_pending.py b/tests/test_pr_checks_pending.py index 8b35226..e9fb13a 100644 --- a/tests/test_pr_checks_pending.py +++ b/tests/test_pr_checks_pending.py @@ -9,6 +9,7 @@ from agent_observer import analysis, db, pricing, report from agent_observer.adapters.codex import import_codex_file +from agent_observer.command_status import pending_pr_checks from tests.helpers import LedgerCase, fixture @@ -85,3 +86,45 @@ def test_quoted_arguments_and_full_replay_stay_pending(self): event = self.query("SELECT * FROM events WHERE family='tool_result'")[0] self.assertEqual(event["status"], "pending") self.assertEqual(json.loads(event["detail_json"])["exit_code"], 8) + + def test_real_compound_targets(self): + # Paths replaced; event 90306's Python body omitted, not interpreted. + records = json.loads(Path(fixture("pr-checks-stored-targets.json")).read_text()) + for record in records: + with self.subTest(ledger_id=record["ledger_id"]): + self.assertTrue(pending_pr_checks( + "tool_result", record["target"], {"exit_code": 8})) + + def test_version_one_correction_is_revisited(self): + self.commands([("tail /tmp/proof.log; gh pr checks 114", 8)]) + self.con.execute("UPDATE events SET status='failed' WHERE family='tool_result'") + self.con.execute("INSERT OR REPLACE INTO schema_meta(key,value)" + " VALUES ('pr_checks_pending_version','1')") + self.con.commit() + db.init_db(self.con) + event = self.query("SELECT * FROM events WHERE family='tool_result'")[0] + self.assertEqual(event["status"], "pending") + self.assertEqual(self.query("SELECT value FROM schema_meta WHERE" + " key='pr_checks_pending_version'")[0][0], "2") + + def test_quoting_heredocs_and_ambiguous_status_producers(self): + cases = [ + ("echo 'not a separator; still echo'; gh pr checks 353", True), + ("gh pr checks 353 > '/tmp/checks;pytest'", True), + ("gh pr checks 353 --jq '.[] | .bucket'", True), + ("echo 'gh pr checks 353;'; pytest", False), + ("printf '%s' '&& gh pr checks 353'", False), + ("cat <<'END'\ngh pr checks 353; fake command\nEND\npytest", False), + ("cat <<'END'\npytest; 'unbalanced data\nEND\ngh pr checks 353", True), + ("pytest && gh pr checks 353", False), + ("gh pr checks 353 && pytest", False), + ("git status --short &&\n gh pr checks 353", True), + ("gh pr checks 353 && git status --short", True), + ("gh pr view 353 --jq 'halt_error(8)' && gh pr checks 353", False), + ("gh pr checks 353 | cat", False), + ("gh pr checks 353 || pytest", False), + ] + for target, expected in cases: + with self.subTest(target=target): + self.assertEqual(pending_pr_checks( + "tool_result", target, {"exit_code": 8}), expected)