Files
6krrt/plans/no-progress-detection-prototype.py
adlee-was-taken d532b37442 plans: no-progress detection spec, Lift A brief, prototype, deploy separation, cockpit
The no-progress spec is reference material; plans/no-progress-lift-a.md was
the contract for PR #102. deploy-separation.md is a spec plus a dry-run
cutover runbook; the cutover has not been done.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N9biTbFC63yDfYfUsZmhgd
2026-09-26 15:21:38 -04:00

242 lines
9.6 KiB
Python

#!/usr/bin/env python3
"""Interim opencode loop watcher (stand-in until the no-progress lift ships).
Signals per session, over a sliding window of its last WINDOW tool calls:
dup share of calls that repeat an earlier (tool, args) in the window
top count of the most repeated (tool, args) in the window
landed any edit/write with a non-empty diff, or a `git commit` with exit 0,
by the session OR any descendant within the window's time span
Flag when the window is full enough and nothing landed and
dup >= DUP_MIN or top >= TOP_MIN.
Read-only agents (explore/librarian/oracle) never land, so for them only
top >= TOP_MIN_RO counts.
Modes:
backtest : replay every session's history, report worst window per session
watch : poll live sessions; on a new flag print it, notify-send, exit 0
"""
import json, sys, time, subprocess, urllib.request, collections, os
U = os.environ.get("OC_URL", "http://127.0.0.1:4097")
WINDOW, MIN_CALLS = 60, 40
DUP_MIN, TOP_MIN, TOP_MIN_RO = 0.25, 12, 8
CUM_MIN = 15 # one exact call repeated this often over the whole session: a slow loop
COVER_MIN = 4.0 # lines read from one file in the window / its length: re-reading, whatever the offsets
READ_ONLY = ("@explore", "@librarian", "@oracle", "explore subagent")
def get(path):
return json.load(urllib.request.urlopen(U + path, timeout=30))
def calls_of(sid):
out = []
for m in get(f"/session/{sid}/message"):
for p in m["parts"]:
if p.get("type") != "tool":
continue
st = p.get("state") or {}
inp = st.get("input") or {}
md = st.get("metadata") or {}
t = (st.get("time") or {}).get("start") or m["info"].get("time", {}).get("created", 0)
tool = p.get("tool")
landed = (tool in ("edit", "write", "patch") and bool(md.get("diff"))) or (
tool == "bash" and "git commit" in (inp.get("command") or "") and md.get("exit") == 0)
out.append((t, tool, json.dumps(inp, sort_keys=True), landed))
return out
import re
_PATHISH = re.compile(r"[\w./-]+\.(?:json|md|py|js|html|yaml|yml|toml|txt|sql|sh|mjs|css)\b")
_CD_PREFIX = re.compile(r"^\s*cd\s+\S+\s*(?:&&|;)\s*")
_ENV_PREFIX = re.compile(r"^\s*(?:[A-Z_][A-Z0-9_]*=\S+\s+)+")
def _normalize_bash(cmd):
"""Drop comment lines and leading `cd dir &&` / `VAR=x` prefixes, so
differently-worded commands under one prefix do not collapse to one key."""
lines = [ln for ln in cmd.splitlines() if not ln.lstrip().startswith("#")]
c = " ".join(lines).strip()
for _ in range(3):
c2 = _ENV_PREFIX.sub("", _CD_PREFIX.sub("", c))
if c2 == c:
break
c = c2
return c
def target_of(tool, args_json):
"""Coarse target: the file a call is about, ignoring offsets and the exact
command wording, so 28 differently-worded `cat boulder.json` count as one."""
try:
a = json.loads(args_json)
except Exception:
return (tool, args_json[:80])
if a.get("filePath"):
# A read keys on its slice: walking a big file in pieces is progress,
# re-reading the same slice is not.
return ("file", os.path.basename(a["filePath"]), a.get("offset"), a.get("limit")) if tool == "read" \
else ("file", os.path.basename(a["filePath"]))
if tool == "bash":
cmd = _normalize_bash(a.get("command") or "")
files = sorted({os.path.basename(f) for f in _PATHISH.findall(cmd)})
if files:
# Numbers in the command are the slice (sed -n 100,160p, head -40):
# walking a file is progress; the same slice again is not.
nums = tuple(sorted(set(re.findall(r"\b\d+\b", cmd))))
return ("bash-files", ",".join(files[:3]), nums)
return ("bash", " ".join(cmd.split()[:2]))
if tool in ("grep", "glob"):
return (tool, str(a.get("pattern"))[:60])
return (tool, args_json[:80])
def window_stats(win):
fp = collections.Counter((c[1], c[2]) for c in win)
dup = sum(v - 1 for v in fp.values() if v > 1) / max(1, len(win))
tg = collections.Counter(target_of(c[1], c[2]) for c in win)
(top_k, top_n) = tg.most_common(1)[0] if tg else (("", ""), 0)
return dup, top_n, top_k
_LEN = {}
def file_lines(path):
if path not in _LEN:
try:
with open(path, "rb") as fh:
_LEN[path] = max(1, fh.read().count(b"\n"))
except OSError:
_LEN[path] = None
return _LEN[path]
def coverage(win):
"""Max over files of (lines requested in the window / file length)."""
req = collections.Counter()
for c in win:
if c[1] != "read":
continue
a = json.loads(c[2])
fp = a.get("filePath")
n = file_lines(fp) if fp else None
if not n:
continue
req[fp] += min(a.get("limit") or n, n)
best = max(((v / file_lines(k), k) for k, v in req.items()), default=(0.0, ""))
return round(best[0], 1), os.path.basename(best[1])
def is_ro(title):
t = (title or "").lower()
return any(k in t for k in READ_ONLY)
def evaluate(sess, calls, landed_times_tree, end_idx=None):
calls = calls if end_idx is None else calls[:end_idx]
win = calls[-WINDOW:]
if len(win) < MIN_CALLS:
return None
t0, t1 = win[0][0], win[-1][0]
landed = any(t0 <= lt <= t1 for lt in landed_times_tree)
dup, top_n, top_k = window_stats(win)
ro = is_ro(sess.get("title"))
cum_k, cum_n = collections.Counter((c[1], c[2]) for c in calls).most_common(1)[0]
slow = (not landed) and cum_n >= CUM_MIN
cov, cov_file = coverage(win)
reread = (not landed) and cov >= COVER_MIN
if reread and not (top_n >= TOP_MIN):
top_n, top_k = int(cov), ("coverage", f"{cov_file} read {cov}x its length")
if ro:
flag = top_n >= TOP_MIN_RO or slow or reread
else:
flag = ((not landed) and (dup >= DUP_MIN or top_n >= TOP_MIN)) or slow or reread
if slow and not (top_n >= TOP_MIN):
top_n, top_k = cum_n, ("exact-total", cum_k[0], cum_k[1][:80])
return dict(flag=flag, dup=round(dup, 2), top=top_n, top_what=" ".join(str(x) for x in top_k)[:120],
landed=landed, ro=ro, n=len(calls), t1=t1)
def tree_landed(sessions, calls_by):
kids = collections.defaultdict(list)
for s in sessions:
if s.get("parentID"):
kids[s["parentID"]].append(s["id"])
memo = {}
def lt(sid):
if sid in memo:
return memo[sid]
own = [c[0] for c in calls_by.get(sid, []) if c[3]]
for k in kids.get(sid, []):
own += lt(k)
memo[sid] = own
return own
return lt
def backtest(since_ms):
sessions = [s for s in get("/session") if s.get("time", {}).get("updated", 0) >= since_ms]
calls_by = {s["id"]: calls_of(s["id"]) for s in sessions}
lt = tree_landed(sessions, calls_by)
for s in sessions:
cs = calls_by[s["id"]]
worst, first_flag = None, None
for i in range(MIN_CALLS, len(cs) + 1, 5):
r = evaluate(s, cs, lt(s["id"]), i)
if r and (worst is None or (r["flag"], r["dup"], r["top"]) > (worst["flag"], worst["dup"], worst["top"])):
worst = r
if r and r["flag"] and first_flag is None:
first_flag = i
if worst:
print(f"{'FLAG' if worst['flag'] else 'ok '} calls={len(cs):4d} first_flag_at={first_flag} "
f"dup={worst['dup']} top={worst['top']} landed={worst['landed']} ro={worst['ro']} | {s.get('title','')[:50]}"
+ (f"\n top: {worst['top_what'][:110]}" if worst['flag'] else ""))
def watch(minutes, state_path):
seen = set()
if os.path.exists(state_path):
seen = set(json.load(open(state_path)))
deadline = time.time() + minutes * 60
while time.time() < deadline:
try:
now = time.time() * 1000
sessions = get("/session")
status = get("/session/status")
recent = [s for s in sessions if s["id"] in status or now - s.get("time", {}).get("updated", 0) < 5 * 60e3]
calls_by = {s["id"]: calls_of(s["id"]) for s in recent}
# Judge only sessions doing work now: busy, or a tool call in the
# last 5 min. A session's "updated" stamp moves on abort, restart
# and title edits, so it cannot stand in for activity.
active = [s for s in recent if s["id"] in status
or (calls_by[s["id"]] and now - calls_by[s["id"]][-1][0] < 5 * 60e3)]
lt = tree_landed(sessions, calls_by)
for s in active:
r = evaluate(s, calls_by[s["id"]], lt(s["id"]))
key = f"{s['id']}:{len(calls_by[s['id']]) // 60}"
if r and r["flag"] and key not in seen:
seen.add(key)
json.dump(sorted(seen), open(state_path, "w"))
msg = (f"{s.get('title','')[:60]} ({s['id']}): {r['n']} calls, dup {r['dup']}, "
f"top x{r['top']}: {r['top_what']}, landed={r['landed']}, busy={s['id'] in status}")
print("LOOP SUSPECT:", msg)
subprocess.run(["notify-send", "-u", "critical", "opencode loop suspected", msg[:250]],
check=False, timeout=5)
return 0
except Exception as e: # opencode down or restarting: keep watching
print(f"[{time.strftime('%H:%M:%S')}] poll error: {e}", file=sys.stderr)
time.sleep(120)
print("no loops seen in", minutes, "min")
return 0
if __name__ == "__main__":
if sys.argv[1] == "backtest":
backtest(int(sys.argv[2]))
else:
sys.exit(watch(int(sys.argv[2]), sys.argv[3]))