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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion .codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion .grok-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.4.0
1.4.1
2 changes: 2 additions & 0 deletions agent_observer/analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
153 changes: 153 additions & 0 deletions agent_observer/command_status.py
Original file line number Diff line number Diff line change
@@ -0,0 +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<space>[\t\r ]+)
| (?P<newline>\n)
| (?P<comment>\#[^\n]*)
| (?P<operator>&&|\|\||<<-?|>>|>&|<&|[;&|()<>])
| (?P<word>(?:'[^']*'|"(?:\\[\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:
"""Recognize exit 8 only when gh pr checks can account for that status.

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
try:
chain = _last_chain(_tokens(target))
commands = [_command_words(command) for command in chain]
except ValueError:
return False
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
17 changes: 17 additions & 0 deletions agent_observer/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,15 @@

import errno
import fcntl
import json
import os
import sqlite3
import sys
import time
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")
Expand Down Expand Up @@ -706,6 +708,21 @@ 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'"
" 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%'"):
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 OR REPLACE INTO schema_meta(key, value)"
" VALUES ('pr_checks_pending_version', '2')")
con.commit()
try:
row = con.execute("PRAGMA database_list").fetchone()
Expand Down
3 changes: 3 additions & 0 deletions agent_observer/ingest.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
import time

from . import privacy
from .command_status import pending_pr_checks
from .db import now

TAIL_BYTES = 4096
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion agent_observer/privacy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 2 additions & 0 deletions tests/fixtures/codex-pr-checks-pending.jsonl
Original file line number Diff line number Diff line change
@@ -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}}}}
22 changes: 22 additions & 0 deletions tests/fixtures/pr-checks-stored-targets.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
Loading
Loading