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
242 lines
9.6 KiB
Python
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]))
|